-
Notifications
You must be signed in to change notification settings - Fork 0
Hexagonal Architecture
SpannerSync edited this page Jun 23, 2026
·
1 revision
GherkinForge enforces Alistair Cockburn's Ports and Adapters pattern (2005). The domain is the innermost ring — it knows nothing about the outer world.
┌─────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────┘
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
}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
ctxas first parameter to every call
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 domain never imports adapters. The use case never imports transport. This is enforced by:
-
.golangci.ymldepguard rules (opt-in strict mode) — CI fails if domain imports infrastructure - AI generation rule §1 — domain isolation is the first constraint checked