Skip to content
AyoKoding

Intermediate

Examples 21–50 build on the beginner foundation. The purchasing context gains a second bounded context (supplier), Postgres adapters replace in-memory stubs, an Anti-Corruption Layer translates external supplier DTOs, CQRS splits commands from queries, and the full wiring demonstrates how all pieces compose in main.go without a DI framework.

Section 1: Second Context — Supplier (Examples 21–25)

Example 21: SupplierRepository output port

A second bounded context introduces its own output port. The interface lives in the app/ package alongside the supplier service — never in domain/ and never in an adapter.

// Package app holds port interfaces for the supplier bounded context.
// Ports are defined here so the domain never depends on infrastructure.
package app
 
import (
    "context"
    // domain package defines the Supplier aggregate and SupplierID type.
    "procurement/supplier/domain"
)
 
// SupplierRepository is an output port.
// Any struct that implements these three methods satisfies the interface.
// No 'implements' keyword required — Go structural typing handles dispatch.
type SupplierRepository interface {
    // FindByID retrieves a supplier by its identity.
    // Returns domain.ErrNotFound when the supplier does not exist.
    FindByID(ctx context.Context, id domain.SupplierID) (domain.Supplier, error)
 
    // Save persists a supplier aggregate (create or update).
    // The adapter decides whether to INSERT or UPDATE.
    Save(ctx context.Context, s domain.Supplier) error
 
    // FindApproved returns all suppliers whose status is Approved.
    // Used by the purchasing context eligibility guard.
    FindApproved(ctx context.Context) ([]domain.Supplier, error)
}

Key takeaway: Each bounded context defines its own output ports in its own app/ package; the port shape reflects what the domain needs, not what the database exposes.

Why it matters: Keeping supplier ports in supplier/app/ instead of sharing a monolithic repository prevents the two contexts from coupling. When the supplier schema changes, only the supplier adapter changes — the purchasing domain remains untouched. This boundary is the fundamental promise of hexagonal architecture: the domain dictates the contract, infrastructure fulfils it.


Example 22: Supplier domain aggregate with lifecycle

The Supplier aggregate carries a status field that controls which transitions are legal. The domain enforces state machines — no adapter can bypass the lifecycle by writing SQL directly.

// Package domain holds the Supplier aggregate for the supplier context.
// No framework imports, no database tags, no HTTP concerns.
package domain
 
import "errors"
 
// SupplierStatus enumerates legal lifecycle states.
// Using a distinct string type prevents mixing with arbitrary strings.
type SupplierStatus string
 
const (
    // SupplierPending is the initial state after registration.
    SupplierPending  SupplierStatus = "PENDING"
    // SupplierApproved means the supplier passed compliance review.
    SupplierApproved SupplierStatus = "APPROVED"
    // SupplierRejected is terminal — cannot be reversed.
    SupplierRejected SupplierStatus = "REJECTED"
)
 
// Supplier is the aggregate root for the supplier context.
type Supplier struct {
    // ID uniquely identifies this supplier across the platform.
    ID     SupplierID
    // Name is the legal trading name, required and non-empty.
    Name   string
    // Status tracks the compliance lifecycle.
    Status SupplierStatus
}
 
// Approve transitions the supplier from Pending to Approved.
// Returns ErrInvalidTransition when the current state disallows it.
func (s *Supplier) Approve() error {
    if s.Status != SupplierPending {
        // Only Pending suppliers may be approved; Rejected is terminal.
        return ErrInvalidTransition
    }
    // Transition is valid — update the in-memory state.
    s.Status = SupplierApproved
    return nil
}
 
// Reject transitions the supplier from Pending to Rejected.
// Rejected is a terminal state — calling Reject again returns an error.
func (s *Supplier) Reject() error {
    if s.Status != SupplierPending {
        // Neither Approved nor Rejected may be rejected again.
        return ErrInvalidTransition
    }
    s.Status = SupplierRejected
    return nil
}
 
// IsApproved is a convenience predicate used by the eligibility guard.
// Callers check this instead of comparing status strings directly.
func (s Supplier) IsApproved() bool {
    // True only when the supplier has passed compliance review.
    return s.Status == SupplierApproved
}
 
var (
    // ErrInvalidTransition signals an illegal lifecycle change.
    ErrInvalidTransition = errors.New("invalid status transition")
    // ErrNotFound signals that a lookup returned no result.
    ErrNotFound          = errors.New("supplier not found")
)

Key takeaway: The aggregate owns its lifecycle logic; adapters call Approve() / Reject() and then persist the result — they never write raw status strings to the database without going through the domain.

Why it matters: State machine enforcement in the domain prevents data corruption from concurrent adapter calls writing contradictory states. A Postgres adapter cannot set status to APPROVED without the domain validating the transition. This is impossible in an anemic domain model where status is just a column value.


Example 23: In-memory SupplierRepository adapter

The in-memory adapter for SupplierRepository uses a mutex-protected map. It implements all three port methods and serves as both a test double and a fast-startup alternative to Postgres.

// Package mem provides in-memory adapters for the supplier context.
// Used in unit tests and in fast-startup environments (no Postgres needed).
package mem
 
import (
    "context"
    "sync"
    "procurement/supplier/app"
    "procurement/supplier/domain"
)
 
// SupplierRepo is a thread-safe in-memory implementation of app.SupplierRepository.
// The struct satisfies the interface implicitly — no 'implements' declaration.
type SupplierRepo struct {
    // mu protects the store map from concurrent reads and writes.
    mu    sync.RWMutex
    // store maps SupplierID to its latest aggregate snapshot.
    store map[domain.SupplierID]domain.Supplier
}
 
// NewSupplierRepo constructs an empty SupplierRepo with an initialised map.
func NewSupplierRepo() *SupplierRepo {
    // Return a pointer so callers share the same store; value copy would lose data.
    return &SupplierRepo{store: make(map[domain.SupplierID]domain.Supplier)}
}
 
// FindByID looks up a supplier by identity.
// RLock allows concurrent reads without blocking other readers.
func (r *SupplierRepo) FindByID(ctx context.Context, id domain.SupplierID) (domain.Supplier, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    // Map lookup: ok is false when the key is absent.
    s, ok := r.store[id]
    if !ok {
        // Return the sentinel error the port contract specifies.
        return domain.Supplier{}, domain.ErrNotFound
    }
    return s, nil
}
 
// Save writes the supplier aggregate into the in-memory store.
// Lock (not RLock) prevents concurrent writes from interleaving.
func (r *SupplierRepo) Save(ctx context.Context, s domain.Supplier) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    // Overwrite any existing entry — same as an SQL upsert.
    r.store[s.ID] = s
    return nil
}
 
// FindApproved returns all suppliers with status APPROVED.
// Iterates the entire map; acceptable for tests and low-volume starts.
func (r *SupplierRepo) FindApproved(ctx context.Context) ([]domain.Supplier, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    var result []domain.Supplier
    for _, s := range r.store {
        if s.IsApproved() {
            // Append only APPROVED entries to the result slice.
            result = append(result, s)
        }
    }
    // Returning nil slice when empty is idiomatic Go — callers use len().
    return result, nil
}
 
// Compile-time assertion: *SupplierRepo must satisfy app.SupplierRepository.
// If the interface changes, this line causes a build error before any test runs.
var _ app.SupplierRepository = (*SupplierRepo)(nil)

Key takeaway: The in-memory adapter is an implementation, not a mock — it stores real state and enforces the same logic paths that a Postgres adapter would, making unit tests faithful proxies for integration behaviour.

Why it matters: Developers on teams without a local Postgres instance can run the full service with DATABASE_URL="" and get a working in-memory store. CI pipelines that value speed can run unit tests against in-memory adapters and reserve Postgres for integration test targets, cutting fast-feedback loop time dramatically.


Example 24: Dependency rejection — refusing non-APPROVED suppliers

The IssuePurchaseOrderService calls SupplierRepo.FindByID to check the supplier's eligibility before accepting a PO. The guard lives in the application service, not in the HTTP adapter.

// Package app holds the IssuePurchaseOrderService for the purchasing context.
// The service orchestrates domain logic and delegates I/O to port implementations.
package app
 
import (
    "context"
    "errors"
    "procurement/purchasing/domain"
    supplierApp "procurement/supplier/app"
    supplierDomain "procurement/supplier/domain"
)
 
// ErrSupplierNotApproved is returned when the named supplier is not APPROVED.
// Application-layer error, not a domain error — the domain does not know suppliers.
var ErrSupplierNotApproved = errors.New("supplier is not approved")
 
// IssuePurchaseOrderService orchestrates PO issuance with supplier eligibility.
type IssuePurchaseOrderService struct {
    // poRepo persists PurchaseOrder aggregates; defined in this package as a port.
    poRepo       PurchaseOrderRepository
    // supplierRepo is a cross-context dependency: the supplier output port.
    supplierRepo supplierApp.SupplierRepository
    // clock provides deterministic time for domain event timestamps.
    clock        Clock
}
 
