A practical guide to structuring Go backends for maintainability and testability
As our social media backend grew from a handful of endpoints to over a hundred, we needed an
architecture that would scale with the team—not just with traffic. This post explains how we apply
Clean Architecture principles to keep our Go codebase maintainable, testable, and enjoyable to
work with.
The Problem with "Just Ship It"
Early-stage startups often start with everything in one package: HTTP handlers that talk directly to
the database, business logic scattered across files, and infrastructure code mixed with domain rules.
It works... until it doesn't.
We hit these pain points:
• Testing was painful — mocking the database in every handler test
• Changes rippled everywhere — updating a database schema required touching HTTP
handlers
• Onboarding was slow — new engineers couldn't find where things lived
Clean Architecture solved these problems by giving every piece of code a clear home.
The Layer Cake
Our codebase follows four distinct layers:
internal/
├── domain/ # Business logic (the "what")
├── store/ # Data persistence (the "where")
├── transport/ # HTTP handlers (the "how users interact")
├── platform/ # Infrastructure (the "how it runs")
└── app/ # Wiring everything together
Here's how the dependencies flow:
┌─────────────────────────────────────────────────────────────────┐
│ internal/app/ │
│ (Wiring & Bootstrap) │
│ Creates concrete implementations, wires dependencies together │
└─────────────────────────────────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ transport/ │ │ domain/ │ │ store/ │ │ platform/ │
│ (HTTP) │ │ (Business) │ │ (Data) │ │ (Infra) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ ▲ │ │
│ │ │ │
└──────────────┘ └──────────────┘
depends on implements
┌─────────────────────────────────────────────────────────────────┐
│ Dependency Rule: ││ │
│ transport/ ──► domain/ ◄── store/ │
│ ▲ │
│ │ │
│ platform/ │
│ │
│ • Domain defines interfaces (what it needs) │
│ • Store & Platform implement those interfaces │
│ • Transport calls domain services │
│ • App wires everything together │
└─────────────────────────────────────────────────────────────────┘
The Golden Rule: Dependencies point inward. Domain knows nothing about HTTP or databases.
Transport knows about domain but not store. This keeps your core business logic portable and
testable.
Layer 1: Domain — The Heart of Your Application
The domain layer contains pure business logic with zero infrastructure dependencies. No
database imports, no HTTP frameworks—just Go structs, interfaces, and functions.
Let's build a notification system for a social media platform as our example.
Services with Options
Every domain service follows the same pattern:
// internal/domain/notification/service.go
type ServiceOptions struct {
DedupeWindow time.Duration
MaxPerPage int
}
type Service struct {
notifications NotificationRepository
tokens PushTokenRepository
publisher EventPublisher
opts ServiceOptions
}
func NewService(
notifications NotificationRepository,
tokens PushTokenRepository,
publisher EventPublisher,
opts ServiceOptions,
) *Service {
// Apply sensible defaults
if opts.DedupeWindow <= 0 {
opts.DedupeWindow = 5 * time.Minute
}
if opts.MaxPerPage <= 0 {
opts.MaxPerPage = 20
}
return &Service{
notifications: notifications,
tokens: tokens,
publisher: publisher,
opts: opts,
}
}Why this pattern works:
• ServiceOptions makes configuration explicit and discoverable
• Defaults in the constructor prevent nil-pointer surprises
• Dependencies are interfaces, not concrete types
Interfaces Defined at the Consumer
This is the most important principle we follow. Interfaces live in the domain package—where
they're used—not in the store package where they're implemented.
// internal/domain/notification/service.go
// NotificationRepository is defined HERE, not in store/postgres/
type NotificationRepository interface {
Create(ctx context.Context, n *Notification) error
GetByID(ctx context.Context, userID, notifID string) (*Notification, error)
ListByUser(ctx context.Context, userID string, limit int, cursor string)
([]Notification, string, error)
MarkRead(ctx context.Context, userID, notifID string) error
MarkAllRead(ctx context.Context, userID string) error
CountUnread(ctx context.Context, userID string) (int, error)
ExistsByDedupeKey(ctx context.Context, userID, dedupeKey string, since
time.Time) (bool, error)
}
type PushTokenRepository interface {
Set(ctx context.Context, userID, token string) error
Get(ctx context.Context, userID string) (string, error)
Delete(ctx context.Context, userID string) error
}
}
type EventPublisher interface {
Publish(ctx context.Context, topic string, payload any) error
Why this matters:
• The domain defines what it needs, not what exists
• Easy to swap implementations (PostgreSQL → MongoDB)
• Tests can provide minimal stub implementations
• No import cycles
Domain Types
Define your core entities in the domain layer:
// internal/domain/notification/notification.go
type Type int
const (
TypeLike Type = iota + 1
TypeComment
TypeFollow
TypeMention
TypeRepost)
type Notification struct {
ID string
UserID string
Type Type
ActorID string
ActorName string
PostID string
Message string
IsRead bool
CreatedAt time.Time
// Domain logic lives on the entity
func (n *Notification) IsExpired(ttl time.Duration) bool {
return time.Since(n.CreatedAt) > ttl
}
}
Domain Errors with Codes
We use typed errors with codes that map cleanly to HTTP status codes:
// internal/domain/notification/errors.go
type ErrorCode string
const (
ErrCodeNotFound ErrorCode = "NOTIFICATION_NOT_FOUND"
ErrCodeAlreadyRead ErrorCode = "NOTIFICATION_ALREADY_READ"
ErrCodeRateLimited ErrorCode = "NOTIFICATION_RATE_LIMITED"
ErrCodeSelfAction ErrorCode = "NOTIFICATION_SELF_ACTION"
)
type Error struct {
Code ErrorCode
Message string
}
func (e *Error) Error() string {
if e.Message != "" {
return e.Message
}
return string(e.Code)
func NewError(code ErrorCode, message string) *Error {
return &Error{Code: code, Message: message}
}
}
// Helper for checking error codes
func CodeOf(err error) (ErrorCode, bool) {
var target *Error
if !errors.As(err, &target) {
return "", false
}
return target.Code, true
}
Domain Logic in the Service
The service contains pure business logic:// internal/domain/notification/service.go
func (s *Service) Notify(ctx context.Context, ev Event) (*Notification, error) {
// Prevent self-notifications
if ev.ActorID == ev.TargetUserID {
return nil, NewError(ErrCodeSelfAction, "cannot notify yourself")
}
// Deduplicate within window
dedupeKey := fmt.Sprintf("%s:%s:%d", ev.ActorID, ev.PostID, ev.Type)
exists, err := s.notifications.ExistsByDedupeKey(ctx, ev.TargetUserID,
dedupeKey, time.Now().Add(-s.opts.DedupeWindow))
if err != nil {
return nil, fmt.Errorf("check dedupe: %w", err)
}
if exists {
return nil, nil // Already notified recently
}
// Create notification
notif := &Notification{
ID: generateID(),
UserID: ev.TargetUserID,
Type: ev.Type,
ActorID: ev.ActorID,
ActorName: ev.ActorName,
PostID: ev.PostID,
Message: s.buildMessage(ev),
IsRead: false,
CreatedAt: time.Now().UTC(),
if err := s.notifications.Create(ctx, notif); err != nil {
return nil, fmt.Errorf("create notification: %w", err)
}
}
// Send push notification async
token, _ := s.tokens.Get(ctx, ev.TargetUserID)
if token != "" && s.publisher != nil {
_ = s.publisher.Publish(ctx, "push.send", PushPayload{
Token: token,
Title: s.titleForType(ev.Type),
Body: notif.Message,
Data: map[string]string{"notificationId": notif.ID},
})
}
return notif, nil
}
func (s *Service) buildMessage(ev Event) string {
switch ev.Type {
case TypeLike:
return fmt.Sprintf("%s liked your post", ev.ActorName)
case TypeComment:
return fmt.Sprintf("%s commented on your post", ev.ActorName)
case TypeFollow:
return fmt.Sprintf("%s started following you", ev.ActorName)
case TypeMention:
return fmt.Sprintf("%s mentioned you in a post", ev.ActorName)
case TypeRepost:
return fmt.Sprintf("%s reposted your post", ev.ActorName)
default:
return "You have a new notification"}
}
Layer 2: Store — Where Data Lives
The store layer implements domain interfaces with database-specific code. We use PostgreSQL, but
the domain doesn't know or care.
Repository Structure
// internal/store/postgres/notification_repository.go
type NotificationRepository struct {
db *sql.DB
func NewNotificationRepository(db *sql.DB) *NotificationRepository {
return &NotificationRepository{db: db}
}
}
func (r *NotificationRepository) Create(ctx context.Context, n
*notification.Notification) error {
query := `
INSERT INTO notifications (id, user_id, type, actor_id, actor_name,
post_id, message, is_read, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
_, err := r.db.ExecContext(ctx, query,
n.ID, n.UserID, n.Type, n.ActorID, n.ActorName, n.PostID, n.Message,
n.IsRead, n.CreatedAt,
)
return err
}
func (r *NotificationRepository) GetByID(ctx context.Context, userID, notifID
string) (*notification.Notification, error) {
query := `
SELECT id, user_id, type, actor_id, actor_name, post_id, message,
is_read, created_at
FROM notifications
WHERE user_id = $1 AND id = $2
`
row := r.db.QueryRowContext(ctx, query, userID, notifID)
return r.scanNotification(row)
}
func (r *NotificationRepository) ListByUser(ctx context.Context, userID string,
limit int, cursor string) ([]notification.Notification, string, error) {
query := `
SELECT id, user_id, type, actor_id, actor_name, post_id, message,
is_read, created_at
FROM notifications
WHERE user_id = $1 AND ($2 = '' OR created_at < $2)
ORDER BY created_at DESC
LIMIT $3
`
rows, err := r.db.QueryContext(ctx, query, userID, cursor, limit+1)
if err != nil {
return nil, "", err
}
defer rows.Close()var notifications []notification.Notification
for rows.Next() {
n, err := r.scanNotificationRow(rows)
if err != nil {
return nil, "", err
}
notifications = append(notifications, *n)
}
// Determine next cursor
var nextCursor string
if len(notifications) > limit {
nextCursor = notifications[limit-1].CreatedAt.Format(time.RFC3339Nano)
notifications = notifications[:limit]
}
return notifications, nextCursor, nil
}
func (r *NotificationRepository) scanNotification(row *sql.Row)
(*notification.Notification, error) {
var n notification.Notification
err := row.Scan(&n.ID, &n.UserID, &n.Type, &n.ActorID, &n.ActorName,
&n.PostID, &n.Message, &n.IsRead, &n.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &n, err
}
Caching Layer
For read-heavy data like unread counts, wrap the repository with caching:
// internal/store/postgres/cached_notification_repository.go
type CachedNotificationRepository struct {
repo *NotificationRepository
cache Cache
func NewCachedNotificationRepository(repo *NotificationRepository, cache Cache)
*CachedNotificationRepository {
return &CachedNotificationRepository{repo: repo, cache: cache}
}
}
func (r *CachedNotificationRepository) CountUnread(ctx context.Context, userID
string) (int, error) {
key := "notifications:unread:" + userID
// Try cache first
if cached, ok := r.cache.GetInt(ctx, key); ok {
return cached, nil
}
// Fall back to database
count, err := r.repo.CountUnread(ctx, userID)
if err != nil {
return 0, err
}
// Cache for 30 secondsr.cache.SetInt(ctx, key, count, 30*time.Second)
return count, nil
}
func (r *CachedNotificationRepository) Create(ctx context.Context, n
*notification.Notification) error {
err := r.repo.Create(ctx, n)
if err != nil {
return err
}
// Invalidate unread count cache
r.cache.Delete(ctx, "notifications:unread:"+n.UserID)
return nil
}
func (r *CachedNotificationRepository) MarkRead(ctx context.Context, userID,
notifID string) error {
err := r.repo.MarkRead(ctx, userID, notifID)
if err != nil {
return err
}
// Invalidate unread count cache
r.cache.Delete(ctx, "notifications:unread:"+userID)
return nil
}
Layer 3: Transport — HTTP Handlers
The transport layer handles HTTP concerns: parsing requests, calling domain services, and
formatting responses.
Handler Structure
// internal/transport/http/notification/handler.go
type Handler struct {
service *notification.Service
logger *slog.Logger
}
func NewHandler(service *notification.Service, logger *slog.Logger) *Handler {
if logger == nil {
logger = slog.Default()
}
return &Handler{service: service, logger: logger}
}
Handlers are thin. They:
1. Extract identity from context
2. Parse/validate input
3. Call the domain service
4. Format the response
type ListNotificationsQuery struct {
Limit int `query:"limit" default:"20" doc:"Max items to return"`
Cursor string `query:"cursor" doc:"Pagination cursor"`
}type NotificationResponse struct {
ID string `json:"id"`
Type string `json:"type"`
ActorID string `json:"actorId"`
ActorName string `json:"actorName"`
PostID string `json:"postId,omitempty"`
Message string `json:"message"`
IsRead bool `json:"isRead"`
CreatedAt string `json:"createdAt"`
}
type ListNotificationsResponse struct {
Items []NotificationResponse `json:"items"`
NextCursor string `json:"nextCursor,omitempty"`
}
func (h *Handler) List(ctx context.Context, input *ListNotificationsQuery)
(*struct{ Body ListNotificationsResponse }, error) {
// 1. Auth check
userID, ok := auth.UserIDFromContext(ctx)
if !ok {
return nil, huma.Error401Unauthorized("unauthorized")
}
// 2. Call domain service
notifications, nextCursor, err := h.service.List(ctx, userID, input.Limit,
input.Cursor)
if err != nil {
h.logger.Error("failed to list notifications", "error", err, "userID",
userID)
return nil, huma.Error500InternalServerError("failed to list
notifications")
}
// 3. Map to response
items := make([]NotificationResponse, len(notifications))
for i, n := range notifications {
items[i] = NotificationResponse{
ID: n.ID,
Type: typeToString(n.Type),
ActorID: n.ActorID,
ActorName: n.ActorName,
PostID: n.PostID,
Message: n.Message,
IsRead: n.IsRead,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
}
}
return &struct{ Body ListNotificationsResponse }{
Body: ListNotificationsResponse{
Items: items,
NextCursor: nextCursor,
},
}, nil
}
func (h *Handler) MarkRead(ctx context.Context, input *struct{ NotificationID
string `path:"id"` }) (*struct{}, error) {
userID, ok := auth.UserIDFromContext(ctx)
if !ok {
return nil, huma.Error401Unauthorized("unauthorized")
}err := h.service.MarkRead(ctx, userID, input.NotificationID)
if err != nil {
code, ok := notification.CodeOf(err)
if ok && code == notification.ErrCodeNotFound {
return nil, huma.Error404NotFound("notification not found")
}
return nil, huma.Error500InternalServerError("failed to mark as read")
}
return &struct{}{}, nil
}
Route Registration
Routes are registered separately from handlers with OpenAPI documentation:
// internal/transport/http/notification/routes.go
func RegisterRoutes(api huma.API, h *Handler) {
huma.Get(api, "/notifications", h.List, func(o *huma.Operation) {
o.OperationID = "list-notifications"
o.Tags = []string{"Notifications"}
o.Summary = "List notifications"
o.Description = "Returns notifications for the authenticated user,
newest first."
o.Security = []map[string][]string{{"bearerAuth": {}}}
})
huma.Patch(api, "/notifications/{id}/read", h.MarkRead, func(o
*huma.Operation) {
o.OperationID = "mark-notification-read"
o.Tags = []string{"Notifications"}
o.Summary = "Mark notification as read"
o.Security = []map[string][]string{{"bearerAuth": {}}}
})
huma.Post(api, "/notifications/read-all", h.MarkAllRead, func(o
*huma.Operation) {
o.OperationID = "mark-all-notifications-read"
o.Tags = []string{"Notifications"}
o.Summary = "Mark all notifications as read"
o.Security = []map[string][]string{{"bearerAuth": {}}}
})
huma.Get(api, "/notifications/unread-count", h.UnreadCount, func(o
*huma.Operation) {
o.OperationID = "get-unread-count"
o.Tags = []string{"Notifications"}
o.Summary = "Get unread notification count"
o.Security = []map[string][]string{{"bearerAuth": {}}}
})
}
Layer 4: Platform — Infrastructure Abstractions
The platform layer wraps external services with clean interfaces:
// internal/platform/cache/redis.go
type Cache struct {
client *redis.Client}
func NewRedisCache(addr string) (*Cache, error) {
client := redis.NewClient(&redis.Options{Addr: addr})
if err := client.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("redis ping: %w", err)
}
return &Cache{client: client}, nil
}
func (c *Cache) Get(ctx context.Context, key string) (string, bool) {
val, err := c.client.Get(ctx, key).Result()
if err == redis.Nil {
return "", false
}
if err != nil {
return "", false
}
return val, true
}
func (c *Cache) Set(ctx context.Context, key, value string, ttl time.Duration)
error {
return c.client.Set(ctx, key, value, ttl).Err()
}
func (c *Cache) Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
return c.client.Del(ctx, keys...).Err()
}
// internal/platform/queue/rabbitmq.go
type Publisher struct {
conn *amqp.Connection
channel *amqp.Channel
}
func NewRabbitMQPublisher(url string) (*Publisher, error) {
conn, err := amqp.Dial(url)
if err != nil {
return nil, fmt.Errorf("rabbitmq dial: %w", err)
}
ch, err := conn.Channel()
if err != nil {
return nil, fmt.Errorf("rabbitmq channel: %w", err)
}
return &Publisher{conn: conn, channel: ch}, nil
}
func (p *Publisher) Publish(ctx context.Context, topic string, payload any)
error {
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
return p.channel.PublishWithContext(ctx, "", topic, false, false,
amqp.Publishing{
ContentType: "application/json",
Body: data,
})}
Wiring It All Together
The app/ package is where all layers meet. It's the only place that knows about all concrete
implementations.
// internal/app/app.go
func New(ctx context.Context) (*App, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// 1. Infrastructure clients
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("database: %w", err)
}
cache, err := platform.NewRedisCache(cfg.RedisAddr)
if err != nil {
return nil, fmt.Errorf("cache: %w", err)
}
publisher, err := platform.NewRabbitMQPublisher(cfg.RabbitMQURL)
if err != nil {
return nil, fmt.Errorf("publisher: %w", err)
}
// 2. Repositories (implement domain interfaces)
notifRepo := postgres.NewNotificationRepository(db)
cachedNotifRepo := postgres.NewCachedNotificationRepository(notifRepo,
cache)
pushTokenRepo := postgres.NewPushTokenRepository(db)
// 3. Domain services
notifService := notification.NewService(
cachedNotifRepo,
pushTokenRepo,
publisher,
notification.ServiceOptions{
DedupeWindow: 5 * time.Minute,
MaxPerPage: 20,
},
)
// 4. HTTP handlers
notifHandler := notifhttp.NewHandler(notifService, logger)
// 5. Wire routes
router := chi.NewRouter()
router.Use(middleware.Logger)
router.Use(middleware.Recoverer)
api := humachi.New(router, huma.DefaultConfig("Social API", "1.0.0"))
notifhttp.RegisterRoutes(api, notifHandler)
return &App{Router: router,
Logger: logger,
}, nil
}
The Adapter Pattern
Sometimes external service interfaces don't match domain interfaces exactly. We use adapters to
bridge them:
// internal/app/push_adapter.go
// Domain expects:
// type EventPublisher interface {
// Publish(ctx context.Context, topic string, payload any) error
// }
// But we want to add retries and logging
type loggingPublisher struct {
inner *platform.Publisher
logger *slog.Logger
}
func NewLoggingPublisher(inner *platform.Publisher, logger *slog.Logger)
notification.EventPublisher {
return &loggingPublisher{inner: inner, logger: logger}
}
func (p *loggingPublisher) Publish(ctx context.Context, topic string, payload
any) error {
err := p.inner.Publish(ctx, topic, payload)
if err != nil {
p.logger.Error("failed to publish event",
"topic", topic,
"error", err,
)
return err
}
p.logger.Debug("event published", "topic", topic)
return nil
}
Adapters keep both sides clean—the domain doesn't bend to infrastructure, and infrastructure
doesn't need to know about domain types.
Testing Benefits
This architecture makes testing straightforward:
Domain tests use simple stubs:
type stubNotifRepo struct {
notifications map[string]*notification.Notification
}
func (s *stubNotifRepo) Create(ctx context.Context, n
*notification.Notification) error {
s.notifications[n.ID] = n
return nil
}func (s *stubNotifRepo) ExistsByDedupeKey(ctx context.Context, userID, dedupeKey
string, since time.Time) (bool, error) {
return false, nil
}
type stubPublisher struct {
events []any
}
func (s *stubPublisher) Publish(ctx context.Context, topic string, payload any)
error {
s.events = append(s.events, payload)
return nil
}
func TestNotify_Success(t *testing.T) {
repo := &stubNotifRepo{notifications:
make(map[string]*notification.Notification)}
publisher := &stubPublisher{}
svc := notification.NewService(
repo,
&stubTokenRepo{token: "device-token"},
publisher,
notification.ServiceOptions{},
)
notif, err := svc.Notify(ctx, notification.Event{
Type: notification.TypeLike,
ActorID: "user-1",
ActorName: "Alice",
TargetUserID: "user-2",
PostID: "post-1",
})
assert.NoError(t, err)
assert.NotNil(t, notif)
assert.Equal(t, "Alice liked your post", notif.Message)
assert.Len(t, repo.notifications, 1)
assert.Len(t, publisher.events, 1) // Push notification sent
}
func TestNotify_SelfAction_Rejected(t *testing.T) {
svc := notification.NewService(
&stubNotifRepo{},
&stubTokenRepo{},
&stubPublisher{},
notification.ServiceOptions{},
)
_, err := svc.Notify(ctx, notification.Event{
Type: notification.TypeLike,
ActorID: "user-1",
TargetUserID: "user-1", // Same user
})
assert.Error(t, err)
code, ok := notification.CodeOf(err)
assert.True(t, ok)
assert.Equal(t, notification.ErrCodeSelfAction, code)
}Handler tests mock the service interface, not the database.
Integration tests use the real wiring with test infrastructure.
Key Takeaways
1. Interfaces at the consumer — Define what you need, not what exists
2. Options pattern for configuration — Explicit, discoverable, with sensible defaults
3. Thin handlers — Parse, delegate, format. No business logic.
4. Adapters for interface mismatch — Bridge external services to domain cleanly
5. One wiring location — app.go is the only file that knows all concrete types
This architecture has served us well as we've grown from a handful of services to dozens. New
engineers can find code quickly, tests are fast and focused, and swapping infrastructure (we
migrated from one message queue to another) required changes in exactly one file.
The upfront investment in structure pays dividends every time you need to add a feature, fix a bug,
or onboard a new team member.
Auwal is a lead backend engineer at Scapu and spearheads the backend system using Golang