Ports and adapters: hexagonal architecture
Open in the interactive app: practice, quiz, mock interview →In one minute
- Hexagonal architecture (ports and adapters) puts business logic at the centre and pushes every technology concern (HTTP, databases, queues, third-party APIs) to the edge.
- The core defines ports: interfaces describing what it needs (a
PaymentGateway) or what it offers (aPlaceOrderuse case). Adapters implement those ports for a specific technology. - Dependencies point inward only. The core never imports a framework, an ORM, or an SDK, so it can be tested in milliseconds and kept stable while technology changes around it.
- The cost is indirection: more interfaces and mapping code. It pays off for long-lived domains with real logic, not for thin CRUD services.
How it works
Inside and outside
Alistair Cockburn described the pattern in 2005, and he is explicit that the hexagon is not a hexagon because six matters: the shape just leaves room to draw ports, and most applications have two to four (Cockburn). Inside sit domain entities and application services (use cases). Outside sit the things that change for technical reasons: web frameworks, message brokers, database drivers, cloud SDKs. The rule is the dependency direction: outside code depends on inside code, never the reverse.
Driving and driven ports
A driving (primary) port is how the world asks the application to do something, for example PlaceOrder.execute(command). A REST controller, a CLI, a Kafka consumer, and a test are all driving adapters that translate their input into the same call. That symmetry was the original goal: an application that can be driven equally by users, other programs, automated tests or batch scripts (Cockburn). A driven (secondary) port is what the application needs from the world, for example OrderRepository.save(order) or Payments.charge(amount, token). A Postgres repository and an in-memory fake are both driven adapters for the same port.
Wiring
Something must construct the adapters and inject them into the core. That is the composition root, usually main or a dependency-injection container. It is the only place that knows which concrete adapters are in use. Swapping Stripe for Adyen, or Postgres for DynamoDB, changes the composition root and one adapter, not the use cases.
Relationship to other styles
Onion Architecture (Palermo, 2008) and Clean Architecture (Martin, 2012) express the same idea with more prescribed rings, and Martin's "Dependency Rule" (source code dependencies point only inward) is the shared core. All three are dependency inversion at the architecture level. Hexagonal is the lightest: it insists on the inside/outside boundary and explicit ports, and says nothing about how the inside is organised. A layered domain inside the hexagon is allowed, not required.
When a driven port faces a legacy system or a partner with a messy model, the adapter is also where translation lives, so the outside model never leaks in. Microsoft calls that adapter an anti-corruption layer (Azure Architecture Center).
Testing benefits
Because the core depends only on port interfaces, use-case tests run with in-memory adapters and no containers. A suite of 2,000 such tests can finish in a few seconds. Adapter tests then run separately against real infrastructure, checking only translation and integration, which keeps slow tests few and focused.
Down the stack
Layer 1The contract
A port is a contract the core states in its own terms: method signatures, domain types in and out, the errors it can raise, and semantic guarantees that adapters must honour. For a repository port that includes "save is atomic for one aggregate" and "load after save returns the saved state". For a payments port it includes "charge with the same idempotency key at most once". If a guarantee is not written into the port, the core cannot rely on it, and a fake adapter will not enforce it.
Layer 2The mechanism
The mechanism is dependency inversion enforced by the build. In Java, the core is a module with no dependency on Spring, JPA, or SDK artifacts; adapters live in modules that depend on the core and implement its interfaces, and ArchUnit tests fail the build if a class in ..domain.. imports org.springframework... ArchUnit ships a ready-made onionArchitecture() rule (its guide notes it covers hexagonal too) that takes the domain, application and adapter packages and checks every dependency between them (ArchUnit user guide). In Go, the core package declares small interfaces, adapters satisfy them implicitly, and the import graph (checked with go list -deps or a linter) must never point from core to adapter. The composition root, usually main, constructs a PostgresOrderRepository with a *sql.DB and passes it to NewPlaceOrder(repo, payments, clock). A unit-of-work port wraps a database transaction so a use case can save two aggregates atomically without importing the driver.
Layer 3The machine
The runtime cost of the indirection is small. An interface call is an indirect jump through a vtable or Go itab, about 1 to 2 ns, and often devirtualised: the JVM's JIT does it when one implementation dominates, and since Go 1.21 profile-guided builds add a direct call to the most common callee so it can be inlined (Go 1.21 release notes). Mapping between domain objects and persistence rows allocates: copying a 1 KB object per request at 10,000 requests per second is 10 MB per second of extra garbage, noticeable in GC profiles but rarely in latency. The real physics is in testing. A call to an in-memory adapter takes around 100 ns; a round trip to Postgres takes 0.3 to 1 ms; starting a database container for a test run takes 5 to 10 seconds. A suite of 2,000 use-case tests is milliseconds of CPU against fakes and minutes against real I/O.
Why it's built this way
- Business rules and technologies change at different rates and for different reasons, so they are separated.
- Tests need fast, deterministic feedback, and I/O is slow and nondeterministic, so I/O is pushed behind ports.
- Swapping a dependency should not ripple through rules, so the core owns the interfaces and adapters depend on it.
- An indirect call costs nanoseconds while I/O costs milliseconds, so the extra layer is affordable.
Diagram
flowchart LR
REST["REST controller"] --> IN["Driving port: PlaceOrder"]
CLI["CLI command"] --> IN
MQ["Queue consumer"] --> IN
IN --> CORE["Domain + use cases"]
CORE --> R["Driven port: OrderRepository"]
CORE --> P["Driven port: Payments"]
R --> PG["Postgres adapter"]
R --> MEM["In-memory adapter (tests)"]
P --> STRIPE["Payment provider adapter"]
P --> FAKE["Fake payments (tests)"]Worked example
A subscription billing service has grown for six years. Business rules (proration, trials, dunning) are tangled with ORM calls and payment SDK objects. The test suite needs a database container and takes 25 minutes, so engineers skip it locally.
The team introduces ports incrementally. First, a BillingAccounts driven port with load(id) and save(account), plus a PaymentProvider port with charge(account_id, cents, idempotency_key). The proration logic moves into a pure Subscription entity that takes dates and prices and returns invoice lines. The existing ORM code becomes the Postgres adapter.
class ChangePlan:
def __init__(self, accounts, payments, clock):
self.accounts, self.payments, self.clock = accounts, payments, clock
def execute(self, account_id, new_plan):
acct = self.accounts.load(account_id)
lines = acct.subscription.change_plan(new_plan, self.clock.today())
due = sum(l.cents for l in lines)
if due > 0:
self.payments.charge(account_id, due, key="chg-%s-%s" % (account_id, new_plan.id))
self.accounts.save(acct)
return linesAfter three months, 1,800 use-case tests run in 6 seconds with in-memory adapters, and 120 adapter tests run against real Postgres and the provider's sandbox in 4 minutes. When the company adds a second payment provider for one region, only a new adapter and a routing rule in the composition root change.
In the wild
- Netflix, 2020: swapping a data source in two hours. Netflix's Studio Workflows team built a new app on ports and adapters because much of its data still lived in a monolith that was being split into services. When they hit a read limit on the monolith, they moved reads for one entity from a JSON API to a GraphQL aggregation layer in about 2 hours, changing only the adapter, because both sources held the same data and business logic depended only on the port (Netflix TechBlog).
- Shopify, 2024: static boundary checks are not enough. Shopify built Packwerk in 2020 to enforce dependency and privacy rules between packages of its Rails monolith. Four years on, the team reported that boundaries drawn by naming often sat in the wrong place, that a package with zero Packwerk violations could still fail at runtime because the tool sees only autoloaded constants, and that privacy checks were removed in version 3.0. Their lesson: measure modularity by code that actually runs, not by tool output (Shopify Engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Layered (controller, service, DAO) | Familiar, little ceremony | Domain often leaks into DAOs and frameworks | Simple apps with modest logic |
| Hexagonal | Framework-free core, fast tests, swappable infrastructure | More interfaces and mapping code | Long-lived services with real business rules |
| Clean or Onion | Strong, explicit rings and rules | Heavier structure, risk of ceremony | Large teams that need enforced conventions |
| Transaction script or CRUD | Very fast to build | Logic scatters as rules grow | Thin data services, prototypes |
Pitfalls in production
- Leaky ports. A repository port returns ORM entities or a payment port takes SDK objects, so the core depends on the technology anyway. Ports must speak only domain types.
- Interface for everything. Wrapping every class in an interface adds noise without benefit. Define ports only at real boundaries with infrastructure or other systems.
- Anaemic core. All logic sits in adapters and the core just forwards calls. Move decisions (validation, calculations, state transitions) into entities and use cases.
- Transaction boundaries lost. Each repository call commits separately, breaking atomicity. Add a unit-of-work port owned by the use case.
- Fakes that drift. In-memory adapters behave differently from real ones (no uniqueness constraints, no ordering). Run a shared contract test suite against both, on the rhythm of the real dependency's changes rather than only your own commits (Fowler).
Leadership lens
- Decide where the pattern earns its keep. Apply it to domains with complex, long-lived rules and accept simpler structure for thin services. AWS's own guidance says the adapter code is justified only when a component has several inputs or outputs, or its stores are expected to change; otherwise it is one more layer to maintain (AWS Prescriptive Guidance).
- In code review, check imports: anything framework- or vendor-specific inside the core is a boundary violation. Enforce it with an architecture test or module rules.
- Ask what the port contracts are and who owns them. Ports are the stable API of your domain.
- Measure the payoff in test speed and in how cheaply infrastructure can change.
Check yourself
What is the difference between a driving and a driven port?
Show answer
A driving port is an entry point the outside world calls to use the application; a driven port is an interface the application calls to reach infrastructure or other systems.
Why must ports use domain types rather than ORM or SDK types?
Show answer
Otherwise the core imports and depends on that technology, which defeats the isolation and makes the core untestable without it.
Where do concrete adapters get chosen and wired in?
Show answer
In the composition root, typically
mainor a DI container, which is the only code that knows both the core and the concrete adapters.
Sources and further study
Primary
- Jeffrey Palermo, The Onion Architecture, part 1 (2008): the ring-based variant and its shared premise with ports and adapters.
- Robert C. Martin, The Clean Architecture (2012): the Dependency Rule that unifies hexagonal, onion and clean.
- Martin Fowler, ContractTest: how to keep test doubles honest against the real service.
Systems docs
- ArchUnit user guide: architectures: executable layered and onion (ports and adapters) dependency rules for Java.
- Go: profile-guided optimization: how PGO builds inline and devirtualise hot interface calls.
Books
- Hexagonal Architecture Explained, Alistair Cockburn and Juan Manuel Garrido de Paz (2024): the pattern's author on its definition, code samples and common misreadings.
- Clean Architecture (1st ed.), Robert C. Martin, Ch 22 "The Clean Architecture" and Ch 34 "The Missing Chapter" (which compares package by layer, by feature and ports and adapters).
- Domain-Driven Design (1st ed.), Eric Evans, Ch 4 "Isolating the Domain": why domain logic must be separated from UI and infrastructure.
Other curricula
- AWS Prescriptive Guidance: Hexagonal architecture pattern: frames it as a way to keep Lambda business logic testable without DynamoDB, and warns about maintenance overhead and an extra layer of latency.
Watch
- Hexagonal Architecture, Alistair Cockburn (Tech Excellence): the pattern explained by its author.
- The Hexagonal: Ports and Adapters Architecture, Alistair Cockburn (iSAQB): a more recent talk on intent and common misreadings.
Free resources
- Hexagonal Architecture, Alistair Cockburn: the original description of ports and adapters.
- Wikipedia: Hexagonal architecture: background and relation to onion and clean architecture.
- Bounded Context (martinfowler.com): how domain boundaries shape what sits inside the hexagon.
- Anti-corruption Layer (Azure Architecture Center): a close relative for isolating a domain from external models.