// Issue validates supplier eligibility then persists the purchase order.
// Returns ErrSupplierNotApproved when the supplier has not passed compliance.
func (s *IssuePurchaseOrderService) Issue(ctx context.Context, cmd IssueCommand) (domain.PurchaseOrderID, error) {
    // Step 1: fetch supplier — crosses context boundary via output port.
    supplier, err := s.supplierRepo.FindByID(ctx, supplierDomain.SupplierID(cmd.SupplierID))
    if err != nil {
        // FindByID returns ErrNotFound when the supplier does not exist.
        return "", err
    }
    // Step 2: eligibility guard — domain aggregate method, not a raw string compare.
    if !supplier.IsApproved() {
        // Guard rejects early; no PO is created, no ID is allocated.
        return "", ErrSupplierNotApproved
    }
    // Step 3: create the domain aggregate using the current clock time.
    po, err := domain.NewPurchaseOrder(cmd.SupplierID, cmd.Items, s.clock.Now())
    if err != nil {
        return "", err
    }
    // Step 4: persist the new aggregate via the output port.
    if err := s.poRepo.Save(ctx, po); err != nil {
        return "", err
    }
    // Step 5: return the new aggregate identity to the caller (HTTP adapter).
    return po.ID, nil
}

Key takeaway: The eligibility guard belongs in the application layer service — it orchestrates two port calls and enforces a cross-context business rule without leaking supplier logic into the domain or the HTTP adapter.

Why it matters: Placing the guard in the HTTP handler creates duplicate validation across every entry point (REST, gRPC, CLI). Placing it in the domain couples the purchasing domain to the supplier domain. The application service is the exactly-right layer — it coordinates without owning domain logic and without duplicating presentation concerns.


Example 25: Unit test for eligibility rejection

A focused unit test verifies that IssuePurchaseOrderService rejects a non-approved supplier. No database is required — the in-memory adapters from Examples 13 and 23 provide the test seam.

// Package app_test exercises IssuePurchaseOrderService eligibility guard.
// No Postgres, no HTTP server, no test framework beyond standard library.
package app_test
 
import (
    "context"
    "testing"
    "procurement/purchasing/app"
    purchasingMem "procurement/purchasing/adapter/out/mem"
    supplierMem   "procurement/supplier/adapter/out/mem"
    supplierDomain "procurement/supplier/domain"
)
 
// TestIssue_RejectsNonApprovedSupplier verifies the eligibility guard fires
// when the supplier exists but has not been approved.
func TestIssue_RejectsNonApprovedSupplier(t *testing.T) {
    // Arrange — seed a PENDING supplier in the in-memory adapter.
    supplierRepo := supplierMem.NewSupplierRepo()
    pendingSupplier := supplierDomain.Supplier{
        ID:     "S-001",
        Name:   "ACME Corp",
        // Status is PENDING — not eligible to receive purchase orders.
        Status: supplierDomain.SupplierPending,
    }
    // Save the pending supplier so FindByID will find it.
    _ = supplierRepo.Save(context.Background(), pendingSupplier)
 
    // Wire the service with in-memory adapters — no composition root needed.
    svc := app.NewIssuePurchaseOrderService(
        purchasingMem.NewPurchaseOrderRepo(),
        supplierRepo,
        app.FixedClock{},
    )
 
    // Act — attempt to issue a PO against the PENDING supplier.
    _, err := svc.Issue(context.Background(), app.IssueCommand{
        SupplierID: "S-001",
        Items:      []app.LineItem{{SKU: "WIDGET-42", Quantity: 10}},
    })
 
    // Assert — the service must return ErrSupplierNotApproved, nothing else.
    if err != app.ErrSupplierNotApproved {
        // Fail with a diagnostic message that names both expected and actual errors.
        t.Errorf("expected ErrSupplierNotApproved, got %v", err)
    }
}

Key takeaway: The test exercises real application logic using real in-memory adapters — no mocking framework, no reflection, no test-specific subclasses — which means a passing test proves the correct objects collaborate correctly.

Why it matters: Tests that use in-memory adapters rather than mocks catch integration mistakes between service and adapter (wrong method signature, missing nil check) that mock-based tests cannot. When a port contract changes, the compile-time check on the in-memory adapter (var _ app.SupplierRepository = (*SupplierRepo)(nil)) fails first, making the breakage visible before any test runs.


Section 2: Adapter Swapping (Examples 26–28)

Example 26: Postgres adapter for PurchaseOrderRepository

The Postgres adapter fulfils the same PurchaseOrderRepository port as the in-memory adapter. The service does not change — only the wiring in main.go selects which adapter to inject.

// Package postgres provides a Postgres-backed PurchaseOrderRepository.
// It depends on database/sql and pgx — infrastructure concerns isolated here.
package postgres
 
import (
    "context"
    "database/sql"
    "procurement/purchasing/app"
    "procurement/purchasing/domain"
)
 
// PurchaseOrderRepo implements app.PurchaseOrderRepository against Postgres.
// The struct holds a *sql.DB from which it obtains connections per request.
type PurchaseOrderRepo struct {
    // db is the database handle; shared across all requests via connection pool.
    db *sql.DB
}
 
// NewPurchaseOrderRepo constructs a repo given an open database handle.
// The caller (main.go) owns the DB lifecycle — this repo does not close it.
func NewPurchaseOrderRepo(db *sql.DB) *PurchaseOrderRepo {
    return &PurchaseOrderRepo{db: db}
}
 
// Save inserts or updates a PurchaseOrder row in the database.
// Uses an upsert so callers do not distinguish create from update.
func (r *PurchaseOrderRepo) Save(ctx context.Context, po domain.PurchaseOrder) error {
    const q = `
        INSERT INTO purchase_orders (id, supplier_id, status, created_at)
        VALUES ($1, $2, $3, $4)
        ON CONFLICT (id) DO UPDATE
            SET status = EXCLUDED.status`
    // QueryContext propagates the request context for cancellation and timeouts.
    _, err := r.db.ExecContext(ctx, q, po.ID, po.SupplierID, string(po.Status), po.CreatedAt)
    // Return the raw database error; the service maps it if needed.
    return err
}
 
// FindByID retrieves a single purchase order row by primary key.
// Scans the result set into the domain struct; no ORM, no reflection.
func (r *PurchaseOrderRepo) FindByID(ctx context.Context, id domain.PurchaseOrderID) (domain.PurchaseOrder, error) {
    const q = `SELECT id, supplier_id, status, created_at FROM purchase_orders WHERE id = $1`
    row := r.db.QueryRowContext(ctx, q, id)
    var po domain.PurchaseOrder
    var status string
    // Scan maps columns to fields by position; field order must match SELECT list.
    err := row.Scan(&po.ID, &po.SupplierID, &status, &po.CreatedAt)
    if err == sql.ErrNoRows {
        // Translate database absence into the port-contract sentinel error.
        return domain.PurchaseOrder{}, domain.ErrNotFound
    }
    if err != nil {
        return domain.PurchaseOrder{}, err
    }
    // Re-hydrate the typed status from the stored string.
    po.Status = domain.POStatus(status)
    return po, nil
}
 
// Compile-time assertion: *PurchaseOrderRepo must satisfy app.PurchaseOrderRepository.
var _ app.PurchaseOrderRepository = (*PurchaseOrderRepo)(nil)

Key takeaway: The Postgres adapter translates between the relational world (rows, SQL strings) and the domain world (typed aggregates) without the service or domain knowing SQL exists.

Why it matters: The translation layer in the adapter isolates schema migrations from service logic. If a column is renamed, only the adapter changes — the domain aggregate, the service, and all tests using in-memory adapters are untouched. This is the testability dividend hexagonal architecture delivers at the cost of writing two adapters instead of one.


Example 27: Environment-based adapter selection

The composition root selects either the in-memory or Postgres adapter at startup time based on an environment variable. The service binary is identical in both cases — only the wiring changes.

// main.go — composition root that selects adapters based on environment.
// No framework, no DI container, no reflection — plain Go constructor calls.
package main
 
import (
    "database/sql"
    "log"
    "os"
    "procurement/purchasing/adapter/in/http"
    "procurement/purchasing/app"
    purchasingMem  "procurement/purchasing/adapter/out/mem"
    purchasingPG   "procurement/purchasing/adapter/out/postgres"
    _ "github.com/jackc/pgx/v5/stdlib" // Register pgx as a database/sql driver.
)
 
func main() {
    // Determine adapter choice by checking DATABASE_URL at startup.
    var poRepo app.PurchaseOrderRepository
    dbURL := os.Getenv("DATABASE_URL")
    if dbURL != "" {
        // Postgres path — open connection pool and wrap in the Postgres adapter.
        db, err := sql.Open("pgx", dbURL)
        if err != nil {
            log.Fatalf("cannot open database: %v", err)
        }
        // NewPurchaseOrderRepo wraps the *sql.DB behind the port interface.
        poRepo = purchasingPG.NewPurchaseOrderRepo(db)
        log.Println("using postgres adapter")
    } else {
        // In-memory path — no external dependency needed.
        poRepo = purchasingMem.NewPurchaseOrderRepo()
        log.Println("using in-memory adapter (no DATABASE_URL set)")
    }
    // The service is constructed identically regardless of which adapter was chosen.
    svc := app.NewIssuePurchaseOrderService(poRepo, /* supplierRepo, clock */ nil, nil)
    // Mount HTTP routes — the handler holds the service, not the repository.
    router := http.NewRouter(svc)
    log.Fatal(router.ListenAndServe(":8080"))
}

