Skip to content

Hexagonal Architecture

SpannerSync edited this page Jun 23, 2026 · 1 revision

Hexagonal Architecture

GherkinForge enforces Alistair Cockburn's Ports and Adapters pattern (2005). The domain is the innermost ring — it knows nothing about the outer world.


Layer Map

┌─────────────────────────────────────────┐
│  Transport (internal/app/)              │  ← HTTP / gRPC / CLI
│  Imports everything, imported by none   │
├─────────────────────────────────────────┤
│  Adapters (pkg/context/*/adapters/)     │  ← DB, broker, in-memory
│  Implements domain ports                │
├─────────────────────────────────────────┤
│  Use Cases (pkg/context/*/usecases/)    │  ← Orchestration only
│  Accepts ports, delegates to domain     │
├─────────────────────────────────────────┤
│  Domain (pkg/context/*/domain/)         │  ← Aggregates, ports, errors
│  Zero external imports                  │
└─────────────────────────────────────────┘

Domain Layer Rules

Imports restricted exclusively to Go standard library primitives:

  • context, errors, fmt, sync, time (type definitions only)
// pkg/context/order/domain/order.go
package domain

import "time"

type Order struct {
    ID         string
    CustomerID string
    Items      []OrderItem
    TotalPence int64     // int64 pence — never float64
    CreatedAt  time.Time
}

// Business logic lives HERE — on the aggregate, not in the use case
func (o *Order) Validate() error {
    if len(o.Items) == 0 {
        return ErrNoItems
    }
    return nil
}

Ports (interfaces) are defined in the domain and implemented in adapters:

// pkg/context/order/domain/ports.go
type OrderRepository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id string) (*Order, error)
}

type EventPublisher interface {
    Publish(ctx context.Context, eventName string, payload any) error
}

type Clock interface {
    Now() time.Time
}

Use Case Layer Rules

Orchestrates domain via injected ports. No framework imports.

// Accept interfaces (ports), return concrete struct
func NewCreateOrderUseCase(
    repo   domain.OrderRepository,
    pub    domain.EventPublisher,
    inv    domain.Inventory,
    clock  domain.Clock,
) *CreateOrderUseCase
  • Inject all ports via constructor — never hardcode
  • Delegate business rules to the aggregate — use cases orchestrate, aggregates decide
  • Wrap every error: fmt.Errorf("creating order: %w", err)
  • Propagate ctx as first parameter to every call

Adapter Layer Rules

Implements domain ports using real infrastructure:

// pkg/context/order/adapters/inmemory/repository.go
type OrderRepository struct {
    mu     sync.RWMutex      // documented locking strategy
    orders map[string]*domain.Order
}

func (r *OrderRepository) Save(_ context.Context, order *domain.Order) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.orders[order.ID] = order
    return nil
}

Swap the in-memory adapter for a pgx-backed one in production — the use case and domain change zero lines.


The Wall

The domain never imports adapters. The use case never imports transport. This is enforced by:

  1. .golangci.yml depguard rules (opt-in strict mode) — CI fails if domain imports infrastructure
  2. AI generation rule §1 — domain isolation is the first constraint checked

Clone this wiki locally