Hexagonal Architecture (Ports and Adapters)
An application core that declares the interfaces it needs, and adapters on the outside that satisfy them. An ordinary dependency-direction choice, worth its cost exactly where the outside varies.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
When does inverting a dependency into a port-and-adapter pair actually pay for the interface it costs?
A logistics product integrates one carrier for shipping labels. Sales have just signed a customer who insists on their own carrier, and a third is likely within the year.
Add an if-statement. Where we call the carrier, branch on which one the customer uses. It is eleven small edits and it works this afternoon.
It works, and for two carriers it is genuinely defensible. It breaks on the third, when the eleven branches become eleven three-way branches that have to agree, and nothing checks that they do (Shotgun Surgery).
- It works, and for two carriers it is genuinely defensible. It breaks on the third, when the eleven branches become eleven three-way branches that have to agree, and nothing checks that they do (Shotgun Surgery).
- It breaks harder on the asynchronous carrier, because the branch is not just "which SDK" but "does this return now or later". A shape difference cannot be hidden behind a conditional at the call site; it has to be absorbed somewhere (Temporal Coupling).
- The eleven sites also each contain the first carrier's error vocabulary — its status strings, its retry semantics — so the core has quietly taken a dependency on one vendor's idea of what can go wrong (An Error Taxonomy That Survives Contact).
- And the branch spreads: once one conditional exists, the next carrier-specific concern is added next to it rather than pushed outward, because that is where the existing code is.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The first carrier's SDK is already imported in eleven places, including two background jobs and an admin screen.
- The second carrier has a genuinely different model — it batches labels and returns them asynchronously, where the first returns one synchronously.
- There is no appetite for a rewrite; the second carrier has to work in six weeks alongside normal delivery.
- A shipment always ends up with exactly one label, whatever carrier produced it, and the label is never charged twice (Idempotency by Design).
- The core rules about what may be shipped where must be identical across carriers — a carrier must not be able to introduce a policy exception by accident (Enforcing Invariants).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The application core owns what a shipment is, when it may be labelled, and what happens after a label exists. It owns no carrier vocabulary at all.
- The port — an interface the core declares — owns the *shape* of the collaboration: what the core needs, expressed in the core's own words (Designing a Module Interface).
- Each adapter owns one carrier: its SDK, its authentication, its error mapping, its retry rules, and the translation into and out of the core's types (Boundary Adapters).
- The composition root owns which adapter is used for which customer, and it is the only place that knows both sides (Wiring and the Composition Root).
- The seam is at the port, and the test of whether it is in the right place is whether the interface can be described without naming a vendor. "createLabel(shipment): LabelRequested" passes; "callCarrierApi(payload)" does not.
- The asynchronous carrier decides the port's shape. A port designed around the synchronous carrier and then stretched to fit the asynchronous one leaks the first vendor's model into every implementation — so the port has to be designed for the harder case (Choosing the Model).
- Ports are not free and not uniform. One port at the carrier seam is a design; a port in front of every collaborator is a habit, and it is the failure mode this style is best known for (Speculative Generality).
Core, port, adapter — and which of the three is load-bearing
The picture is simple: an application core in the middle, interfaces on its edge that it declares, and adapters outside that implement them. Requests come in through adapters too, so the same shape covers HTTP handlers and database clients — "driving" adapters call the core, "driven" adapters are called by it.
The load-bearing part is not the interface. It is the translation inside the adapter: the code that turns a carrier's response object into the core's own type and a carrier's error strings into the core's small set of failure kinds. An interface with no translation behind it has inverted a compile-time arrow and left the vendor's model exactly where it was (Boundary Adapters).
- The port is named for what the core needs, never for what the vendor offers.
- The fake is an adapter, held to the same contract test suite as the real ones (Contract Tests).
- The asynchronous carrier decides the interface shape, because a port that assumes an immediate answer cannot absorb one that does not.
- Driving and driven adapters are the same idea pointed in opposite directions; only the driven ones need the inversion.
A port only earns its keep when the outside actually varies
The failure of this style in practice is not that people do not use it. It is that they use it uniformly — an interface in front of every collaborator, including the ones with exactly one implementation that will never have a second. That produces a codebase where every call is indirect and nothing is variable, which is the worst of both.
The discriminating question is not "is this external?" but "have I seen this vary, or am I contractually committed to it varying?" A carrier: yes, twice over. A JSON serialiser: no. The interface below is worth writing; an interface over the serialiser is not (What an Abstraction Costs).
// "We put the carrier behind an interface."
interface CarrierClient {
postShipment(payload: AcmeShipmentPayload): Promise<AcmeLabelResponse>
getStatus(trackingRef: string): Promise<AcmeStatus>
}
// The core now imports AcmeShipmentPayload and switches on
// AcmeStatus. The arrow was inverted; the model was not.
// The async carrier cannot implement postShipment at all.// The core says what it needs, in its own vocabulary,
// and allows for an answer that arrives later.
interface LabelPort {
requestLabel(s: Shipment): Promise<LabelRequest>
}
type LabelRequest =
| { status: 'ready'; label: Label }
| { status: 'pending'; ref: LabelRef }
type LabelFailure =
| 'rejected-address' | 'carrier-unavailable' | 'not-permitted'The second version can be implemented by both carriers, because it was designed around the harder of the two models rather than the one that happened to be first. It also fixes the error vocabulary at three cases the core knows how to act on, so adding a carrier cannot add a new failure mode to the core's branching. The first version has to change every time a vendor does, which is precisely what the port was supposed to prevent (Stable Boundaries).
What it costs, scored honestly
The style is usually presented with only its benefits, so here is the arithmetic with the costs in it. The comparison is against the two realistic alternatives: calling the vendor directly, and branching at each call site.
Note the migration column. A port added before the second implementation exists is a bet; a port extracted once two implementations exist is evidence-driven and mechanical. That difference matters more than any other row here (The Rule of Three).
| Option | Simplicity | Flexibility | Testability | Migration cost | Operational | Note |
|---|---|---|---|---|---|---|
| Call the vendor directly | Cheapest to read and to write, and correct while there is one carrier. Testing needs the network or a mock of the SDK. Adding a second carrier means touching all eleven sites at once, under whatever deadline arrived with the customer. | |||||
| Branch at each call site | The honest two-carrier answer for a small codebase, and genuinely fine at two call sites. At eleven it becomes eleven places that must agree about carrier semantics, with nothing checking that they do. | |||||
| Port and adapters | One interface, one translation layer per carrier, one contract suite. The third carrier costs one file. The core becomes trivially testable, which is often worth more than the swap itself. |
caveat These scores assume the second carrier is real. Score the port option against a world where it never arrives and simplicity is the only column that matters — it drops to the worst option outright. The numbers also cannot express the shape mismatch that decides most real integrations: a synchronous and an asynchronous carrier are not two implementations of one thing until someone designs a port that admits both, and that design work is not captured by any column here.
How to build it
Most important first.
- Design the port from the core's needs, in the core's vocabulary. If the interface is a transcription of the vendor's API with the vendor's name removed, nothing has been inverted (Leaky Abstractions).
- Design it for the widest variation you have actually seen — here, asynchronous label production — so it is a request that may complete later rather than a function that returns a label (Design for the Known, Name What You Assumed).
- Own the error vocabulary at the port. The core should see a small closed set of failures it can act on, not the union of every vendor's status codes (An Error Taxonomy That Survives Contact).
- Put one adapter per external system, and keep translation entirely inside it: no vendor type crosses the port in either direction (Boundary Adapters).
- Introduce the port only at seams where variation is observed or contractually certain. Everywhere else, call the thing directly; the port can be added when the second case appears and the shared shape is evidence rather than a guess (The Rule of Three).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding the third carrier costs one new adapter, one contract test run against the port's test suite, and one wiring entry. Nothing in the core is opened, so the regression surface is the adapter and the wiring.
- Adding a business rule — say, no labels for hazardous goods to certain countries — costs one edit in the core and one test, and it applies to every carrier at once. That is the second benefit and it is usually undersold relative to the swap story.
- What got more expensive: a change that is genuinely carrier-shaped, such as exposing one carrier's delivery-window feature, now has to be either pushed into the port for everyone or leaked around it. The port makes the common shape cheap and the exceptional feature awkward, and every multi-vendor integration eventually meets that.
- An interface and a set of translation functions per adapter, forever, in exchange for variation that may or may not arrive. When it does not, this is the clearest example of paid-for flexibility in the domain.
- Debugging gets harder: a stack trace now passes through an interface, and finding which implementation ran requires reading the wiring rather than the call site (Local Reasoning).
- The port becomes a lowest common denominator. Anything one vendor does well and the others do not is either excluded or leaks.
What can go wrong
- The port is one interface with one implementation, added because the style says so, and it will never have a second. Pure indirection with no variation bought (Premature Abstraction).
- The port has methods that only one adapter can implement, so the others throw. The interface is a union of vendors rather than an abstraction over them, and the compiler no longer tells you anything (Interface Segregation, Critically).
- A vendor type crosses the boundary — a carrier's tracking object stored on the shipment — and the inversion is now decorative (Invariant Leaks).
- The mitigation fails in its own way: adapter tests use mocks of the vendor SDK, so they verify your belief about the vendor rather than the vendor, and the first real integration failure is in production (Mocking).
- The core depends on the port, which it owns. Adapters depend on the port and on their vendor SDK. Nothing points from core to adapter, which is the entire content of the style (Dependency Inversion).
- The composition root depends on everything, deliberately, and is the only file that does (Constructor Injection).
- A hidden dependency to watch: the core often ends up depending on the port's *timing* — that a call returns promptly — which is not expressed in the interface and breaks the moment an adapter goes over a network (What Changes at the Network Boundary).
- "Every external dependency should be behind a port." No. A port pays where the outside varies. A logging call, a UUID generator or a well-standardised library is not variation and wrapping it is pure cost (Dependency Inversion, Critically).
- "Hexagonal is the correct architecture." It is one arrangement of one rule — decisions do not import details — and it is worth its ceremony in proportion to observed variation. Software Architecture teaches it as a style; here it is a dependency-direction choice you make at specific seams (Design, Architecture and System Design).
- "The hexagon shape means something." It does not. It was chosen to avoid implying a top and a bottom, and it carries no information about how many ports there should be.
- "Ports mean we can swap the database." Technically yes; in practice nobody does, and the port over a database usually degrades into the ORM's own interface with different names (When the Repository Is Just Indirection in Backend covers the mechanism).
- shotgun-surgery
- primitive-obsession
Testing it, and how it ages
- Write one contract test suite against the port and run it against every adapter, including the fake used in core tests. That is what stops the fake from drifting into a more forgiving version of reality (Contract Tests).
- Test the core against an in-memory adapter with no network. If the core cannot be exercised without one, the port is not actually a boundary (What a Unit Is).
- Test each adapter against the real vendor sandbox on a schedule, because vendor behaviour changes without your deploy (Where a Test Must Be Real).
- The port's shape is set by the most awkward adapter you have, so it changes each time an adapter with a new model arrives — the asynchronous carrier now, a carrier that requires pre-registration next.
- After three or four adapters the port stabilises, and that is the point at which the style has paid for itself. Before the second adapter, it has not.
- It stops fitting when the vendors stop being interchangeable — when customers choose a carrier *for* its distinctive features. At that point one port over many carriers is the wrong model and the honest structure is separate capabilities, not a common interface (Choosing the Model).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- CONTESTEDThe strongest case against ports: an interface with one implementation is a cost with no benefit, and teams reliably fail to predict which dependencies will actually vary, so the disciplined alternative is to call the vendor directly and extract the port when the second case appears — a two-day refactor with a compiler to guide it. That argument is right whenever extraction later is cheap. It is wrong when the dependency is already at eleven call sites, or when the second implementation is contractually certain, which is exactly the situation in this lesson.
- LANGUAGE-SPECIFICIn a language with structural typing or duck typing, a port need not be a declared interface at all — the fake satisfies the same shape and the boundary is real without ceremony. In a nominally-typed language the interface is a file that must be written and kept in sync, so the cost of a port is meaningfully higher and the threshold for adding one should be higher too.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the contract-test suite shared between real adapters and the in-memory fake is what stops the fake becoming a more forgiving universe than production.
- — Programming Languages & Runtime Internals — what a port costs depends on how the language expresses interfaces: structural typing makes the boundary nearly free, nominal typing makes it a file to maintain.