Key takeaway: The adapter selection if/else is the only place in the codebase that names both the port and the concrete adapter type together — every other file knows only the interface, giving the binary a clean swap with zero service changes.

Why it matters: Environment-based selection is the simplest CI/CD adapter swap strategy. The same binary image ships to all environments: DATABASE_URL="" in local development, real URL in staging and production. No environment-specific build flags, no separate binaries, no configuration overrides at the service level.


Example 28: Integration test seam with real Postgres

Integration tests use testcontainers-go (Go) / testcontainers (Rust) to spin up a real Postgres container per test run. Transaction rollback cleanup removes test data without truncating tables.

// Package postgres_test runs integration tests against a real Postgres container.
// Requires Docker; will be skipped in environments without it.
package postgres_test
 
import (
    "context"
    "database/sql"
    "testing"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    purchasingPG "procurement/purchasing/adapter/out/postgres"
    "procurement/purchasing/domain"
)
 
// TestPurchaseOrderRepo_SaveAndFind is an integration test against a real Postgres.
// Uses testcontainers-go to start a throwaway container per test binary run.
func TestPurchaseOrderRepo_SaveAndFind(t *testing.T) {
    ctx := context.Background()
    // Start a Postgres container — image, database, user, password configured here.
    container, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("procurement"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    if err != nil {
        t.Fatalf("failed to start container: %v", err)
    }
    // Ensure the container is terminated after the test, even on failure.
    defer container.Terminate(ctx)
 
    // Obtain the connection string from the running container.
    connStr, _ := container.ConnectionString(ctx, "sslmode=disable")
    db, _ := sql.Open("pgx", connStr)
    // Run schema migrations so the purchase_orders table exists.
    runMigrations(t, db)
 
    // Begin a transaction — all test writes will be rolled back.
    tx, _ := db.BeginTx(ctx, nil)
    defer tx.Rollback() // Rollback undoes all inserts after the test.
 
    // Construct the adapter using the transaction as the database handle.
    repo := purchasingPG.NewPurchaseOrderRepo(tx)
    po := domain.PurchaseOrder{ID: "PO-TEST-1", SupplierID: "S-001", Status: domain.PODraft}
    // Save through the adapter — writes go into the transaction, not committed.
    if err := repo.Save(ctx, po); err != nil {
        t.Fatalf("Save failed: %v", err)
    }
    // FindByID reads within the same transaction — uncommitted data is visible.
    found, err := repo.FindByID(ctx, "PO-TEST-1")
    if err != nil {
        t.Fatalf("FindByID failed: %v", err)
    }
    if found.SupplierID != "S-001" {
        t.Errorf("expected supplier S-001, got %v", found.SupplierID)
    }
    // Rollback in defer ensures the purchase_orders table is unchanged after the test.
}

Key takeaway: Integration tests verify that the SQL in the Postgres adapter is correct without leaving test data behind — the transaction rollback pattern gives real database behaviour at no cleanup cost.

Why it matters: An in-memory adapter test can pass while the Postgres adapter has a column-order bug in its Scan call. Integration tests catch exactly that class of bug. testcontainers removes the shared-database contamination risk by giving each test run an isolated throwaway container, making integration tests safe to run in parallel CI pipelines.


Section 3: Anti-Corruption Layer (Examples 29–32)

Example 29: ACL — translating external SupplierDTO

An Anti-Corruption Layer (ACL) translator lives in adapter/out/ and converts an external supplier microservice's JSON DTO into the purchasing context's domain Supplier type. The domain never sees the external schema.

// Package suppliergateway translates external supplier API responses
// into the supplier domain type used by the purchasing context.
// This adapter isolates the domain from the external supplier service's schema.
package suppliergateway
 
import "procurement/supplier/domain"
 
// ExternalSupplierDTO represents the JSON shape returned by the supplier microservice.
// Field names match the external API contract; they are not the domain field names.
type ExternalSupplierDTO struct {
    // SupplierCode is the external identifier — not the same as domain.SupplierID format.
    SupplierCode   string `json:"supplier_code"`
    // LegalName differs from the domain's Name field by convention.
    LegalName      string `json:"legal_name"`
    // ApprovalStatus uses the external service's vocabulary ("VETTED", "BLOCKED", etc.).
    ApprovalStatus string `json:"approval_status"`
}
 
// ToDomain converts an ExternalSupplierDTO into the purchasing context's domain.Supplier.
// This is the ACL translation function — the domain's Supplier never knows about ExternalSupplierDTO.
func (dto ExternalSupplierDTO) ToDomain() domain.Supplier {
    return domain.Supplier{
        // Map external SupplierCode to domain SupplierID with a type cast.
        ID:   domain.SupplierID(dto.SupplierCode),
        // Map legal_name to the domain's Name field.
        Name: dto.LegalName,
        // Translate external vocabulary to domain SupplierStatus values.
        Status: translateStatus(dto.ApprovalStatus),
    }
}
 
// translateStatus converts external status strings to domain enum values.
// Unknown statuses map to Pending — a conservative safe default.
func translateStatus(external string) domain.SupplierStatus {
    switch external {
    case "VETTED":
        // External VETTED maps to domain Approved.
        return domain.SupplierApproved
    case "BLOCKED", "REJECTED":
        // Both external blocking states map to domain Rejected.
        return domain.SupplierRejected
    default:
        // Unknown or new external statuses default to Pending (safe, not approved).
        return domain.SupplierPending
    }
}

Key takeaway: The ACL translator is a pure function — it maps one struct to another with no I/O, making it trivially testable and safe to extend when the external schema changes.

Why it matters: Without an ACL, renaming a field in the external supplier API forces a change in the domain aggregate. With the ACL, the change is contained to the translator function. The domain team can evolve their model independently from the external service team's release schedule, which is the correct boundary for independent deployability.


Example 30: EventPublisher output port

EventPublisher is an output port in app/ that decouples the application service from the event bus. An in-memory adapter provides synchronous delivery for tests; a real outbox adapter provides durability for production.

// Package app defines the EventPublisher output port for the purchasing context.
// The port interface lives here; concrete adapters live in adapter/out/.
package app
 
import "context"
 
// DomainEvent carries the type name and payload for a single domain event.
// Using an interface keeps the port generic — callers decide the concrete event type.
type DomainEvent interface {
    // EventType returns a string name used for routing (e.g., "PurchaseOrderIssued").
    EventType() string
}
 
// EventPublisher is an output port for publishing domain events.
// Implementations include: in-memory (tests), outbox+Postgres, NATS, Kafka.
type EventPublisher interface {
    // Publish delivers a domain event to whatever bus or store backs this port.
    // Callers do not know whether delivery is synchronous or asynchronous.
    Publish(ctx context.Context, event DomainEvent) error
}
 
// InMemoryEventPublisher collects published events for test inspection.
// No external dependency — useful for verifying that services emit expected events.
type InMemoryEventPublisher struct {
    // Events holds all published events in publication order.
    Events []DomainEvent
}
 
// Publish appends the event to the in-memory slice.
// Not thread-safe by design — unit tests run sequentially.
func (p *InMemoryEventPublisher) Publish(_ context.Context, event DomainEvent) error {
    // Append to slice; no mutex needed for sequential unit tests.
    p.Events = append(p.Events, event)
    return nil
}
 
// Compile-time assertion: *InMemoryEventPublisher must satisfy EventPublisher.
var _ EventPublisher = (*InMemoryEventPublisher)(nil)

Key takeaway: The EventPublisher port hides whether events go to an in-memory Vec, a Postgres outbox, or a Kafka topic — the application service calls Publish and never needs to know which backend is active.

Why it matters: Swapping from an in-memory publisher to an outbox-backed one requires changing only the composition root. Teams can start with InMemoryEventPublisher in early development, graduate to the outbox pattern as reliability requirements grow, and never touch the service that publishes events.


Example 31: ApprovalRouterPort — routing POs to approval workflows

A PO with total value above a threshold must route to senior approval. The routing decision belongs to a dedicated output port, not to a conditional in the service.

// Package app defines the ApprovalRouterPort output port.
// Routing POs to approval workflows is an infrastructure concern — the domain does not own it.
package app
 
import (
    "context"
    "procurement/purchasing/domain"
)
 
// ApprovalRouterPort routes a submitted PurchaseOrder to an approval workflow.
// Concrete adapters: InMemoryApprovalRouter (tests), BPMNEngineRouter (production).
type ApprovalRouterPort interface {
    // Route sends the purchase order to the correct approval workflow.
    // Returns the assigned workflow ID for tracking.
    Route(ctx context.Context, po domain.PurchaseOrder) (WorkflowID, error)
}
 
// WorkflowID identifies an approval workflow instance in the external BPMN engine.
type WorkflowID string
 
// InMemoryApprovalRouter records which POs were routed and with what workflow ID.
// Used in unit tests to verify that the service calls Route after submission.
type InMemoryApprovalRouter struct {
    // Routed maps PurchaseOrderID to the assigned WorkflowID for test inspection.
    Routed map[domain.PurchaseOrderID]WorkflowID
}
 
// NewInMemoryApprovalRouter constructs a router with an initialised map.
func NewInMemoryApprovalRouter() *InMemoryApprovalRouter {
    return &InMemoryApprovalRouter{Routed: make(map[domain.PurchaseOrderID]WorkflowID)}
}
 
// Route records the routing decision and returns a synthetic WorkflowID.
func (r *InMemoryApprovalRouter) Route(_ context.Context, po domain.PurchaseOrder) (WorkflowID, error) {
    // Assign a deterministic workflow ID for test reproducibility.
    wfID := WorkflowID("WF-" + string(po.ID))
    // Record the routing so tests can assert the correct PO was routed.
    r.Routed[po.ID] = wfID
    return wfID, nil
}
 
// Compile-time assertion: *InMemoryApprovalRouter must satisfy ApprovalRouterPort.
var _ ApprovalRouterPort = (*InMemoryApprovalRouter)(nil)

Key takeaway: A dedicated port for approval routing allows the adapter to change (stub → BPMN engine → rules engine) without touching the service that initiates the routing.

Why it matters: Approval workflow engines (Camunda, Activiti, cloud Step Functions) each have different APIs. The ApprovalRouterPort encapsulates that API behind a two-method interface. Swapping engines means writing a new adapter, not modifying the purchase order service that has already been tested against the in-memory stub.


Example 32: Full intermediate flow diagram — two contexts, four ports

flowchart TD
    A["HTTP POST /purchase-orders<br/>chi router — primary adapter"]:::blue
    B["IssuePurchaseOrderService<br/>app layer"]:::teal
    C["SupplierRepository.FindByID<br/>output port — cross-context"]:::orange
    D["InMemory or Postgres<br/>SupplierRepo adapter"]:::teal
    E["PurchaseOrderRepository.Save<br/>output port — same context"]:::orange
    F["InMemory or Postgres<br/>PORepo adapter"]:::teal
    G["EventPublisher.Publish<br/>output port — event bus"]:::orange
    H["InMemory or Outbox<br/>EventPublisher adapter"]:::teal
 
    A -->|"IssueCommand DTO"| B
    B -->|"FindByID(supplierID)"| C
    C --> D
    D -->|"Supplier aggregate"| C
    C -->|"domain.Supplier"| B
    B -->|"Save(po)"| E
    E --> F
    F -->|"ok"| E
    E -->|"ok"| B
    B -->|"Publish(event)"| G
    G --> H
    H -->|"ok"| G
    G -->|"ok"| B
    B -->|"PurchaseOrderID"| A
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#000000,stroke-width:2px

This diagram traces a single HTTP request through the complete intermediate hexagonal wiring: one primary adapter, one application service, three output ports each backed by a swappable adapter pair.

Key takeaway: Every arrow that crosses from the blue HTTP adapter into the teal service, or from the service into an orange port, crosses an abstraction boundary — the service never imports HTTP or database packages.

Why it matters: The diagram reveals that the service is fully testable at the unit level by replacing all three orange ports with in-memory adapters. No network call, no Docker container. The same diagram shows exactly which adapters to replace for integration testing (swap in-memory with Postgres for E and F) or end-to-end testing (run all three against real infrastructure).


Section 4: Multi-Context Composition Root (Examples 33–35)

Example 33: Composition root wiring two contexts

main.go creates one EventPublisher shared between both contexts, wires each context's repository, and passes the purchasing service's supplier dependency to the correct adapter.

// main.go — composition root wiring purchasing and supplier contexts.
// No DI framework: all dependencies are explicit constructor arguments.
package main
 
import (
    "database/sql"
    "log"
    "os"
    purchasingApp  "procurement/purchasing/app"
    purchasingHTTP "procurement/purchasing/adapter/in/http"
    purchasingPG   "procurement/purchasing/adapter/out/postgres"
    purchasingMem  "procurement/purchasing/adapter/out/mem"
    supplierApp    "procurement/supplier/app"
    supplierPG     "procurement/supplier/adapter/out/postgres"
    supplierMem    "procurement/supplier/adapter/out/mem"
)
 
func main() {
    dbURL := os.Getenv("DATABASE_URL")
    // shared is the single EventPublisher used by both contexts.
    // One publisher means one place to change when the event bus changes.
    var shared purchasingApp.EventPublisher
 
    var poRepo    purchasingApp.PurchaseOrderRepository
    var supRepo   supplierApp.SupplierRepository
 
    if dbURL != "" {
        // Both contexts share the same database handle but use separate adapter instances.
        db, err := sql.Open("pgx", dbURL)
        if err != nil { log.Fatalf("db open: %v", err) }
        // Each adapter wraps the shared *sql.DB; no state is shared between adapters.
        poRepo  = purchasingPG.NewPurchaseOrderRepo(db)
        supRepo = supplierPG.NewSupplierRepo(db)
        // In production, EventPublisher is the outbox adapter writing to the same DB.
        shared = purchasingPG.NewOutboxEventPublisher(db)
    } else {
        // In-memory adapters for local development and unit test binary.
        poRepo  = purchasingMem.NewPurchaseOrderRepo()
        supRepo = supplierMem.NewSupplierRepo()
        // In-memory publisher — collects events for inspection, no external bus.
        shared = &purchasingApp.InMemoryEventPublisher{}
    }
 
    // Wire the purchasing service with its three dependencies.
    // The service sees only port interfaces — concrete types are invisible.
    purchasingSvc := purchasingApp.NewIssuePurchaseOrderService(poRepo, supRepo, shared)
    // Mount the HTTP router with the service as the only dependency.
    router := purchasingHTTP.NewRouter(purchasingSvc)
    log.Fatal(router.ListenAndServe(":8080"))
}

Key takeaway: The composition root is the only file that imports concrete adapter types — every other file imports only ports (interfaces/traits), so changing an adapter requires touching exactly one file.

Why it matters: In a DI-container-based system, the wiring is distributed across annotations and config files; tracing a dependency requires tool support. In this explicit wiring, a developer can read main.go top to bottom and know exactly which concrete type backs every port — a significant debugging and onboarding advantage, especially for teams new to the codebase.


Example 34: Constructor injection depth

Nested constructor calls reveal the full dependency graph at the point of reading main.go. No reflection, no runtime container, no annotation scanning.

// explicit_wiring.go — excerpt showing deep constructor injection for the purchasing service.
// Every dependency is visible as a constructor argument; nothing is hidden in a container.
package main
 
import (
    purchasingApp "procurement/purchasing/app"
    purchasingMem "procurement/purchasing/adapter/out/mem"
    supplierMem   "procurement/supplier/adapter/out/mem"
)
 
// wirePurchasingService constructs the full service graph without a DI framework.
// Step through this function in a debugger to trace any dependency.
func wirePurchasingService() *purchasingApp.IssuePurchaseOrderService {
    // Level 3 (leaf) — in-memory adapters need no dependencies of their own.
    poRepo      := purchasingMem.NewPurchaseOrderRepo()
    supRepo     := supplierMem.NewSupplierRepo()
    publisher   := &purchasingApp.InMemoryEventPublisher{}
    approvalRtr := purchasingApp.NewInMemoryApprovalRouter()
    clock       := purchasingApp.FixedClock{} // deterministic time for dev/test.
 
    // Level 2 — the service depends on four port implementations.
    // All four are visible on a single line; a DI container would hide them.
    return purchasingApp.NewIssuePurchaseOrderService(
        poRepo,
        supRepo,
        publisher,
        approvalRtr,
        clock,
    )
}

Key takeaway: Constructor injection with plain function calls is the simplest dependency injection — no magic, fully step-debuggable, and the entire object graph fits on one screen.

Why it matters: DI containers in Java-world hide the object graph inside annotations and classpath scanning. When a circular dependency appears or a bean is missing, the error surfaces at runtime (often only in production). With plain constructor calls, a circular dependency is a compile error, a missing dependency is a type error, and the entire graph is readable from main.go without a diagram tool.


Example 35: Command DTO at the adapter boundary

The HTTP adapter deserializes JSON into a command DTO, validates it, then passes a clean IssueCommand value into the application service. The DTO never enters the domain package.

// Package http is the primary HTTP adapter for the purchasing context.
// It receives JSON, validates it, and converts to the application command type.
package http
 
import (
    "encoding/json"
    "net/http"
    "procurement/purchasing/app"
)
 
// issuePORequest is the JSON body for POST /purchase-orders.
// This struct exists only in the adapter layer — it is not an app or domain type.
type issuePORequest struct {
    // SupplierID is required; empty string is rejected by validate().
    SupplierID string            `json:"supplier_id"`
    // Items is required; empty slice is rejected by validate().
    Items      []lineItemRequest `json:"items"`
}
 
// lineItemRequest is the JSON line-item shape; maps 1-to-1 with app.LineItem.
type lineItemRequest struct {
    SKU      string `json:"sku"`
    Quantity int    `json:"quantity"`
}
 
// validate enforces required-field rules before the command enters the service.
// Returns a human-readable error string; never returns a domain error.
func (r issuePORequest) validate() error {
    if r.SupplierID == "" {
        // SupplierID is mandatory — reject early with a clear message.
        return errBadRequest("supplier_id is required")
    }
    if len(r.Items) == 0 {
        // At least one line item is required to issue a purchase order.
        return errBadRequest("items must not be empty")
    }
    return nil
}
 
// HandleIssuePO is the chi handler for POST /purchase-orders.
func HandleIssuePO(svc app.IssuePurchaseOrderUseCase) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req issuePORequest
        // Decode JSON body into the adapter-local DTO struct.
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            http.Error(w, "invalid JSON", http.StatusBadRequest)
            return
        }
        // Validate at the adapter boundary before calling the service.
        if err := req.validate(); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        // Convert the DTO to the application command type — no domain import needed.
        cmd := app.IssueCommand{
            SupplierID: req.SupplierID,
            Items:      toAppLineItems(req.Items),
        }
        // Delegate to the application service; the adapter does no business logic.
        id, err := svc.Issue(r.Context(), cmd)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(map[string]string{"id": string(id)})
    }
}

