FeaturesHow It WorksDownloadAboutBlogGet the app ↗
For Gen X, Y and Z. By Gen Z.
← Back to Blog
InsightsJul 8, 20261 min read

Clean Architecture in Go: From Domain to HTTP

By admin

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