Overview
Hexagonal architecture (Alistair Cockburn, a.k.a. ports & adapters) structures an application so the domain logic sits at the center, defining ports (interfaces) for everything it needs from or offers to the outside world; adapters (HTTP controllers, database gateways, message consumers…) plug into those ports. Dependencies always point inward, so the domain knows nothing about frameworks, databases, or transport — making it testable in isolation and resilient to technology churn.
Key points
- Driving vs. driven sides: primary (driving) adapters call the application (REST controller, CLI, scheduler); secondary (driven) adapters are called by it through ports (persistence, messaging, external APIs).
- The dependency rule: domain depends on nothing; adapters depend on the domain via its ports — dependency inversion applied at architecture scale.
- Test strategy falls out: domain tested with plain unit tests, ports contract-tested, adapters integration-tested; end-to-end kept thin.
- One hexagon is not an architecture — a system has several bounded contexts, and
the house answer is the modulith: one hexagon per context under
modules/, composed into runnable assemblies underapplication/. The full layout and its dependency rules are in the Details. - Siblings: onion architecture and Clean Architecture (Martin) are the same idea with different layering vocabulary; don't get lost in the taxonomy.
- Cost: indirection and mapping (DTOs ↔ domain objects) — overkill for CRUD-only services, pays off when the domain has real logic.
- To explore: where transaction boundaries live, mapping strategies at the ports, hexagonal + DDD tactical patterns as the interior.
The shape, with every dependency arrow pointing inward:
flowchart LR
subgraph primary["Driving side (primary adapters)"]
REST["REST controller"]
CLI["CLI"]
SCHED["Scheduler"]
end
subgraph hexagon["The hexagon"]
DRIVING["Driving ports"]
DOMAIN["Domain logic"]
DRIVEN["Driven ports"]
end
subgraph secondary["Driven side (secondary adapters)"]
PERSIST["Persistence"]
MSG["Messaging"]
EXT["External APIs"]
end
REST -->|"calls"| DRIVING
CLI -->|"calls"| DRIVING
SCHED -->|"calls"| DRIVING
DRIVING --- DOMAIN
DOMAIN --- DRIVEN
PERSIST -->|"implements"| DRIVEN
MSG -->|"implements"| DRIVEN
EXT -->|"implements"| DRIVEN
Details
The house reference principles
Four reference implementations —
Java,
Go,
Rust,
Frontend (web components) — realize
one shared set of principles, each with its own enforcement mechanism (Maven module
graph, nested internal/ + linter, Cargo crate graph, npm workspace + TS project
references). Each implementation stands on its own; the principles live here:
- One hexagon per bounded context, several hexagons per repository. The favored
layout is a modulith:
modules/<context>/holds a self-contained hexagon (user-side/,domain/,infra/),platform/holds what every context shares, andapplication/holds the assemblies that compose modules into runnable artifacts. The flat trisection (application/+domain/+infrastructure/at the root) is the degenerate one-context case of the same rules — see the favored layout below. - One deployment unit per delivery typology, not per adapter: an
application/assembly (api,consumer,cron, …) is the runnable, containerised artifact, and it hosts the matching user-side adapters of every module it assembles. Its containerisation definition (Dockerfile and the likes) lives beside it, never centralised. - A module's driving adapters are not runnable.
user-side/carries HTTP resources, message consumers and the in-process service, each as a library an assembly mounts. Deciding what runs together is the assembly's job alone. - Modules compose through the in-process service adapter, never through each
other's domains. A module that needs a peer declares a driven port in its own
vocabulary; its
infra/implements that port by delegating to the peer'suser-side/service. This is the seam that makes carve-out cheap — see modulith & microservices. - The domain has a contract face and an implementation face — ports in both directions, command DTOs and events on the contract side; entities and services on the implementation side. Everything depends on the contract; almost nothing on the implementation.
- Business logic enters the hexagon as command/query data through one explicit
dispatch seam — driving adapters construct commands and never import concrete
handlers. The seam is per module, not per repository. The mechanism behind it
is per-language, settled 2026-08-09:
Java routes through a registry Mediator (handlers self-declare via
supports()); Rust keeps per-use-case driving-port traits and, where a unified seam is justified, makes the compiler the registry (commands as an enum, one exhaustivematch); Go and the frontend skip the mediator object — per-use-case driving ports, explicitly wired inmain(Go) or delivered by typed context keys (frontend) — with cross-cutting concerns as decorators around the seam. Each reference records its stance and the rejected alternatives. - An adapter's API artifact exists only where the contract is real and consumable
(REST DTOs/OpenAPI, protos, message schemas) — the
contract/adapterspair underuser-side/api/is earned, never imposed for symmetry; a cron adapter is a single module. - Infrastructure granularity is decided ad-hoc per context — a module's
infra/is a single module or one per (port × technology); the forcing question is which assemblies need which driven adapters. Ubiquitous ports (clock, id generation) are not a module's business at all: they sit inplatform/. An assembly's dependency list reads like its ops runbook. - One assembly point per deployment unit wires domains and infrastructure — the
application/<typology>module in Java, each binary'smainin Go and Rust. Dependency injection is a pattern (explicit construction), never a reflection container. - No contract testing between infrastructure and the domain contract: adapters are integration-tested against their real technology on their own terms; the domain owns its in-memory fakes.
- Duplication between deployment units is accepted over cross-unit coupling — by design there is scarcely any overlap between assemblies.
- Every directory/module carries a
README.mdand anAGENTS.mdstating its purpose and local conventions.
The favored layout — a modulith of hexagons
The trisection is what a single hexagon looks like. Real systems hold several bounded contexts, and the layout that scales is one that repeats the trisection per context instead of merging every context into one domain module:
acme/
├── application/ # assemblies: one per deployment typology
│ ├── api/ # HTTP app — mounts every module's api adapters
│ ├── consumer/ # messaging app
│ └── cron/ # scheduler app
├── platform/ # shared, domain-agnostic modules
│ ├── kernel/ # Command/Query, Handler, the dispatch seam
│ └── … # ubiquitous ports (clock, ids), test kit
├── modules/ # one directory per bounded context
│ ├── ordering/
│ │ ├── user-side/ # driving adapters — libraries, never runnable
│ │ │ ├── api/
│ │ │ │ ├── contract/ # published interfaces + DTOs
│ │ │ │ └── adapters/ # their implementation
│ │ │ ├── consumers/ # message consumers
│ │ │ └── service/ # in-process adapter for peer modules
│ │ ├── domain/
│ │ │ ├── contract/ # driving + driven ports, public DTOs
│ │ │ └── core/ # the business logic
│ │ └── infra/ # driven-port adapters
│ └── billing/ # same shape, independently
└── deployment/ # IaC — OpenTofu stacks per environment
The dependency rules are the trisection's, restated per module (an entry not listed is forbidden):
| Module | May depend on |
|---|---|
platform/kernel |
nothing |
modules/x/domain/contract |
platform/kernel |
modules/x/domain/core |
its own domain/contract |
modules/x/infra |
its own domain/contract, peer modules' user-side/service, its technology |
modules/x/user-side/api/contract |
nothing — transport types only |
modules/x/user-side/api/adapters |
its own user-side/api/contract + domain/contract |
modules/x/user-side/service |
its own domain/contract |
application/<typology> |
anything it assembles — the only place allowed to see a domain/core |
Two consequences carry the layout's value:
- No module ever depends on another module's domain. The single legal
inter-module edge is
infra → peer user-side/service, and it runs through a port the consumer declared in its own language. Contexts stay independently understandable, and a bad dependency is a build failure rather than a review comment. - The composition is decided in
application/, at assembly time. Which modules run in one process, which get their own, and whether a peer call is a method call or a network hop are all assembly concerns. That is precisely what makes the modulith → microservices move mechanical rather than architectural.
The whole hexagon shape is preserved inside each module, so nothing above replaces the trisection — it repeats it:
flowchart LR
subgraph asm["application/api (the deployment unit)"]
ASSEMBLY["assembly + wiring"]
end
subgraph ordering["modules/ordering"]
OUS["user-side/api/adapters"]
OSVC["user-side/service"]
ODC["domain/contract (ports)"]
OCORE["domain/core"]
OINFRA["infra"]
end
subgraph billing["modules/billing"]
BDC["domain/contract<br/>(declares an OrderLookup driven port)"]
BINFRA["infra/ordering-gateway"]
end
ASSEMBLY -->|"mounts"| OUS
ASSEMBLY -->|"wires"| OCORE
OUS --> ODC
OSVC --> ODC
OCORE --> ODC
OINFRA --> ODC
BINFRA -->|"implements"| BDC
BINFRA -->|"calls in-process"| OSVC
When to stay flat. A service with one genuine bounded context gains nothing from
modules/<the-only-one>/ but a level of nesting. Start flat, and adopt the modulith
the moment a second context appears — the flat trisection maps onto
modules/<context>/ one-to-one, so the promotion is a directory move plus a build
file, not a redesign.
Practice
- Trip Service Kata (source) — warm-up on seams: get code with hard-wired dependencies under test without touching the untouchable parts.
- Racing Car katas (source) — dependency-breaking drills; each kata is a small dependency-inversion refactor.
- Birthday Greetings — hexagonal extraction (exercise) — extract a pure domain from a coupled script; ports and adapters earned by refactoring, then proven by swapping adapters.
- Build Your Own Redis, hexagonally (source) — greenfield practice: keep the wire protocol and storage behind ports and watch the core stay framework-free.
- RealWorld full-stack build (source) — the architecture at product scale: a whole API as a domain core with HTTP and Postgres at the ports, judged by an external test suite.
Related
- Reference implementations — the house principles above realized per language, each standing on its own: Java (a concept group with per-layer guideline notes), Go, Rust, and Frontend — web components.
- The four hexagonal references compared — the four above read side by side: what holds each wall (compiler, resolver, linter or review), what each realization costs, and which stacks should offer both layouts.
- Modulith & microservices — the layout above read as a position on the decomposition spectrum, and the mechanics of moving along it.
- DDD — supplies the domain model the hexagon protects, and the bounded
contexts
modules/is carved along. - Clean code — dependency inversion is SOLID's "D".
- TDD — ports make outside-in test-first design practical.
- Microservice architecture — a common internal structure for each service.
- Coupling & cohesion — the forces ports & adapters manage: narrow, inward-directed coupling around a cohesive domain.
- Observability & SRE practice — where telemetry attaches to the hexagon: filters/middleware in the driving adapters, wiring at the assembly point, at most a logging facade in the domain.