Key takeaway: The command DTO (adapter layer) and the IssueCommand (app layer) are deliberately separate types — the adapter translates between the wire format and the application contract, preventing HTTP concerns from leaking into the service.

Why it matters: Keeping JSON field names and HTTP status codes out of the application layer means the service is reusable from a CLI, gRPC, or message queue adapter without any modification. The conversion cost (one struct mapping) is trivial; the decoupling benefit is that each adapter can evolve its wire format independently from the application's command shape.


Section 5: Structural Enforcement and CQRS (Examples 36–43)

Example 36: Dependency rule enforcement in CI

go list can detect illegal cross-package imports by checking that no domain/ package imports adapter/ or app/. Rust's module visibility (pub(crate), pub(super)) enforces the same boundary at compile time.

// dependency_check_test.go — CI guard verifying the dependency rule.
// This test fails the build if any domain package imports an adapter package.
package infra_test
 
import (
    "os/exec"
    "strings"
    "testing"
)
 
// TestDomainPackageHasNoAdapterImports uses 'go list' to extract the import graph
// and asserts that domain packages never import adapter packages.
func TestDomainPackageHasNoAdapterImports(t *testing.T) {
    // 'go list -f {{.ImportPath}} {{.Imports}}' prints each package's imports.
    // The './purchasing/domain/...' pattern matches all sub-packages.
    out, err := exec.Command(
        "go", "list", "-f", "{{.ImportPath}} {{.Imports}}",
        "./purchasing/domain/...",
    ).Output()
    if err != nil {
        t.Fatalf("go list failed: %v", err)
    }
    // Scan each line; fail if any domain package imports adapter or app.
    for _, line := range strings.Split(string(out), "\n") {
        if strings.Contains(line, "adapter/") {
            // Found an illegal cross-layer import — print the offending line.
            t.Errorf("domain package imports adapter: %s", line)
        }
        if strings.Contains(line, "/app") {
            // domain must not import app — app may import domain, never the reverse.
            t.Errorf("domain package imports app: %s", line)
        }
    }
}

