Beginner
Examples 1–20 introduce hexagonal architecture (ports and adapters) using a procurement platform domain (purchasing context). Go is the canonical language throughout; Rust appears as a parallel formulation where ownership reshapes port design. Every code block is self-contained and targets annotation density of 1.0–2.25 comment lines per code line per example.
The Three Zones (Examples 1–4)
Example 1: The hexagon metaphor — three zones as Go packages
Hexagonal architecture divides every application into three concentric zones: the domain (pure business logic), the application (use-case orchestration + port interfaces), and the adapters (technology connectors). In Go, each zone is a distinct directory under the bounded context root, which becomes its own package. Domain imports nothing; application imports only domain; adapters import application plus any framework or driver.
%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05
graph TD
subgraph Adapter["Adapter Zone #40;outermost#41;"]
WEB["handler.go\n#40;chi HTTP adapter#41;"]:::orange
DB["mem_repo.go\n#40;in-memory adapter#41;"]:::orange
end
subgraph Application["Application Zone #40;middle#41;"]
UC["UseCase interface\n#40;input port#41;"]:::teal
REPO["Repository interface\n#40;output port#41;"]:::teal
SVC["service.go\n#40;application service#41;"]:::teal
end
subgraph Domain["Domain Zone #40;innermost#41;"]
PO["PurchaseOrder\n#40;aggregate root#41;"]:::blue
POID["PurchaseOrderID\n#40;value object#41;"]:::blue
MONEY["Money\n#40;value object#41;"]:::blue
end
WEB -- "calls" --> UC
SVC -- "implements" --> UC
SVC -- "calls" --> REPO
DB -- "implements" --> REPO
SVC -- "uses" --> PO
PO -- "has" --> POID
PO -- "has" --> MONEY
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
// Zone 1: Domain — zero framework imports allowed
// => directory: purchasing/domain/
// => only stdlib (fmt, errors) and sibling domain types permitted
package domain
// PurchaseOrder: pure Go struct — no db tags, no json tags, no framework deps
// => compiles and runs without any external library on the module graph
type PurchaseOrder struct {
ID PurchaseOrderID // => strongly-typed identity (format: po_<uuid>)
SupplierID SupplierID // => distinct type; cannot swap with PurchaseOrderID accidentally
Total Money // => Money carries both amount and ISO 4217 currency
Status POStatus // => domain string-type enum; no framework dependency
}
// Zone 2: Application — imports domain only
// => directory: purchasing/app/
// => may import purchasing/domain; must not import purchasing/adapter or any framework
package app
// Zone 3: Adapter — imports application and framework
// => directory: purchasing/adapter/in/http/ or purchasing/adapter/out/mem/
// => may import purchasing/app, github.com/go-chi/chi, etc.
package httpKey Takeaway: Domain imports nothing; application imports domain; adapters import application and frameworks. The dependency arrow always points inward.
Why It Matters: When the domain package has zero external imports, every domain test runs with go test ./purchasing/domain/... in under a millisecond — no server, no database, no module download. Swapping chi for echo, or a Postgres adapter for an in-memory one, becomes a one-directory change confined to the adapter layer. Rob Pike's Go at Google (2012 SPLASH) notes that large-scale Go programmes stay maintainable through strict package boundaries — hexagonal architecture operationalises exactly that discipline.
Example 2: Domain entity — pure Go struct, no framework tags
A domain entity holds only business state and behaviour. Framework tags (json:"...", db:"...", bson:"...") are infrastructure concerns that belong in adapter-layer DTOs and repository mapping structs. Placing them in the domain couples the domain to a specific serialisation or persistence framework and forces recompilation whenever that framework changes.
Anti-pattern — framework tags in the domain:
// WRONG: json and db tags leak infrastructure into the domain
// => domain struct now carries serialisation and ORM coupling at the type level
type PurchaseOrder struct {
ID string `json:"id" db:"id"` // => json/db tags: adapter concerns
SupplierID string `json:"supplier_id" db:"supplier_id"` // => raw string: typed safety lost
Total float64 `json:"total_amount" db:"total_amount"` // => loses currency information
Status string `json:"status" db:"status"` // => stringly-typed: compiler cannot validate
}
// Problem: tests must import encoding/json or database/sql to exercise the struct
// => coupling cost: every domain test pulls in serialisation/persistence librariesCorrect — clean domain struct:
// PurchaseOrder: zero framework imports; compiles with only the standard library
// => no json tags, no db tags, no bson tags, no ORM annotations
package domain
// PurchaseOrder: aggregate root for the purchasing bounded context
// => struct fields use domain types — not raw primitives
// => all fields exported (uppercase): idiomatic Go; domain is a shared package
type PurchaseOrder struct {
ID PurchaseOrderID // => domain value object; not raw string
SupplierID SupplierID // => distinct type; prevents id-kind confusion at compile time
Total Money // => Money{Amount, Currency}; richer than float64 alone
Status POStatus // => string-type enum; only valid constants exist
}
// Submit: domain behaviour — pure function, no I/O, no framework calls
// => returns a new PurchaseOrder with status transitioned to AwaitingApproval
// => Go pattern: return (value, error) for operations that can fail with a domain rule
func (po PurchaseOrder) Submit() (PurchaseOrder, error) {
if po.Status != POStatusDraft { // => guard: only DRAFT can be submitted
return PurchaseOrder{}, ErrInvalidTransition // => domain error; no HTTP status here
// => caller (application service) maps this to an appropriate response
}
po.Status = POStatusAwaitingApproval // => state copy: Go structs are value types by default
return po, nil // => new state returned; original po is unchanged
// => state transition: DRAFT → AWAITING_APPROVAL
}
// Test: domain.PurchaseOrder{...} — no framework needed; sub-millisecondKey Takeaway: Domain structs carry only business state and rules. Zero framework tags means zero framework test dependencies.
Why It Matters: A Go struct with no framework tags instantiates in any test without importing encoding/json or a database driver. Switching from pgx to sqlx, or from JSON to MessagePack serialisation, touches only adapter files — thousands of lines of domain logic remain unchanged. Alistair Cockburn's Hexagonal Architecture (2005) identifies this isolation of the application core as the central purpose of the pattern.
Example 3: Value objects — PurchaseOrderID and Money in Go
Value objects encapsulate a primitive value plus its invariants. They make illegal states unrepresentable at the type level and prevent the billion-dollar mistake of mixing up raw strings or bare numerics across a codebase.
// Domain value objects: PurchaseOrderID, SupplierID, and Money
// => package purchasing/domain
package domain
import (
"errors" // => stdlib: ErrInvalidPurchaseOrderID construction
"fmt" // => stdlib: error message formatting
"strings" // => stdlib: prefix validation
)
// PurchaseOrderID: wraps a string but enforces the "po_<uuid>" format invariant
// => named type (not typedef): distinct from string and from SupplierID at compile time
// => Go does not have constructors; NewPurchaseOrderID is the factory function convention
type PurchaseOrderID string
// NewPurchaseOrderID: factory function — validates invariants before returning a value
// => returns (PurchaseOrderID, error): caller must handle the error; no panic on bad input
func NewPurchaseOrderID(value string) (PurchaseOrderID, error) {
if !strings.HasPrefix(value, "po_") || len(value) < 39 {
// => format invariant: "po_" prefix + 36-char UUID = minimum 39 chars total
return "", fmt.Errorf("invalid PurchaseOrderID %q: must start with po_ and be ≥39 chars", value)
// => zero-value returned on error; caller uses the error, not the returned ID
}
return PurchaseOrderID(value), nil // => valid: wrap raw string in the named type
// => return type is PurchaseOrderID, not string; cannot be accidentally passed where SupplierID expected
}
// => NewPurchaseOrderID("po_550e8400-e29b-41d4-a716-446655440000") → PurchaseOrderID, nil
// => NewPurchaseOrderID("abc") → "", error "must start with po_ ..."
// => NewPurchaseOrderID("") → "", error "must start with po_ ..."
// Money: immutable value object combining amount + ISO 4217 currency code
// => struct: two related fields; richer than a bare float64 alone
// => prevents silently mixing USD and EUR — they are the same type, but amount comparison
// without matching currencies is a domain error caught in domain logic
type Money struct {
Amount int64 // => store as minor units (cents); avoids floating-point precision bugs
Currency string // => ISO 4217 code; e.g. "USD", "IDR", "EUR" — exactly 3 chars
}
// NewMoney: validates both fields before returning a Money value
// => returns error on invalid input; no Money with negative amount or wrong currency code
func NewMoney(amount int64, currency string) (Money, error) {
if amount < 0 {
// => invariant: negative money has no meaning in a P2P procurement domain
return Money{}, errors.New("money amount must be >= 0")
// => caller sees the clear message; construction cannot produce negative Money
}
if len(currency) != 3 {
// => ISO 4217: all currency codes are exactly 3 uppercase letters (USD, EUR, IDR)
return Money{}, fmt.Errorf("currency %q must be a 3-letter ISO 4217 code", currency)
// => "US" (2 chars) and "USDD" (4 chars) both fail this guard
}
return Money{Amount: amount, Currency: currency}, nil // => valid: both fields pass invariants
// => Money{Amount: 150000, Currency: "USD"} represents $1500.00 in minor units
}
// => NewMoney(150000, "USD") → Money{150000,"USD"}, nil
// => NewMoney(-1, "USD") → Money{}, error "must be >= 0"
// => NewMoney(500, "US") → Money{}, error "must be 3-letter ISO 4217"Key Takeaway: Value objects enforce invariants at construction, making invalid states impossible to represent downstream.
Why It Matters: When PurchaseOrderID and SupplierID are distinct named types in Go, the compiler rejects repository.FindByID(supplierID) — the wrong type is caught before any test runs. Encoding the po_ prefix in the factory function means no HTTP handler, application service, or repository needs to re-validate format. Three Dots Labs' DDD + CQRS + Clean Architecture in Go demonstrates exactly this pattern in production Go services.
Example 4: The dependency rule — what can import what in Go
The dependency rule is the single most important invariant in hexagonal architecture: dependencies always point inward. Outer zones depend on inner zones; inner zones never depend on outer zones. In Go, this is enforced by package import paths — any import that crosses the boundary in the wrong direction is visible in go mod graph and catchable with golang.org/x/tools/go/analysis.
Legal imports (inward dependencies only):
// Application zone: may import domain types — inward dependency is always legal
// => file: purchasing/app/service.go
package app
import (
"purchasing/domain" // => ok: inward dependency; app → domain
// => domain.PurchaseOrder, domain.PurchaseOrderID, domain.Money all available
)
// Adapter zone: may import application types — one step further outward
// => file: purchasing/adapter/in/http/handler.go
package http
import (
"purchasing/app" // => ok: inward; adapter → app; adapter never imports domain directly
// => app.IssuePurchaseOrderUseCase interface available; domain types accessed via app
)Illegal imports (outward dependencies — architecture violations):
// Domain zone importing Application: FORBIDDEN — never do this
// => file: purchasing/domain/purchase_order.go (hypothetical violation)
package domain
// import "purchasing/app" // => NEVER: domain → app creates circular import
// => Go compiler would reject this with "import cycle not allowed"
// => Go's circular import detection is a free enforcement mechanism for hexagonal
// Application zone importing Adapter: FORBIDDEN — never do this
// => file: purchasing/app/service.go (hypothetical violation)
package app
// import "purchasing/adapter/in/http" // => NEVER: app → adapter is outward
// => application service would depend on chi router; cannot test without HTTP stack
// => Go compiler rejects circular imports; non-circular outward imports must be caught
// with an architecture linter such as github.com/fdaines/arch-goKey Takeaway: Dependencies always point inward — domain ← application ← adapters. Go's circular import rejection enforces this mechanically for circular violations; arch-go catches non-circular outward imports.
Why It Matters: Go's compiler rejects circular imports, which means a domain package that accidentally imports the application package fails to compile — the dependency rule violation surfaces immediately in go build. For non-circular violations (e.g., app importing adapter), tools like arch-go or Cargo workspace crate separation in Rust provide the same guarantee. In a growing P2P platform this protection becomes increasingly valuable: as the team scales, manual enforcement of architectural rules is unreliable. The compiler and linter make the hexagon self-defending.
Port Interfaces (Examples 5–8)
Example 5: Output port — PurchaseOrderRepository interface in Go
An output port is a small Go interface placed in the app/ package. It expresses what the application needs from the outside world using domain language only. Go's structural typing means any type that has the correct method set satisfies the interface — no implements declaration is required. This is Cockburn's original port definition implemented with zero syntactic ceremony.
// Output port: lives in app package; speaks domain language only
// => file: purchasing/app/ports.go
package app
import "purchasing/domain" // => only domain import; no database/sql, no pgx, no framework
// PurchaseOrderRepository: output port for PO persistence
// => small interface: 3 methods; Go convention is interfaces ≤5 methods
// => any struct with these methods satisfies this interface — no declaration coupling
type PurchaseOrderRepository interface {
// Save: persist a PurchaseOrder; return the saved instance
// => takes domain type; returns domain type; no SQL, no pgx.Rows visible here
Save(po domain.PurchaseOrder) (domain.PurchaseOrder, error)
// => caller: repo.Save(po) — does not know if storage is Postgres or in-memory map
// FindByID: retrieve a PurchaseOrder by its typed identity
// => (domain.PurchaseOrder, bool): Go idiom for "found or not found"; no sentinel nil
FindByID(id domain.PurchaseOrderID) (domain.PurchaseOrder, bool)
// => returns (po, true) when found; (zero, false) when not found — caller checks the bool
// ExistsByID: lightweight existence check without loading the full aggregate
// => useful for duplicate-check guard before saving a new PO
ExistsByID(id domain.PurchaseOrderID) bool
// => returns true if PO with given id is present; false otherwise; no aggregate loaded
}
// => application service calls this interface; zero coupling to Postgres or any driver
// => structural typing: InMemoryPurchaseOrderRepository satisfies this without any declarationKey Takeaway: Output ports are small interfaces in the app/ package that speak only domain language — no SQL, no driver types, no framework.
Why It Matters: Because PurchaseOrderRepository is an interface, the application service can be tested with an in-memory implementation that runs in microseconds. Go's structural typing means the in-memory test adapter requires only matching method signatures — no coupling declaration. Swapping from a pgx adapter to a sqlx adapter later means writing one new struct with the same three methods — the application service and every test remain unchanged.
Example 6: Clock output port — making time testable in Go
Time is an implicit dependency. Code that calls time.Now() directly is non-deterministic and cannot be tested without mocking the system clock. Wrapping time behind a Clock output port makes the dependency explicit and swappable.
// Clock output port: time as an explicit dependency
// => file: purchasing/app/ports.go (alongside PurchaseOrderRepository)
package app
import "time" // => stdlib only; no framework import
// Clock: output port; returns current time as a stdlib time.Time
// => Adapter in production: returns time.Now() from the system clock
// => Adapter in tests: returns a fixed time.Time — deterministic; no sleep() needed
type Clock interface {
Now() time.Time
// => test adapter: type FixedClock struct{ T time.Time }; func (c FixedClock) Now() time.Time { return c.T }
// => single-method interface: very easy to satisfy with a small struct or function literal
}
// Usage inside an application service — clock.Now() instead of time.Now()
// => file: purchasing/app/service.go
type IssuePurchaseOrderService struct {
repo PurchaseOrderRepository // => output port; injected at wiring time
clock Clock // => output port; testable time source
// => both fields are interface types; concrete adapter chosen at composition root
}
func (s *IssuePurchaseOrderService) Execute(cmd IssuePOCommand) (domain.PurchaseOrder, error) {
issuedAt := s.clock.Now() // => explicit; testable; no hidden time.Now() calls
// => issuedAt: time.Time — embedded in the issued PO or a domain event
_ = issuedAt // => used to timestamp the PO in a real implementation
return domain.PurchaseOrder{}, nil
}Key Takeaway: Wrapping the system clock behind a Clock port makes time an explicit, swappable dependency — test adapters return fixed timestamps.
Why It Matters: Time-dependent business rules ("PO must be issued within 30 days of approval") become deterministically testable. Tests run at the same speed regardless of wall-clock time. In Go, FixedClock{T: knownTime} satisfies the Clock interface with two lines — no mock framework required.
Example 7: Input port — IssuePurchaseOrderUseCase interface
An input port is a small Go interface in the app/ package that defines a use case the application exposes to the outside world. Primary adapters (HTTP handlers, CLI commands, event consumers) call input ports — they never call application service structs directly. This keeps the adapter decoupled from service implementation details.
// Input port: use-case interface in the app package
// => file: purchasing/app/ports.go
package app
import "purchasing/domain" // => domain types only in the port signature
// IssuePOCommand: immutable command carrying everything the use case needs
// => struct with value semantics: passed by copy; no pointer needed for small commands
// => raw strings from the HTTP layer; application service validates and converts them
type IssuePOCommand struct {
SupplierID string // => raw string from HTTP body; validated inside the service
TotalAmount int64 // => minor units (cents) from the JSON payload
TotalCurrency string // => ISO 4217 code; service validates 3-letter rule
}
// IssuePurchaseOrderUseCase: input port; defines the use-case contract
// => HTTP handler calls this interface; never the concrete IssuePurchaseOrderService struct
// => Go structural typing: handler couples to the interface, not the implementation
type IssuePurchaseOrderUseCase interface {
// Execute: the single method of this use case
// => takes a command (inbound DTO); returns the resulting domain object or an error
Execute(cmd IssuePOCommand) (domain.PurchaseOrder, error)
// => domain error returned to adapter; adapter maps it to HTTP 422 or 400
}
// => any struct with Execute(IssuePOCommand)(domain.PurchaseOrder, error) satisfies this
// => test double: type FakeUseCase struct{}; func (f FakeUseCase) Execute(...) — 2 linesKey Takeaway: Input ports are small interfaces in the app/ package — primary adapters depend on the interface, not the concrete service struct.
Why It Matters: When an HTTP handler depends on IssuePurchaseOrderUseCase (an interface), a test can swap in a fake returning a known PurchaseOrder in two lines — no HTTP server needed. Adding a CLI adapter that calls the same use case requires zero changes to the service or the interface.
Example 8: Go structural typing — no implements declaration
Go's structural typing is why hexagonal architecture maps so cleanly to the language. A type satisfies an interface by having the methods — no implements keyword, no declaration coupling. This means an adapter written after the port was defined requires only matching the method set.
// Go structural typing: no implements keyword needed
// => file: purchasing/adapter/out/mem/repo.go
package mem
import (
"sync" // => stdlib: mutex for concurrent access
"purchasing/app" // => import app package for the port interface
"purchasing/domain" // => import domain package for entity types
)
// InMemoryPurchaseOrderRepository: satisfies app.PurchaseOrderRepository implicitly
// => Go compiler checks at compile time that this struct has the required methods
// => zero declaration coupling: no "implements PurchaseOrderRepository" anywhere
type InMemoryPurchaseOrderRepository struct {
mu sync.RWMutex // => mutex: safe concurrent reads and writes
store map[domain.PurchaseOrderID]domain.PurchaseOrder // => backing store: pure in-memory map
}
// NewInMemoryPurchaseOrderRepository: constructor function — idiomatic Go pattern
func NewInMemoryPurchaseOrderRepository() *InMemoryPurchaseOrderRepository {
return &InMemoryPurchaseOrderRepository{
store: make(map[domain.PurchaseOrderID]domain.PurchaseOrder), // => empty map initialised
// => make() required: nil map causes panic on write; must initialise before use
}
}
// Save: satisfies PurchaseOrderRepository.Save — method signature must match exactly
func (r *InMemoryPurchaseOrderRepository) Save(po domain.PurchaseOrder) (domain.PurchaseOrder, error) {
r.mu.Lock() // => exclusive lock: prevents concurrent write conflicts
defer r.mu.Unlock() // => deferred unlock: released when function returns; cannot forget
r.store[po.ID] = po // => key = typed PurchaseOrderID; value = PurchaseOrder struct copy
return po, nil // => return the saved instance; nil error means success
// => structural typing: this method makes the struct satisfy PurchaseOrderRepository.Save
}
// FindByID: satisfies PurchaseOrderRepository.FindByID
func (r *InMemoryPurchaseOrderRepository) FindByID(id domain.PurchaseOrderID) (domain.PurchaseOrder, bool) {
r.mu.RLock() // => shared read lock: multiple concurrent reads allowed
defer r.mu.RUnlock() // => deferred unlock: released at function return
po, ok := r.store[id] // => map lookup: ok = true if found; ok = false if absent
return po, ok // => (zero PurchaseOrder, false) when not found; no nil pointer
// => (domain.PurchaseOrder, bool) idiom: avoids nil-pointer dereference bugs
}
// ExistsByID: satisfies PurchaseOrderRepository.ExistsByID
func (r *InMemoryPurchaseOrderRepository) ExistsByID(id domain.PurchaseOrderID) bool {
r.mu.RLock() // => shared read lock: safe for concurrent existence checks
defer r.mu.RUnlock()
_, ok := r.store[id] // => blank identifier: discard the value; only need the bool
return ok // => true = found; false = not found; O(1) hash map lookup
}
// Compile-time interface satisfaction check (Go idiom)
// => var _ app.PurchaseOrderRepository = (*InMemoryPurchaseOrderRepository)(nil)
// => this line causes a compile error if the struct no longer satisfies the interface
// => zero-cost: nil pointer cast; never executed at runtime
var _ app.PurchaseOrderRepository = (*InMemoryPurchaseOrderRepository)(nil)Key Takeaway: Go's structural typing means an adapter satisfies a port interface by matching the method set — no implements declaration couples the adapter to the port at the source level.
Why It Matters: In Java you must write implements PurchaseOrderRepository; in Go that coupling does not exist. A new adapter (e.g., a Redis adapter) written months after the port interface was defined requires only matching the three method signatures — no change to the port, no change to the service, no declaration update. This is why Cockburn's language-agnostic port definition maps most cleanly to Go's structural typing.
The Domain (Examples 9–12)
Example 9: POStatus — Go string-type enum
Go has no built-in enum keyword. The idiomatic pattern is a named string (or int) type plus a set of package-level constants. This gives compile-time type safety without requiring a full ADT.
// POStatus: string-type enum in Go
// => file: purchasing/domain/po_status.go
package domain
// POStatus: named string type — distinct from plain string at compile time
// => prevents passing an arbitrary string where a status is expected
type POStatus string
// Package-level constants: the only valid POStatus values
// => const block: idiomatic for a set of related named constants
const (
POStatusDraft POStatus = "DRAFT"
// => initial state when PO is first created in the system
POStatusAwaitingApproval POStatus = "AWAITING_APPROVAL"
// => PO submitted by requester; waiting for approver sign-off
POStatusApproved POStatus = "APPROVED"
// => approver confirmed the PO; next step is issuing to supplier
POStatusIssued POStatus = "ISSUED"
// => PO sent to the supplier; awaiting goods receipt
POStatusCancelled POStatus = "CANCELLED"
// => PO cancelled before goods receipt; terminal state
)
// IsValid: helper to check if a deserialized status string is a known value
// => useful at the adapter boundary when loading from database or HTTP body
func (s POStatus) IsValid() bool {
switch s {
case POStatusDraft, POStatusAwaitingApproval, POStatusApproved, POStatusIssued, POStatusCancelled:
return true // => all known values return true
default:
return false // => unknown value: reject at the adapter layer before entering domain
}
}
// => POStatus("DRAFT").IsValid() → true
// => POStatus("GARBAGE").IsValid() → false
// => Go's type system prevents: var s POStatus = "anything" — but const values are type-checkedKey Takeaway: Go string-type enums provide compile-time type safety; Rust enums provide exhaustive pattern matching enforced by the compiler.
Why It Matters: When POStatus is a named type rather than a raw string, the compiler prevents repo.FindByStatus("PENDING") where "PENDING" is not a valid constant. Adding a new lifecycle state requires updating the switch in Go or the match in Rust — the compiler points to every place that needs updating. This makes lifecycle transitions self-documenting and refactoring-safe.
Example 10: Pure domain entity — PurchaseOrder with Submit transition
The domain entity encapsulates business rules as methods. State transitions are pure functions: they take the current state, validate the business rule, and return a new state (or an error). No I/O, no framework calls, no side effects.
// PurchaseOrder: aggregate root with domain behaviour
// => file: purchasing/domain/purchase_order.go
package domain
import "errors"
// ErrInvalidTransition: domain error for illegal state transitions
// => sentinel error: compare with errors.Is(); no framework dependency
var ErrInvalidTransition = errors.New("invalid purchase order state transition")
// PurchaseOrder: aggregate root struct — no framework tags
type PurchaseOrder struct {
ID PurchaseOrderID // => typed identity; format: po_<uuid>
SupplierID SupplierID // => typed supplier reference; format: sup_<uuid>
Total Money // => minor-unit amount + 3-letter currency code
Status POStatus // => lifecycle state; only valid constants
}
// Submit: domain behaviour — pure transition from DRAFT to AWAITING_APPROVAL
// => value receiver: Go struct is copied; original po is never mutated
// => returns (PurchaseOrder, error): either the new state or a domain error
func (po PurchaseOrder) Submit() (PurchaseOrder, error) {
if po.Status != POStatusDraft {
// => guard: only DRAFT POs can be submitted; any other status is a domain violation
return PurchaseOrder{}, ErrInvalidTransition
// => zero value returned with error; caller must check error before using the result
}
po.Status = POStatusAwaitingApproval // => struct copy modified: original po is unchanged
// => Go value semantics: modifying po on the copy does not affect the caller's variable
return po, nil // => return the new state; nil error signals success
// => state transition: DRAFT → AWAITING_APPROVAL; no I/O; sub-microsecond
}
// Approve: domain behaviour — transition from AWAITING_APPROVAL to APPROVED
// => same pattern: value receiver, copy-and-modify, return new state or error
func (po PurchaseOrder) Approve() (PurchaseOrder, error) {
if po.Status != POStatusAwaitingApproval {
return PurchaseOrder{}, ErrInvalidTransition // => domain rule: only awaiting POs can be approved
}
po.Status = POStatusApproved // => copy modified; original unchanged
return po, nil // => state transition: AWAITING_APPROVAL → APPROVED
}
// Test: po := domain.PurchaseOrder{..., Status: POStatusDraft}; submitted, err := po.Submit()
// => no framework; no DB; sub-millisecond; pure functionKey Takeaway: Domain state transitions are pure functions — they return a new value or a domain error; they never call I/O or mutate global state.
Why It Matters: Pure domain methods test instantly without any infrastructure. A suite of 200 domain tests completes in under a second in both Go and Rust. Because the domain is a pure function over its inputs, the same logic runs identically in production, in tests, and in any future CLI or batch processing adapter — the behaviour is infrastructure-independent.
Example 11: SupplierId value object and SupplierID as a distinct type
A bounded context references external entities by their identity only. The purchasing context does not own the Supplier aggregate — it holds a SupplierID reference. Making SupplierID a distinct named type prevents accidental confusion with PurchaseOrderID at the type level.
// SupplierID: distinct named type from PurchaseOrderID
// => file: purchasing/domain/supplier_id.go
package domain
import (
"fmt" // => stdlib: error formatting
"strings" // => stdlib: prefix validation
)
// SupplierID: named string type — distinct from PurchaseOrderID at compile time
// => Go's type system: SupplierID("x") != PurchaseOrderID("x") as distinct types
type SupplierID string
// NewSupplierID: factory function — validates the "sup_<uuid>" format invariant
func NewSupplierID(value string) (SupplierID, error) {
if !strings.HasPrefix(value, "sup_") || len(value) < 40 {
// => format invariant: "sup_" prefix (4 chars) + 36-char UUID = minimum 40 chars
return "", fmt.Errorf("invalid SupplierID %q: must start with sup_ and be ≥40 chars", value)
// => zero-value string returned with error; caller must not use the returned ID on error
}
return SupplierID(value), nil // => valid: wrap raw string in the named type
// => returned type is SupplierID; compiler prevents passing it where PurchaseOrderID expected
}
// ExampleUsage demonstrates the type safety at compile time
// => func example() {
// poID, _ := NewPurchaseOrderID("po_550e8400-e29b-41d4-a716-446655440000")
// supID, _ := NewSupplierID("sup_660f9511-f3ac-52e5-b827-557766551111")
// po := PurchaseOrder{ID: poID, SupplierID: supID, ...} // => correct: distinct types
// _ = PurchaseOrder{ID: supID, ...} // => compile error: cannot use SupplierID as PurchaseOrderID
// }
// => Go type checker catches the swap at compile time; no test needed for this class of bugKey Takeaway: Distinct named types for different IDs make accidental argument swaps a compile-time error in both Go and Rust.
Why It Matters: Without typed IDs, repository.FindBySupplierID(purchaseOrderID) is a runtime bug that only surfaces under specific test conditions. With named types, the compiler rejects the call immediately. In a procurement platform with five or more bounded contexts, each having multiple entity IDs, typed IDs eliminate an entire class of subtle wiring bugs that otherwise appear only in integration tests or production incidents.
Example 12: Dependency direction test — enforcing the rule with go test
The dependency rule should be machine-verified, not trusted to code review. Go provides golang.org/x/tools/go/analysis and community tools like arch-go for this purpose. A simpler approach uses go list in a shell-based test to enumerate imports and assert no violations.
// Dependency rule test: verify package import directions in CI
// => file: purchasing/arch_test.go
// => uses only stdlib: os/exec, testing, strings — no external analysis library needed
package purchasing_test
import (
"os/exec" // => stdlib: run go list to enumerate package imports
"strings" // => stdlib: string parsing of go list output
"testing" // => stdlib: Go test framework
)
// TestDomainMustNotImportApp: domain must not import application zone
func TestDomainMustNotImportApp(t *testing.T) {
// go list -f {{.Imports}} ./purchasing/domain/...
// => lists all direct imports of every package under purchasing/domain
cmd := exec.Command("go", "list", "-f", "{{.Imports}}", "./purchasing/domain/...")
out, err := cmd.Output()
if err != nil {
t.Fatalf("go list failed: %v", err)
// => test infrastructure failure; investigate go module setup
}
if strings.Contains(string(out), "purchasing/app") {
// => violation: domain package imports app package; outward dependency
t.Error("domain must not import app; dependency rule violation detected")
// => CI build fails; developer sees the violation immediately
}
// => ok: domain only imports purchasing/domain sibling packages and stdlib
}
// TestAppMustNotImportAdapter: application must not import adapter zone
func TestAppMustNotImportAdapter(t *testing.T) {
cmd := exec.Command("go", "list", "-f", "{{.Imports}}", "./purchasing/app/...")
out, err := cmd.Output()
if err != nil {
t.Fatalf("go list failed: %v", err)
}
if strings.Contains(string(out), "purchasing/adapter") {
// => violation: app imports adapter; framework leaks into orchestration layer
t.Error("app must not import adapter; dependency rule violation detected")
// => CI build fails; violation caught before code review
}
// => ok: app only imports purchasing/domain and stdlib
}
// => both tests run in < 200ms; zero external deps; pure stdlibKey Takeaway: In Go, go list in a test function verifies import directions automatically; in Rust, Cargo workspace crate separation enforces the dependency rule at the build level.
Why It Matters: Making the dependency rule machine-verifiable turns an architectural convention into a CI gate. Any future commit that accidentally imports a chi route handler into the domain zone will fail the test suite before reaching code review. This is the same argument ArchUnit provides for Java, applied to Go's existing go list toolchain.
In-Memory Adapter (Examples 13–16)
Example 13: In-memory PurchaseOrderRepository — the test-seam pattern
The in-memory adapter is the first and most important secondary adapter to write. It uses a plain map as the backing store, starts in under a microsecond, has no external dependencies, and is the default adapter for all application service tests. It is a production-quality artifact — not a test utility — that lives in adapter/out/mem/.
// In-memory adapter: complete implementation of PurchaseOrderRepository
// => file: purchasing/adapter/out/mem/repo.go
package mem
import (
"sync" // => stdlib: RWMutex for concurrent-safe operations
"purchasing/app" // => import app package: the port interface lives here
"purchasing/domain" // => import domain package: entity and value object types
)
// InMemoryPurchaseOrderRepository: adapter implementing app.PurchaseOrderRepository
// => backing store: map[domain.PurchaseOrderID]domain.PurchaseOrder
// => exported struct: usable from any package that needs a test adapter
type InMemoryPurchaseOrderRepository struct {
mu sync.RWMutex // => protects concurrent map access
store map[domain.PurchaseOrderID]domain.PurchaseOrder // => backing store; nil until make()
}
// NewInMemoryPurchaseOrderRepository: returns an initialised adapter ready to use
func NewInMemoryPurchaseOrderRepository() *InMemoryPurchaseOrderRepository {
return &InMemoryPurchaseOrderRepository{
store: make(map[domain.PurchaseOrderID]domain.PurchaseOrder),
// => make(): allocates the underlying hash map; must be called before any write
}
}
// Save: store a PurchaseOrder; return the same instance plus nil error
func (r *InMemoryPurchaseOrderRepository) Save(po domain.PurchaseOrder) (domain.PurchaseOrder, error) {
r.mu.Lock() // => exclusive lock: prevents concurrent write + read races
defer r.mu.Unlock() // => deferred: always released even if a panic occurs
r.store[po.ID] = po // => key = PurchaseOrderID (typed); value = PurchaseOrder (struct copy)
// => Go map stores value copy: caller's po and stored po are independent after this line
return po, nil // => return the same instance; nil error = success
}
// FindByID: look up a PurchaseOrder by its typed ID
func (r *InMemoryPurchaseOrderRepository) FindByID(id domain.PurchaseOrderID) (domain.PurchaseOrder, bool) {
r.mu.RLock() // => shared read lock: multiple goroutines can read concurrently
defer r.mu.RUnlock() // => deferred release
po, ok := r.store[id] // => map lookup: ok = true if key found; ok = false if absent
return po, ok // => (zero-value PurchaseOrder, false) when not found — no nil
}
// ExistsByID: lightweight check without loading the full aggregate
func (r *InMemoryPurchaseOrderRepository) ExistsByID(id domain.PurchaseOrderID) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.store[id] // => blank identifier: discard value; only need the boolean
return ok // => O(1) hash lookup
}
// compile-time interface check: fails to compile if method signatures diverge from the port
var _ app.PurchaseOrderRepository = (*InMemoryPurchaseOrderRepository)(nil)
// => this single line ensures the struct stays compatible with the port interface
// => zero runtime cost: nil pointer cast; never executedKey Takeaway: The in-memory adapter implements the port with a plain map — no framework, no database, instantiates in microseconds.
Why It Matters: Because InMemoryPurchaseOrderRepository satisfies the same PurchaseOrderRepository interface as a Postgres adapter, every application service test runs without Docker. A suite of 200 service tests completes in under a second. The compile-time interface check (var _ app.PurchaseOrderRepository = ...) ensures the adapter never silently diverges from the port contract as the codebase evolves.
Example 14: Fixed clock adapter — deterministic time in tests
// FixedClock: in-memory adapter implementing app.Clock
// => file: purchasing/adapter/out/mem/clock.go
package mem
import (
"time" // => stdlib: time.Time type
"purchasing/app" // => import app package for the Clock interface
)
// FixedClock: deterministic clock adapter for tests
// => returns the same time on every call; no wall-clock dependency
type FixedClock struct {
T time.Time // => exported field: caller sets the desired test timestamp
// => example: mem.FixedClock{T: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
}
// Now: satisfies app.Clock interface; returns the fixed timestamp
func (c FixedClock) Now() time.Time {
return c.T // => always returns c.T; deterministic; no system clock call
// => test: two consecutive Now() calls return the same value — no flakiness
}
// SystemClock: production adapter returning real time
// => trivial implementation; included here as a direct contrast to FixedClock
type SystemClock struct{} // => zero-value struct: no fields needed
// Now: satisfies app.Clock interface; returns the current wall-clock time
func (c SystemClock) Now() time.Time {
return time.Now() // => stdlib: real wall-clock time; used only at the composition root
// => never used in unit tests; FixedClock replaces it at wiring time
}
// compile-time checks: both adapters must satisfy the Clock interface
var _ app.Clock = FixedClock{} // => fails to compile if FixedClock diverges from the interface
var _ app.Clock = SystemClock{} // => fails to compile if SystemClock diverges from the interface
// => zero runtime cost; enforced at every compileKey Takeaway: The FixedClock adapter makes every time-dependent test deterministic — same timestamp every run, no time.Sleep, no flakiness.
Why It Matters: Business rules that expire, timeout, or sequence on timestamps are notoriously difficult to test with real clocks. FixedClock eliminates that class of test flakiness entirely. Because both FixedClock and SystemClock satisfy the app.Clock interface, swapping between them at the composition root is one line of code — no test changes needed.
Example 15: Wiring a complete unit test with in-memory adapters
This example shows the full unit test pattern: wire the service with in-memory adapters, call the use case, assert the domain result — no HTTP server, no database, no framework bootstrap.
// Unit test: wire service with in-memory adapters; no infrastructure needed
// => file: purchasing/app/service_test.go
package app_test
import (
"testing" // => stdlib: Go test framework
"time" // => stdlib: time.Time for FixedClock
"purchasing/app" // => application package: service constructor and types
"purchasing/adapter/out/mem" // => in-memory adapters
"purchasing/domain" // => domain types for assertions
)
func TestIssuePurchaseOrderService_Execute_Success(t *testing.T) {
// Arrange: wire the service with in-memory adapters — no framework, no containers
repo := mem.NewInMemoryPurchaseOrderRepository()
// => repo: *mem.InMemoryPurchaseOrderRepository — satisfies app.PurchaseOrderRepository
clock := mem.FixedClock{T: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
// => clock: mem.FixedClock — satisfies app.Clock; always returns 2026-01-01T00:00:00Z
svc := app.NewIssuePurchaseOrderService(repo, clock)
// => svc: *app.IssuePurchaseOrderService — wired with in-memory adapters
cmd := app.IssuePOCommand{
SupplierID: "sup_660f9511-f3ac-52e5-b827-557766551111",
TotalAmount: 150000, // => $1500.00 in minor units (cents)
TotalCurrency: "USD", // => ISO 4217 code; service validates 3-letter rule
}
// Act: call the use case
po, err := svc.Execute(cmd)
// => Execute: creates PO, runs Submit() domain transition, saves via repo
if err != nil {
t.Fatalf("expected no error, got: %v", err) // => test fails with the error message
// => if this fires, check domain rule guards in domain.PurchaseOrder.Submit()
}
// Assert: verify domain result
if po.Status != domain.POStatusAwaitingApproval {
t.Errorf("expected AWAITING_APPROVAL, got %q", po.Status)
// => state transition must be DRAFT → AWAITING_APPROVAL via Submit()
}
if po.Total.Currency != "USD" {
t.Errorf("expected USD, got %q", po.Total.Currency)
// => Money.Currency must be preserved through the service call
}
// Assert: PO saved to in-memory repo
saved, ok := repo.FindByID(po.ID)
if !ok {
t.Error("expected PO to be persisted in the repository")
// => service must call repo.Save(); if not, the port was not called
}
if saved.ID != po.ID {
t.Errorf("saved PO ID %q does not match returned ID %q", saved.ID, po.ID)
}
// => full use-case test; zero infrastructure; runs in < 1ms
}Key Takeaway: Wiring a full use-case test requires two lines: construct the in-memory adapters, pass them to the service constructor. No framework, no container, no test annotations.
Why It Matters: This test verifies the entire use case — domain construction, state transition, persistence — without any infrastructure. It runs in under a millisecond. Teams following this pattern report that application service tests are indistinguishable in speed from pure unit tests, yet cover the full orchestration path including port interactions.
Example 16: Why no mocking framework is needed
The in-memory adapter is the substitute for a mocking framework. Because the port is a small interface, an in-memory implementation is shorter than a mock setup and is more readable, type-safe, and refactoring-friendly.
// Comparing in-memory adapter vs mock framework in Go
// => Go has no built-in mock framework; the community uses testify/mock or gomock
// => with hexagonal ports, neither is needed for the common test case
// APPROACH 1: mock framework (testify/mock) — more code, less readable
// type MockPurchaseOrderRepository struct {
// mock.Mock
// }
// func (m *MockPurchaseOrderRepository) Save(po domain.PurchaseOrder) (domain.PurchaseOrder, error) {
// args := m.Called(po)
// return args.Get(0).(domain.PurchaseOrder), args.Error(1)
// }
// func (m *MockPurchaseOrderRepository) FindByID(...) ... { ... } // 5+ more lines
// func (m *MockPurchaseOrderRepository) ExistsByID(...) ... { ... }
// => total: 15+ lines; requires testify import; type assertions; mock.Called boilerplate
// APPROACH 2: in-memory adapter (preferred) — less code, fully type-safe
// repo := mem.NewInMemoryPurchaseOrderRepository()
// => 1 line; no import beyond the mem package; fully type-checked; no assertion magic
// APPROACH 3: hand-rolled stub for a specific test — when only one method matters
// => type stubbedRepo struct{ saved domain.PurchaseOrder }
type stubbedRepo struct {
saved domain.PurchaseOrder // => captures what was saved for assertion
// => zero-value bool findOK defaults to false; adjust per test need
}
func (s *stubbedRepo) Save(po domain.PurchaseOrder) (domain.PurchaseOrder, error) {
s.saved = po // => capture the saved PO for assertion in the test body
return po, nil
// => minimal implementation: only the method under test does anything meaningful
}
func (s *stubbedRepo) FindByID(id domain.PurchaseOrderID) (domain.PurchaseOrder, bool) {
return domain.PurchaseOrder{}, false // => stub returns zero; test does not exercise this
// => for tests that only exercise Save, FindByID can return zero without consequence
}
func (s *stubbedRepo) ExistsByID(id domain.PurchaseOrderID) bool {
return false // => stub returns false; test does not exercise this method
// => three-method interface: all three must be present for structural satisfaction
}
// var _ app.PurchaseOrderRepository = (*stubbedRepo)(nil) — compile-time check
var _ app.PurchaseOrderRepository = (*stubbedRepo)(nil)
// => ensures stubbedRepo satisfies the interface; compile error if a method signature changesKey Takeaway: Small port interfaces make hand-rolled test doubles shorter and more readable than mock framework setups.
Why It Matters: Mock frameworks add compile-time complexity, generate hard-to-read stack traces, and sometimes have type-assertion failures that only surface at runtime. A three-method in-memory adapter or stub is fully type-checked, requires no magic, and is immediately readable to any Go or Rust developer. This is a direct benefit of Go's idiomatic small-interface design and hexagonal architecture's small-port requirement.
Composition Root (Examples 17–20)
Example 17: Composition root — main.go as the wiring point
The composition root is the single place in the application where concrete adapters are chosen and wired into the service constructors. In Go this is main.go (or a cmd/server/main.go). It is the only place allowed to import both the application package and adapter packages simultaneously.
// Composition root: main.go wires concrete adapters into the application service
// => file: cmd/server/main.go
package main
import (
"log" // => stdlib: structured log output for startup messages
"purchasing/app" // => application layer: service constructor + use-case interface
"purchasing/adapter/in/http" // => HTTP primary adapter: chi handler
"purchasing/adapter/out/mem" // => in-memory secondary adapter: repository + clock
)
func main() {
// Step 1: create secondary (output) adapters
repo := mem.NewInMemoryPurchaseOrderRepository()
// => repo: *mem.InMemoryPurchaseOrderRepository — satisfies app.PurchaseOrderRepository
clock := mem.SystemClock{} // => real wall-clock; switch to FixedClock for test builds
// => clock: mem.SystemClock — satisfies app.Clock; returns time.Now() in production
// Step 2: create the application service with injected adapters
svc := app.NewIssuePurchaseOrderService(repo, clock)
// => svc: *app.IssuePurchaseOrderService — all dependencies resolved; ready to handle commands
// => Go constructor: explicit, no reflection, no DI container required
// Step 3: create primary (input) adapter wired to the use-case interface
handler := http.NewPurchaseOrderHandler(svc)
// => handler: *http.PurchaseOrderHandler — depends on app.IssuePurchaseOrderUseCase interface
// => handler never imports the mem package; it only calls the interface
// Step 4: start the HTTP server
router := handler.Router() // => chi.Router: registers routes on the handler
log.Println("starting procurement server on :8080")
if err := http.ListenAndServe(":8080", router); err != nil {
log.Fatalf("server error: %v", err) // => fatal: log the error and exit with code 1
}
// => only main.go imports both app and adapter packages simultaneously
// => all other packages import at most one zone inward of themselves
}Key Takeaway: The composition root (main.go or main.rs) is the only file that imports both application and adapter packages simultaneously — all other files see at most one zone inward.
Why It Matters: Confining adapter selection to the composition root means no business logic file ever decides which database or HTTP framework is used. Swapping the in-memory repository for a Postgres one requires changing exactly one line in main.go — everything else compiles unchanged. Three Dots Labs' DDD + CQRS + Clean Architecture in Go identifies this "main as the wiring point" pattern as the key to keeping Go hexagonal applications testable at scale.
Example 18: Environment-based adapter selection
Production applications need to switch between adapters based on deployment environment. The composition root reads an environment variable or config and selects the concrete adapter — the application service never sees the choice.
// Environment-based adapter selection in the composition root
// => file: cmd/server/main.go (excerpt showing adapter selection logic)
package main
import (
"log" // => stdlib: log.Printf for startup messages
"os" // => stdlib: os.Getenv to read environment variables
"purchasing/app" // => application layer
"purchasing/adapter/out/mem" // => in-memory adapter
// "purchasing/adapter/out/pg" // => Postgres adapter (imported when needed)
)
func buildRepository() app.PurchaseOrderRepository {
// USE_IN_MEMORY_REPO: environment variable controlling adapter selection
// => "true" = in-memory (development, CI tests without Docker)
// => anything else (or unset) = Postgres (staging, production)
if os.Getenv("USE_IN_MEMORY_REPO") == "true" {
log.Println("using in-memory repository (no Postgres required)")
return mem.NewInMemoryPurchaseOrderRepository()
// => returns *mem.InMemoryPurchaseOrderRepository via the interface
// => application service receives app.PurchaseOrderRepository; never sees this type
}
// In a real app: read DSN from environment; open pg connection; return pg adapter
// => return pg.NewPurchaseOrderRepository(db)
// => for this beginner example, fall back to in-memory to keep it self-contained
log.Println("defaulting to in-memory repository for this example")
return mem.NewInMemoryPurchaseOrderRepository()
// => production: replace with pg adapter; service and tests are unchanged
}
func buildClock() app.Clock {
if os.Getenv("FIXED_CLOCK_TIME") != "" {
// => parse the fixed time and return a FixedClock for deterministic local testing
// => production CI: never set FIXED_CLOCK_TIME; SystemClock is always used in prod
log.Println("using fixed clock for deterministic local testing")
return mem.FixedClock{} // => simplified; real code parses the env var to time.Time
}
return mem.SystemClock{} // => production: real wall-clock time
// => application service receives app.Clock; never knows which adapter is running
}Key Takeaway: Adapter selection lives in the composition root, controlled by environment variables — the application service never contains an if production/test branch.
Why It Matters: A procurement service that selects adapters via environment variables can run in three modes — in-memory for local development, in-memory with a fixed clock for CI, and Postgres + system clock for production — without a single if statement in the domain or application layers. This maps directly to the twelve-factor app principle of config via environment.
Example 19: HTTP input adapter with chi routing
The HTTP primary adapter translates HTTP concepts into application commands. It is thin: deserialise the request, build the command, call the use-case interface, serialise the response, map errors to status codes. No business logic.
// HTTP primary adapter: chi router handler
// => file: purchasing/adapter/in/http/handler.go
package http
import (
"encoding/json" // => stdlib: JSON decode/encode for request and response bodies
"net/http" // => stdlib: http.ResponseWriter, *http.Request, http.StatusCreated
"github.com/go-chi/chi/v5" // => chi: lightweight HTTP router; adapter-layer import only
"purchasing/app" // => app package: IssuePurchaseOrderUseCase interface + command
"purchasing/domain" // => domain: error types for error mapping
)
// CreatePORequest: inbound DTO — adapter-layer struct; never enters the domain
// => json tags here, not in the domain struct; adapter concern
type CreatePORequest struct {
SupplierID string `json:"supplier_id"` // => raw string from JSON body
TotalAmount int64 `json:"total_amount"` // => minor units (cents) from JSON
TotalCurrency string `json:"total_currency"` // => ISO 4217 code from JSON
}
// CreatePOResponse: outbound DTO — adapter-layer struct; built from domain types
// => json tags here; domain struct never carries json tags
type CreatePOResponse struct {
ID string `json:"id"` // => po.ID: typed → raw string for JSON
SupplierID string `json:"supplier_id"` // => po.SupplierID: typed → raw string
Status string `json:"status"` // => po.Status: POStatus → string for JSON
}
// PurchaseOrderHandler: primary adapter containing the chi handler methods
type PurchaseOrderHandler struct {
useCase app.IssuePurchaseOrderUseCase // => interface; never the concrete service struct
// => depends on interface: handler tests only need a stub, not the full service
}
// NewPurchaseOrderHandler: constructor; injects use-case interface
func NewPurchaseOrderHandler(useCase app.IssuePurchaseOrderUseCase) *PurchaseOrderHandler {
return &PurchaseOrderHandler{useCase: useCase}
// => explicit injection: useCase is set here; adapter cannot create its own service
}
// Router: registers routes and returns a chi.Router for use in the composition root
func (h *PurchaseOrderHandler) Router() chi.Router {
r := chi.NewRouter() // => chi.NewRouter(): creates a new mux; adapter-layer only
r.Post("/api/v1/purchase-orders", h.create)
// => POST /api/v1/purchase-orders → h.create handler
return r
}
// create: handles POST /api/v1/purchase-orders
func (h *PurchaseOrderHandler) create(w http.ResponseWriter, r *http.Request) {
// Step 1: deserialise request body
var req CreatePORequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest) // => HTTP 400
return // => return early; do not call the use case with invalid input
}
// Step 2: translate HTTP DTO → application command (no business logic)
cmd := app.IssuePOCommand{
SupplierID: req.SupplierID, // => pass raw string; service validates
TotalAmount: req.TotalAmount, // => pass int64 minor units; service validates
TotalCurrency: req.TotalCurrency, // => pass 3-letter code; service validates
}
// Step 3: delegate to use case — all business logic lives in the service
po, err := h.useCase.Execute(cmd)
if err != nil {
// => map domain errors to appropriate HTTP status codes
if err == domain.ErrInvalidTransition {
http.Error(w, err.Error(), http.StatusUnprocessableEntity) // => HTTP 422
} else {
http.Error(w, "internal error", http.StatusInternalServerError) // => HTTP 500
}
return
}
// Step 4: translate domain result → HTTP response DTO
resp := CreatePOResponse{
ID: string(po.ID), // => typed PurchaseOrderID → raw string for JSON
SupplierID: string(po.SupplierID), // => typed SupplierID → raw string for JSON
Status: string(po.Status), // => POStatus string-type → string for JSON
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // => HTTP 201 Created
json.NewEncoder(w).Encode(resp) // => serialise response DTO; adapter concern only
}Key Takeaway: The HTTP adapter is thin — deserialise, build command, call use-case interface, serialise response, map errors to HTTP codes. All business logic lives in the service.
Why It Matters: A thin HTTP adapter means the application logic is portable. Adding a gRPC adapter, a Kafka consumer adapter, or a CLI adapter all call the same IssuePurchaseOrderUseCase interface. The HTTP-specific concerns (status codes, JSON tags, chi.Router) are confined to the adapter package and never influence business logic.
Example 20: Complete request/response flow — tracing a POST through all zones
This final example traces a single POST /api/v1/purchase-orders request through all three zones to show how data transforms at each boundary: HTTP DTO → application command → domain entity → repository save → HTTP response.
// Complete request/response flow through all three hexagonal zones
// => this is a narrative trace; not a runnable standalone file
// => each comment block corresponds to one zone crossing
// ─── ZONE 3: Adapter/in (HTTP) ─────────────────────────────────────────────
// Incoming: POST /api/v1/purchase-orders
// Body: {"supplier_id":"550e8400","total_amount":150000,"total_currency":"USD"}
// => http.Request arrives at PurchaseOrderHandler.create()
// json.NewDecoder(r.Body).Decode(&req)
// => CreatePORequest{SupplierID:"550e8400", TotalAmount:150000, TotalCurrency:"USD"}
// => raw HTTP payload; no domain types yet; adapter-layer struct only
// cmd := app.IssuePOCommand{SupplierID: req.SupplierID, ...}
// => boundary crossing 1: HTTP DTO → application command
// => crossing point: handler translates HTTP concerns into application language
// ─── ZONE 2: Application ───────────────────────────────────────────────────
// IssuePurchaseOrderService.Execute(cmd) receives the command
// => all business orchestration happens here; no HTTP types visible
// id, _ := domain.NewPurchaseOrderID("po_" + uuid.New().String())
// => PurchaseOrderID: typed; format "po_<uuid>" validated by factory function
// total, _ := domain.NewMoney(cmd.TotalAmount, cmd.TotalCurrency)
// => Money: typed; Amount + Currency; invariants validated at construction
// po := domain.PurchaseOrder{ID: id, SupplierID: supplierID, Total: total, Status: domain.POStatusDraft}
// => boundary crossing 2: command fields → typed domain value objects
// => domain entity constructed in DRAFT state; application service orchestrates
// ─── ZONE 1: Domain ────────────────────────────────────────────────────────
// submitted, _ := po.Submit()
// => pure function: validates po.Status == POStatusDraft; returns new PO with AWAITING_APPROVAL
// => no I/O; no network; sub-microsecond; only domain rules applied
// ─── ZONE 2: Application (back) ────────────────────────────────────────────
// saved, _ := s.repo.Save(submitted)
// => boundary crossing 3: application service calls output port
// => port interface: app.PurchaseOrderRepository.Save(); adapter chosen at composition root
// ─── ZONE 3: Adapter/out (persistence) ────────────────────────────────────
// InMemoryPurchaseOrderRepository.Save(submitted)
// => r.store[submitted.ID] = submitted — in-memory map write; no SQL; no network
// => returns submitted, nil — domain object back to application service
// ─── ZONE 2: Application (return) ─────────────────────────────────────────
// return saved, nil — domain PurchaseOrder returned to the HTTP handler
// ─── ZONE 3: Adapter/in (HTTP) — response ─────────────────────────────────
// resp := CreatePOResponse{ID: string(po.ID), ...}
// => boundary crossing 4: domain entity → HTTP response DTO
// => typed PurchaseOrderID → raw string for JSON; domain concern ends here
// json.NewEncoder(w).Encode(resp)
// w.WriteHeader(http.StatusCreated)
// Response: HTTP 201 {"id":"po_<uuid>","supplier_id":"550e8400","status":"AWAITING_APPROVAL"}
// => four boundary crossings; each crossing is a deliberate translation
// => domain was never touched by HTTP; adapter was never touched by domain rulesKey Takeaway: Every hexagonal request crosses four deliberate boundaries: HTTP DTO → command, command → domain entity, domain entity → port call, domain entity → HTTP response. Each crossing is an explicit translation.
Why It Matters: Making each boundary crossing explicit prevents the "bleeding through" anti-pattern where HTTP concerns (JSON field names, status codes) or database concerns (SQL types, column names) leak into the domain. The four-crossing model also means each zone can be tested in isolation: domain tests skip all three other crossings, application service tests skip the HTTP and DB crossings, and end-to-end tests exercise all four. Alistair Cockburn's Hexagonal Architecture (2005) names this explicit boundary-crossing structure as the core mechanism that makes the application core technology-independent.
Last updated May 23, 2026