Key takeaway: Go needs an explicit test to enforce the dependency rule because its package system does not have visibility modifiers; Rust enforces the same boundary at compile time via pub(crate) and pub(super).

Why it matters: Without the CI check, a junior developer can accidentally add an import from domain/ to adapter/ and the build still passes. The go list test catches it on the next CI run. In Rust, the violation is impossible — the compiler rejects it before any test runs. Both strategies ensure the dependency rule is machine-verified, not just documented.


Example 37: CQRS motivation — separating command and query ports

A single PurchaseOrderRepository port with both Save and FindByStatus conflates write and read concerns. CQRS splits them: the command service uses the write port; the query service uses the read port.

// Package app shows why splitting command and query ports improves the design.
// Before CQRS: one repository interface for both reads and writes.
package app
 
// Before — single port mixes command and query responsibilities.
// The command service (Issue) does not need FindByStatus or FindPendingApproval.
// Including those methods forces every command adapter to implement unused methods.
type PurchaseOrderRepositoryBefore interface {
    Save(ctx context.Context, po domain.PurchaseOrder) error
    FindByID(ctx context.Context, id domain.PurchaseOrderID) (domain.PurchaseOrder, error)
    // These two methods are only needed by query services — not by command services.
    FindByStatus(ctx context.Context, status domain.POStatus) ([]domain.PurchaseOrder, error)
    FindPendingApproval(ctx context.Context) ([]domain.PurchaseOrder, error)
}
 
// After CQRS — two focused ports.
// PurchaseOrderWriter is the command-side port: write-only.
type PurchaseOrderWriter interface {
    Save(ctx context.Context, po domain.PurchaseOrder) error
    FindByID(ctx context.Context, id domain.PurchaseOrderID) (domain.PurchaseOrder, error)
}
 
// PurchaseOrderReader is the query-side port: read-only, returns read models not aggregates.
type PurchaseOrderReader interface {
    FindByStatus(ctx context.Context, status domain.POStatus, page, pageSize int) ([]PurchaseOrderSummary, error)
    FindPendingApproval(ctx context.Context) ([]PurchaseOrderSummary, error)
}

Key takeaway: CQRS port segregation eliminates unused method obligations — command adapters implement only write methods, read adapters implement only read methods.

Why it matters: A fat repository interface forces every adapter to implement methods it never uses. In Go, that means a compile error ("does not implement interface") whenever a query method is added, even for adapters that serve only command services. Segregated ports give each adapter a minimal surface area, which reduces the blast radius of interface changes and makes adapters easier to reason about in isolation.


Example 38: CQRS command service

The command service Issue returns only the new PurchaseOrderID. It never returns a read model, never fetches a list, and never calls the read port.

// Package app — command service for the purchasing context.
// IssuePurchaseOrderCommandService handles only the write side.
package app
 
import (
    "context"
    "procurement/purchasing/domain"
)
 
// IssuePurchaseOrderCommandService orchestrates PO creation on the write side.
// It depends on PurchaseOrderWriter (not the full Repository) — CQRS segregation.
type IssuePurchaseOrderCommandService struct {
    // writer is the command-side port; does not expose query methods.
    writer   PurchaseOrderWriter
    // supRepo cross-context port for supplier eligibility check.
    supRepo  SupplierRepository
    // clock deterministic time for domain aggregate timestamps.
    clock    Clock
    // publisher sends PurchaseOrderIssued event after successful write.
    publisher EventPublisher
}
 
// Issue processes the command and returns only the new aggregate identity.
// It does NOT return the full PurchaseOrder — that is the query service's job.
func (s *IssuePurchaseOrderCommandService) Issue(ctx context.Context, cmd IssueCommand) (domain.PurchaseOrderID, error) {
    // Eligibility guard — cross-context call via output port.
    sup, err := s.supRepo.FindByID(ctx, cmd.SupplierID)
    if err != nil || !sup.IsApproved() {
        return "", ErrSupplierNotApproved
    }
    // Create domain aggregate using clock-provided timestamp.
    po, err := domain.NewPurchaseOrder(cmd.SupplierID, cmd.Items, s.clock.Now())
    if err != nil {
        return "", err
    }
    // Persist via write-side port only.
    if err := s.writer.Save(ctx, po); err != nil {
        return "", err
    }
    // Publish domain event; caller does not see the event — only the ID.
    _ = s.publisher.Publish(ctx, domain.PurchaseOrderIssuedEvent{ID: po.ID})
    // Return only the identity — no read model, no full aggregate.
    return po.ID, nil
}

Key takeaway: The command service's return type (PurchaseOrderID) signals its CQRS role — writes produce identities, not read models.

Why it matters: Returning the full aggregate from a command service couples the write and read models. When the read model gains display fields (formatted amounts, computed totals, denormalized supplier names), the command service must change even though it performs no reads. Returning only the ID keeps the command service immune to read-model evolution.


Example 39: CQRS query service

The query service FindPendingApproval returns read models, not domain aggregates. It depends only on PurchaseOrderReader — the write port is invisible to it.

// Package app — query service for the purchasing context.
// FindPurchaseOrdersQueryService handles only the read side.
package app
 
import "context"
 
// FindPurchaseOrdersQueryService serves read-only queries.
// It depends on PurchaseOrderReader (not the full Repository) — CQRS segregation.
type FindPurchaseOrdersQueryService struct {
    // reader is the query-side port; Save and FindByID are not accessible.
    reader PurchaseOrderReader
}
 
// FindPendingApproval returns read models for POs awaiting reviewer action.
// Returns []PurchaseOrderSummary — not []domain.PurchaseOrder.
// Read models are leaner: no business methods, no lifecycle invariants.
func (s *FindPurchaseOrdersQueryService) FindPendingApproval(ctx context.Context) ([]PurchaseOrderSummary, error) {
    // Delegate entirely to the read-side port; no domain logic applied here.
    return s.reader.FindPendingApproval(ctx)
}
 
// FindByStatus returns a paginated set of summaries filtered by status.
// Page numbering is 1-based; pageSize is clamped by the adapter if too large.
func (s *FindPurchaseOrdersQueryService) FindByStatus(ctx context.Context, status string, page, pageSize int) ([]PurchaseOrderSummary, error) {
    // Convert the string status to the typed domain enum before calling the port.
    return s.reader.FindByStatus(ctx, domain.POStatus(status), page, pageSize)
}

Key takeaway: The query service is a thin orchestrator — it delegates to the reader port without adding business logic, because queries are projections, not decisions.

Why it matters: Placing query logic in the same service as command logic creates services that grow unbounded. Every new filter, sort, or projection adds a method. CQRS gives teams a structural rule for when to split: if the operation reads without side effects, it belongs in the query service — full stop.


Example 40: Read-only output port — PurchaseOrderReadRepository

The read-side adapter implements PurchaseOrderReader and returns PurchaseOrderSummary read models instead of full aggregates. The adapter can be backed by a read replica, a materialized view, or a denormalized table.

// Package mem provides an in-memory read-side adapter for the purchasing context.
// It implements PurchaseOrderReader returning PurchaseOrderSummary read models.
package mem
 
import (
    "context"
    "procurement/purchasing/app"
    "procurement/purchasing/domain"
    "sync"
)
 
// InMemoryPurchaseOrderReader holds summaries projected from saved aggregates.
// In a real system this might tail an event log; here it shares the write store.
type InMemoryPurchaseOrderReader struct {
    mu       sync.RWMutex
    // summaries maps PurchaseOrderID to its read-model projection.
    summaries map[domain.PurchaseOrderID]app.PurchaseOrderSummary
}
 
// NewInMemoryPurchaseOrderReader constructs a reader with an empty summary store.
func NewInMemoryPurchaseOrderReader() *InMemoryPurchaseOrderReader {
    return &InMemoryPurchaseOrderReader{
        summaries: make(map[domain.PurchaseOrderID]app.PurchaseOrderSummary),
    }
}
 
// Project updates the read-side store when a new aggregate is saved.
// Called by tests and the in-memory write adapter after each save.
func (r *InMemoryPurchaseOrderReader) Project(po domain.PurchaseOrder) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.summaries[po.ID] = app.PurchaseOrderSummary{
        ID:         string(po.ID),
        SupplierID: string(po.SupplierID),
        // Status is formatted for display; domain enum is the source of truth.
        Status:     string(po.Status),
    }
}
 
// FindPendingApproval returns all summaries with PENDING_APPROVAL status.
func (r *InMemoryPurchaseOrderReader) FindPendingApproval(ctx context.Context) ([]app.PurchaseOrderSummary, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    var result []app.PurchaseOrderSummary
    for _, s := range r.summaries {
        if s.Status == string(domain.POPendingApproval) {
            result = append(result, s)
        }
    }
    return result, nil
}
 
// FindByStatus returns summaries filtered by status with simple page/pageSize slicing.
func (r *InMemoryPurchaseOrderReader) FindByStatus(ctx context.Context, status domain.POStatus, page, pageSize int) ([]app.PurchaseOrderSummary, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    var all []app.PurchaseOrderSummary
    for _, s := range r.summaries {
        if s.Status == string(status) {
            all = append(all, s)
        }
    }
    // Compute slice bounds for pagination — avoids out-of-bounds panic.
    start := (page - 1) * pageSize
    if start >= len(all) {
        return nil, nil
    }
    end := start + pageSize
    if end > len(all) {
        end = len(all)
    }
    return all[start:end], nil
}
 
// Compile-time assertion: *InMemoryPurchaseOrderReader must satisfy app.PurchaseOrderReader.
var _ app.PurchaseOrderReader = (*InMemoryPurchaseOrderReader)(nil)

Key takeaway: The read adapter returns projected read models rather than full aggregates, keeping query responses lean and decoupled from domain logic.

Why it matters: Returning full aggregates from query endpoints exposes every domain field, including sensitive lifecycle methods and internal invariants. Read models are shaped for the consumer — API contracts can add display fields, computed properties, or formatted strings without touching the domain aggregate. This is the practical benefit of the CQRS read model pattern.


Example 41: PO summary read model

PurchaseOrderSummary is a flat, display-ready struct that lives in app/query/. It carries no business methods, no lifecycle invariants, and no validation logic.

// Package query holds read models for the purchasing context.
// Read models are plain structs shaped for API responses — no business logic.
package query
 
// PurchaseOrderSummary is a read-model projection of the PurchaseOrder aggregate.
// It lives in app/query/, not in domain/ — consumers shape it, not the domain.
type PurchaseOrderSummary struct {
    // ID is the purchase order identity as a plain string for JSON serialisation.
    ID string `json:"id"`
    // SupplierID identifies the supplier, formatted for display.
    SupplierID string `json:"supplier_id"`
    // SupplierName is a denormalized display field; not in the aggregate.
    // Populated by the read adapter from a JOIN or a separate lookup.
    SupplierName string `json:"supplier_name,omitempty"`
    // Status is a human-readable string derived from the domain enum.
    Status string `json:"status"`
    // TotalAmount is a precomputed display value; the aggregate uses Money.
    TotalAmount string `json:"total_amount"`
    // CreatedAt is an ISO 8601 string for display; the aggregate uses time.Time.
    CreatedAt string `json:"created_at"`
}
// Note: PurchaseOrderSummary has no methods. It is a data bag for the API layer.
// The domain PurchaseOrder has methods (Submit, Approve) that this struct must not copy.

Key takeaway: A read model is a data transfer object shaped by its consumer, not by the domain — it may include denormalized fields, formatted strings, and computed values that would be inappropriate in the domain aggregate.

Why it matters: Forcing API consumers to call domain methods to format display fields leaks domain logic into the presentation layer. A dedicated read model allows the domain aggregate to evolve its internal representation (changing Money precision, renaming a field) without breaking the API contract. The read model absorbs the breaking change in its projection logic, not in every consumer.


Example 42: Paginated query — FindByStatus

Pagination belongs in the query port and its adapter, not in the service. The service passes page and pageSize to the reader port and returns the slice as-is.

// Package app — paginated query use case for the purchasing context.
// FindByStatusQuery carries both filter and pagination parameters.
package app
 
import "context"
 
// FindByStatusQuery carries the filter and pagination parameters for a status query.
// A dedicated query struct is cleaner than a long parameter list.
type FindByStatusQuery struct {
    // Status filters POs to a specific lifecycle state.
    Status string
    // Page is 1-based; page 1 returns the first pageSize records.
    Page int
    // PageSize is the maximum number of records per page; clamped by the adapter.
    PageSize int
}
 
// Page wraps a slice of items with the total count for the client to paginate.
type Page[T any] struct {
    // Items is the current page's slice of read models.
    Items []T
    // Total is the total number of matching records across all pages.
    Total int
}
 
// FindPurchaseOrdersQueryService — paginated query method.
// FindByStatus returns a Page of PurchaseOrderSummary for the given status and page.
func (s *FindPurchaseOrdersQueryService) FindByStatus(ctx context.Context, q FindByStatusQuery) (Page[PurchaseOrderSummary], error) {
    // Clamp pageSize to prevent enormous result sets from overwhelming the client.
    if q.PageSize <= 0 || q.PageSize > 100 {
        // Default to 20 if the caller provides an invalid page size.
        q.PageSize = 20
    }
    // Delegate to the read port; service does not know whether this is Postgres or memory.
    items, err := s.reader.FindByStatus(ctx, domain.POStatus(q.Status), q.Page, q.PageSize)
    if err != nil {
        return Page[PurchaseOrderSummary]{}, err
    }
    // Fetch the total count for pagination metadata.
    total, err := s.reader.CountByStatus(ctx, domain.POStatus(q.Status))
    if err != nil {
        return Page[PurchaseOrderSummary]{}, err
    }
    return Page[PurchaseOrderSummary]{Items: items, Total: total}, nil
}

Key takeaway: The Page<T> envelope communicates total record count alongside the current slice, giving API clients the information they need to render pagination controls without a second request.

Why it matters: An API that returns only a slice forces clients to request page after page until an empty page arrives — they can never render a "5 of 47 pages" control. The Page<T> pattern is a one-line change to the query service but a significant UX improvement. The service adds pagination clamping as the sole business rule; the adapter handles the SQL LIMIT/OFFSET.


Example 43: CQRS composition root

Wiring the command service and query service at main.go requires two separate service constructions. The shared Postgres pool provides both write and read adapters; in-memory variants serve development.

// main.go — CQRS composition root wiring command and query services separately.
package main
 
import (
    "database/sql"
    "log"
    "os"
    purchasingApp  "procurement/purchasing/app"
    purchasingHTTP "procurement/purchasing/adapter/in/http"
    purchasingPG   "procurement/purchasing/adapter/out/postgres"
    purchasingMem  "procurement/purchasing/adapter/out/mem"
)
 
func main() {
    dbURL := os.Getenv("DATABASE_URL")
 
    var writer    purchasingApp.PurchaseOrderWriter
    var reader    purchasingApp.PurchaseOrderReader
    var publisher purchasingApp.EventPublisher
 
    if dbURL != "" {
        db, err := sql.Open("pgx", dbURL)
        if err != nil { log.Fatalf("db open: %v", err) }
        // Command-side adapter — wraps *sql.DB behind the write port.
        pgRepo := purchasingPG.NewPurchaseOrderRepo(db)
        writer = pgRepo   // pgRepo implements both writer and reader ports.
        reader = purchasingPG.NewPurchaseOrderReadRepo(db)
        publisher = purchasingPG.NewOutboxEventPublisher(db)
    } else {
        memRepo := purchasingMem.NewPurchaseOrderRepo()
        writer = memRepo
        // In-memory reader shares projection state with the writer via a shared reference.
        memReader := purchasingMem.NewInMemoryPurchaseOrderReader()
        reader    = memReader
        publisher = &purchasingApp.InMemoryEventPublisher{}
    }
 
    // Command service depends on the write port only.
    cmdSvc := purchasingApp.NewIssuePurchaseOrderCommandService(writer, nil, nil, publisher)
    // Query service depends on the read port only — completely separate.
    qrySvc := purchasingApp.NewFindPurchaseOrdersQueryService(reader)
 
    // Mount both services on the HTTP router.
    router := purchasingHTTP.NewRouter(cmdSvc, qrySvc)
    log.Fatal(router.ListenAndServe(":8080"))
}

Key takeaway: The CQRS composition root creates two independent services from two independent ports — the wiring makes the read/write split explicit and step-debuggable.

Why it matters: In a single-service design, adding a new query method to the repository interface forces all command adapters to implement it. In CQRS, the command adapter (PurchaseOrderWriter) is unaffected. The read adapter (PurchaseOrderReader) gains the new method in isolation. Composition root separation makes this contractual independence visible and compile-enforced.


Section 6: Port Evolution (Examples 44–50)

Example 44: Adding a method to a port

When a new business requirement needs a new repository method, the recommended Go pattern embeds the old interface and extends it. Adapters that do not need the new method implement a no-op or a default.

// Package app — port extension by embedding the old interface.
// Embedding avoids forcing all existing adapters to break at once.
package app
 
import (
    "context"
    "procurement/purchasing/domain"
)
 
// PurchaseOrderRepository is the original port with three methods.
type PurchaseOrderRepository interface {
    Save(ctx context.Context, po domain.PurchaseOrder) error
    FindByID(ctx context.Context, id domain.PurchaseOrderID) (domain.PurchaseOrder, error)
    FindByStatus(ctx context.Context, status domain.POStatus) ([]domain.PurchaseOrder, error)
}
 
// PurchaseOrderRepositoryV2 extends the port by embedding V1 and adding FindBySupplier.
// Adapters that satisfy V1 satisfy V2 only after implementing FindBySupplier.
// This allows gradual migration: update one adapter at a time.
type PurchaseOrderRepositoryV2 interface {
    // Embed the previous interface — all V1 methods are implicitly part of V2.
    PurchaseOrderRepository
    // FindBySupplier is the new method required by the supplier-dashboard feature.
    // Returns all POs for a given supplier regardless of status.
    FindBySupplier(ctx context.Context, id domain.SupplierID) ([]domain.PurchaseOrder, error)
}
 
// DefaultFindBySupplier provides a fallback implementation for adapters not yet updated.
// Callers that need the real implementation must update their adapter.
func DefaultFindBySupplier(_ context.Context, _ domain.SupplierID) ([]domain.PurchaseOrder, error) {
    // Returns an empty result — safe default until the adapter is upgraded.
    return nil, nil
}

Key takeaway: Embedding the original interface in a V2 interface lets the team introduce new methods without immediately breaking all adapters — each adapter opts in on its own schedule.

Why it matters: A naive approach (adding the method to the existing interface) breaks all adapter compile units simultaneously. In a monorepo with many adapters, that means a single PR touches many files. The embedding / supertrait approach lets a team update adapters one at a time, merge incrementally, and keep the build green throughout the migration.


Example 45: Deprecating a port method

When a port method is no longer needed, marking it deprecated in a comment and providing a no-op default adapter implementation gives consumers time to migrate before removal.

// Package app — deprecating a port method with a no-op default adapter.
// The method stays in the interface during the deprecation window; the adapter no-ops it.
package app
 
import "context"
 
// EventPublisherV2 adds PublishBatch and deprecates Publish.
// Callers should migrate to PublishBatch during the deprecation window.
type EventPublisherV2 interface {
    // Publish is DEPRECATED. Use PublishBatch with a single-element slice instead.
    // Will be removed in the next major version after all callers migrate.
    Publish(ctx context.Context, event DomainEvent) error
    // PublishBatch delivers multiple events in a single operation.
    // More efficient than calling Publish in a loop; supports atomic delivery.
    PublishBatch(ctx context.Context, events []DomainEvent) error
}
 
// NoOpPublish is a default implementation of the deprecated Publish method.
// Adapters can embed this struct to satisfy the interface while migrating.
type NoOpPublish struct{}
 
// Publish does nothing — callers must migrate to PublishBatch.
// Logs a deprecation warning in development; silent in production.
func (NoOpPublish) Publish(_ context.Context, _ DomainEvent) error {
    // No-op: this method is deprecated; the adapter will remove it after migration.
    return nil
}

Key takeaway: A deprecation comment in the interface and a no-op default implementation give a migration window without breaking existing callers immediately.

Why it matters: Hard-removing a port method breaks every adapter in the monorepo simultaneously. The deprecation window approach is a contractual courtesy: all adapters keep compiling, the deprecation notice alerts developers to migrate, and removal happens in a later planned version. This is the standard Go stdlib deprecation pattern applied to internal ports.


Example 46: Port interface segregation — splitting Repository

The large PurchaseOrderRepository interface is split into PurchaseOrderWriter and PurchaseOrderReader. Existing adapters implement both; new dedicated adapters implement only one.

// Package app — interface segregation: split PurchaseOrderRepository into two.
// Existing adapters satisfy both; new adapters satisfy only the side they need.
package app
 
import (
    "context"
    "procurement/purchasing/domain"
)
 
// PurchaseOrderWriter is the command-side segregated port.
// Contains only the methods that modify purchase order state.
type PurchaseOrderWriter interface {
    // Save persists a new or updated aggregate.
    Save(ctx context.Context, po domain.PurchaseOrder) error
    // FindByID retrieves an aggregate for optimistic-lock checks before save.
    FindByID(ctx context.Context, id domain.PurchaseOrderID) (domain.PurchaseOrder, error)
}
 
// PurchaseOrderReader is the query-side segregated port.
// Contains only the methods that read state without modifying it.
type PurchaseOrderReader interface {
    // FindByStatus returns summaries filtered by the given status.
    FindByStatus(ctx context.Context, status domain.POStatus, page, pageSize int) ([]PurchaseOrderSummary, error)
    // CountByStatus returns the total record count for pagination metadata.
    CountByStatus(ctx context.Context, status domain.POStatus) (int, error)
}
 
// PurchaseOrderRepository composes both ports for adapters that implement everything.
// The in-memory and Postgres adapters both implement PurchaseOrderRepository.
// Services use the narrower Writer or Reader — not this combined interface.
type PurchaseOrderRepository interface {
    PurchaseOrderWriter
    PurchaseOrderReader
}

Key takeaway: Segregated ports let each service declare exactly the capabilities it needs — command services see no query methods, query services see no write methods.

Why it matters: Interface segregation is the "I" in SOLID. In Go, a 10-method interface forces every implementer to have 10 methods even if they use only 2. Splitting into focused ports makes each adapter minimal and each service's dependency explicit. When a query method's signature changes, only the read adapter and the query service are affected — the command service and write adapter are untouched.


Example 47: SupplierNotifierPort — notification interface with adapters

A notification port decouples the supplier approval service from email, SMS, and any future notification channel. The no-op adapter serves tests without sending real messages.

// Package app defines the SupplierNotifierPort for the supplier context.
// Notification channel (email, SMS, push) is an infrastructure concern isolated here.
package app
 
import (
    "context"
    "procurement/supplier/domain"
)
 
// SupplierNotifierPort sends notifications to suppliers about status changes.
// Concrete adapters: NoOp (tests), Email, SMS, Push (production).
type SupplierNotifierPort interface {
    // NotifyApproved sends a notification when a supplier is approved.
    // The adapter decides whether to send email, SMS, or both.
    NotifyApproved(ctx context.Context, s domain.Supplier) error
    // NotifyRejected sends a notification when a supplier is rejected.
    // Includes the rejection reason if the adapter supports it.
    NotifyRejected(ctx context.Context, s domain.Supplier, reason string) error
}
 
// NoOpSupplierNotifier satisfies SupplierNotifierPort without sending real messages.
// Used in unit tests to prevent real notifications during test runs.
type NoOpSupplierNotifier struct{}
 
// NotifyApproved does nothing — no real notification is sent.
func (NoOpSupplierNotifier) NotifyApproved(_ context.Context, _ domain.Supplier) error {
    return nil // No-op: safe to call in tests without email server.
}
 
// NotifyRejected does nothing — no real notification is sent.
func (NoOpSupplierNotifier) NotifyRejected(_ context.Context, _ domain.Supplier, _ string) error {
    return nil // No-op: safe to call in tests without SMS gateway.
}
 
// Compile-time assertion: NoOpSupplierNotifier must satisfy SupplierNotifierPort.
var _ SupplierNotifierPort = NoOpSupplierNotifier{}

Key takeaway: The no-op notifier prevents test runs from triggering real notifications while giving the service a fully injected dependency that satisfies the port contract.

Why it matters: Without the no-op pattern, test suites either mock the notifier (adding a mocking framework dependency) or hit real notification infrastructure (slow, side-effecting, brittle). The no-op is three lines of code that eliminates both problems. Adding an SMS adapter later requires only a new struct that implements two methods — zero changes to the service or the test suite.


Example 48: EventPublisher decoupling — domain never sees the event bus

The domain publishes a typed event object. The EventPublisher adapter wraps the NATS or Kafka client and handles serialisation. The domain imports nothing from the event bus library.

// Package nats provides an EventPublisher adapter backed by NATS JetStream.
// The domain's PurchaseOrderIssuedEvent is serialised here — not in the domain.
package nats
 
import (
    "context"
    "encoding/json"
    "fmt"
    natsgo "github.com/nats-io/nats.go"
    "procurement/purchasing/app"
)
 
// NATSEventPublisher wraps a NATS JetStream connection behind the EventPublisher port.
// The domain package has zero knowledge of NATS — only this adapter imports the client.
type NATSEventPublisher struct {
    // js is the JetStream context used for durable, acknowledged publishing.
    js natsgo.JetStreamContext
}
 
// NewNATSEventPublisher constructs the adapter given an open JetStream context.
func NewNATSEventPublisher(js natsgo.JetStreamContext) *NATSEventPublisher {
    return &NATSEventPublisher{js: js}
}
 
// Publish serialises the domain event to JSON and publishes it to NATS JetStream.
// The subject is derived from EventType() — e.g., "PurchaseOrderIssued".
func (p *NATSEventPublisher) Publish(_ context.Context, event app.DomainEvent) error {
    // Serialise the domain event to JSON; domain never knows JSON is used here.
    payload, err := json.Marshal(event)
    if err != nil {
        return fmt.Errorf("serialise event: %w", err)
    }
    // Derive the NATS subject from the event type — routing is adapter responsibility.
    subject := "procurement." + event.EventType()
    // Publish with acknowledgement; JetStream retries on transient network failures.
    _, err = p.js.Publish(subject, payload)
    return err
}
 
// Compile-time assertion: *NATSEventPublisher must satisfy app.EventPublisher.
var _ app.EventPublisher = (*NATSEventPublisher)(nil)

Key takeaway: The event bus client library import is confined to the adapter package — swapping from NATS to Kafka means writing a new adapter, not touching the domain or the application service.

Why it matters: The domain's PurchaseOrderIssuedEvent struct is a plain data object. The JSON serialisation format, the NATS subject naming convention, and the JetStream acknowledgement logic are all adapter decisions. If the team migrates to Kafka, they write KafkaEventPublisher, wire it at main.go, and retire NATSEventPublisher. Zero domain changes, zero service changes, zero test changes.


Example 49: Full intermediate test suite — unit, integration, and coverage map

A complete test suite for the intermediate purchasing service covers three levels: unit (in-memory), integration (real Postgres), and table-driven unit tests using Go's testing.T.

// Package app_test — table-driven unit tests for IssuePurchaseOrderCommandService.
// All tests use in-memory adapters; zero Docker, zero network, zero filesystem.
package app_test
 
import (
    "context"
    "testing"
    "procurement/purchasing/app"
    purchasingMem "procurement/purchasing/adapter/out/mem"
    supplierMem   "procurement/supplier/adapter/out/mem"
    supplierDomain "procurement/supplier/domain"
)
 
// issueTestCase describes a single scenario for the table-driven test.
type issueTestCase struct {
    name           string
    supplierStatus supplierDomain.SupplierStatus
    // wantErr is the expected error; nil means the command should succeed.
    wantErr        error
}
 
// TestIssuePurchaseOrderCommandService_Issue covers the main eligibility paths.
func TestIssuePurchaseOrderCommandService_Issue(t *testing.T) {
    // Table of test cases: one row per eligibility scenario.
    cases := []issueTestCase{
        {name: "approved supplier succeeds", supplierStatus: supplierDomain.SupplierApproved, wantErr: nil},
        {name: "pending supplier rejected",  supplierStatus: supplierDomain.SupplierPending,  wantErr: app.ErrSupplierNotApproved},
        {name: "rejected supplier blocked",  supplierStatus: supplierDomain.SupplierRejected, wantErr: app.ErrSupplierNotApproved},
    }
    for _, tc := range cases {
        // t.Run isolates each case; a failing case does not stop subsequent ones.
        t.Run(tc.name, func(t *testing.T) {
            // Arrange — seed supplier with the test case's status.
            supRepo := supplierMem.NewSupplierRepo()
            _ = supRepo.Save(context.Background(), supplierDomain.Supplier{
                ID:     "S-001",
                Name:   "ACME",
                Status: tc.supplierStatus,
            })
            // Wire service with in-memory adapters for this sub-test.
            svc := app.NewIssuePurchaseOrderCommandService(
                purchasingMem.NewPurchaseOrderRepo(),
                supRepo,
                app.FixedClock{},
                &app.InMemoryEventPublisher{},
            )
            // Act — attempt to issue a PO against the seeded supplier.
            _, err := svc.Issue(context.Background(), app.IssueCommand{
                SupplierID: "S-001",
                Items:      []app.LineItem{{SKU: "X", Quantity: 1}},
            })
            // Assert — compare actual error to expected.
            if err != tc.wantErr {
                t.Errorf("[%s] expected %v, got %v", tc.name, tc.wantErr, err)
            }
        })
    }
}

Key takeaway: Table-driven tests express all eligibility paths in a compact data structure, making it easy to add new scenarios without duplicating setup and teardown code.

Why it matters: Five separate test functions for five supplier status scenarios each duplicate 15 lines of setup. A table-driven test expresses all five in a 10-row data structure with shared setup. When the IssueCommand shape changes, only the shared setup changes — not five separate functions. This scales to 20 scenarios without cognitive overhead.


Example 50: Full intermediate flow recap — CQRS + query facade + notifier

flowchart TD
    A["HTTP POST<br/>primary adapter"]:::blue
    B["IssuePurchaseOrderCommandService<br/>command side"]:::teal
    C["PurchaseOrderWriter<br/>output port"]:::orange
    D["PostgresPurchaseOrderRepo<br/>write adapter"]:::teal
    E["SupplierRepository<br/>output port"]:::orange
    F["PostgresSupplierRepo<br/>read adapter"]:::teal
    G["EventPublisher<br/>output port"]:::orange
    H["NATSEventPublisher<br/>adapter"]:::teal
 
    I["HTTP GET<br/>primary adapter"]:::blue
    J["FindPurchaseOrdersQueryService<br/>query side"]:::teal
    K["PurchaseOrderReader<br/>output port"]:::orange
    L["PostgresPurchaseOrderReadRepo<br/>read adapter"]:::teal
 
    M["SupplierNotifierPort<br/>output port"]:::orange
    N["EmailNotifier<br/>adapter"]:::teal
 
    A -->|"IssueCommand"| B
    B -->|"Save(po)"| C
    C --> D
    B -->|"FindByID(supplierID)"| E
    E --> F
    B -->|"Publish(event)"| G
    G --> H
    B -->|"NotifyApproved(supplier)"| M
    M --> N
    B -->|"PurchaseOrderID"| A
 
    I -->|"FindByStatusQuery"| J
    J -->|"FindByStatus(status,page)"| K
    K --> L
    J -->|"Page[PurchaseOrderSummary]"| I
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#000000,stroke-width:2px

This diagram shows the complete intermediate wiring: the command path (left) and the query path (right) each pass through their own ports and adapters without sharing any code paths after the composition root.

Key takeaway: The CQRS split produces two independent call graphs — command and query — that share only the composition root and the domain types; every port and adapter is independently swappable.

Why it matters: The diagram above is the architecture's public contract. Any change that stays within a single adapter (swapping NATS for Kafka in the EventPublisher box) is invisible to every other box. Changes that affect a port interface (adding a method to PurchaseOrderWriter) are visible in exactly one box and affect only the adapters and services connected to it. This is the structural isolation hexagonal architecture promises and that this tutorial demonstrates through 30 executable examples.


Further Reading

Last updated May 23, 2026

Command Palette

Search for a command to run...