Software Architecture · Architectural Styles
Ports and Adapters and the Direction of the Dependency Arrow
Why a hexagonal boundary is an interface owned by the application core rather than by the framework, and how driving and driven adapters differ in who initiates the call.
The same application can be reachable through an HTTP endpoint, a command-line entry point, and a queue consumer, and can store its state in a relational database, a blob store, or an in-memory map during a test. Hexagonal architecture is the claim that all of those are external, none of them appear in the application’s own source, and the application core is defined only by the interfaces at its edge. The geometric metaphor — a hexagon — is incidental; the original description allows any number of sides. The load-bearing idea is that the interfaces belong to the inside.
That ownership is what distinguishes this style from a layered design with interfaces sprinkled on top. In a layered system, the persistence layer defines its repository interface and the domain layer calls it. Under ports and adapters, the application core declares the interface it needs, and the database adapter implements it. The method signatures may be identical. The direction of the compile-time reference is opposite, and so is the set of things that must change when either side evolves.
Two kinds of port, distinguished by who calls
The edge of the core has two directions of traffic, and the style names them separately.
A driving port, also called a primary or inbound port, is an interface the core implements. Something outside calls it: an HTTP handler, a message listener, a test. The port is expressed in application terms — a request to confirm an order, not a POST to a path — and its implementation is a use-case class. Each driving adapter translates a protocol into a call on that interface and translates the result back.
A driven port, also called a secondary or outbound port, is an interface the core calls and something outside implements. Repositories, payment gateways, clock and identifier sources, and message publishers all live here. The core depends on the interface; an adapter depends on the core’s interface and on the external system.
Both kinds are interfaces in the application’s package, and in both cases the dependency arrow points from the adapter toward the core. The inversion is the same operation applied twice, and the symmetry is what makes the boundary uniform: no external type appears in the core’s signatures regardless of which direction the call travels.
// driving port: implemented by the core, called from outside
export interface ConfirmOrder {
execute(orderId: OrderId): Promise<ConfirmationResult>;
}
// driven port: declared by the core, implemented outside
export interface Orders {
load(orderId: OrderId): Promise<Order | undefined>;
save(order: Order): Promise<void>;
}
Neither interface names HTTP, SQL, or a serialization format. A method such as load returns a domain type; how that value is reconstructed from a row, a document, or a map is decided outside.
The same ownership rule scales beyond a fixed set of adapters. A host that lets third parties supply implementations is publishing these interfaces as a long-lived contract, with the compatibility obligations that plugin extension points carry.
What an adapter is allowed to know
An adapter’s job is translation plus protocol mechanics, and the boundary is only as good as the discipline about where the translation ends.
A driving adapter knows the wire: routing, headers, authentication, status codes, body parsing, and the mapping from a malformed body to a client error. RFC 9110 gives the vocabulary it needs — a failed precondition is 412, a rejected state transition is 409, malformed syntax is 400, and an unhandled fault in the core is 500 — but the decision to use those codes is made in the adapter, not in the use case. A use case that returns a domain-level rejection has no opinion about status codes, and an adapter that maps all rejections to 400 has produced a protocol that cannot distinguish “retry later” from “never retry”.
A driven adapter knows the storage or service protocol: connection handling, retries, timeouts, and the mapping between rows and domain types. It does not know why the core is saving the order. When a driven adapter starts enforcing a business rule — a uniqueness check expressed as a domain invariant, a status transition validated before writing — the rule now exists in one place for production and nowhere else, and every alternative adapter (including the in-memory one used by tests) silently lacks it.
The translation itself is code that exists only because of the boundary. For a read returning n records, a driven adapter allocates one domain object per record plus whatever intermediate structure the mapper uses, and a driving adapter typically allocates a response body representation on the way out. This is the same mapping cost described in the layered style, and it is the price of keeping protocol types out of the core. Whether that price is worth paying is decided per port, not for the architecture as a whole.
Transactions do not belong to any adapter
The hardest boundary question is where a transaction begins and ends, and this style gives no default answer. A transaction must span the core operation and the driven calls it makes, which means the mechanism cannot be owned by either adapter in isolation.
Three placements are workable, and each has a distinct failure mode.
The adapter that starts the driving call can open a transaction before invoking the use case and commit after it returns. This keeps the core free of transaction APIs, and it works when one driving invocation equals one unit of work. It breaks when the use case calls out to a non-transactional participant: the transaction stays open across a network round trip to a payment provider, and a slow provider holds database locks for the duration.
The core can declare a unit-of-work port with begin, commit, and rollback operations, and an adapter can implement it over the database. The core now controls the boundary and can close it before the slow call. The cost is that the core names a transactional concept, and every test adapter must implement the three methods — a moderate but visible leak of infrastructure vocabulary into the inside.
The adapter can manage transactions per driven call, giving each repository method its own. This is the simplest and the weakest: two writes in one use case are no longer atomic, and partial failure leaves the store in a state the core never intended. It is acceptable only when the use case performs a single write.
The choice is a trade between atomicity scope and lock duration, and the boundary does not decide it. What the boundary enforces is that whichever option is chosen, the decision is visible in one place rather than scattered across adapters.
Failure modes that survive a correct diagram
Several defects leave the diagram intact while destroying the property it was meant to provide.
A port shaped by its adapter is the most common. If the driven port takes a cursor string because the database uses cursor pagination, or accepts a batch size and offsets, it encodes the storage protocol in the core’s interface. The correct shape is a domain notion — “the next page of orders” — with the adapter owning whichever mechanism the store provides. Once a port’s signature contains a type from the external system, every alternative adapter must fabricate that type.
Port explosion is the second. A port per table produces a set of interfaces that mirror the schema, and the core is then coupled to the schema through a one-to-one mapping of names. A driven port should correspond to a capability the core needs, which usually means fewer ports than tables and often more than one table behind a single port.
The third is the shared model. A response type used both as the HTTP body and as the domain object eliminates the driving-side translation entirely: validation runs in whichever layer is written first, and field names carry serialization constraints into the core. It is tempting because it removes an entire mapping function, and it is exactly the mapping that the boundary exists to create.
Verification is straightforward when the core is truly independent: an in-process adapter pair — an in-memory implementation of each driven port and a direct call to each driving port — lets the whole application be exercised without a database or a network. When writing such an adapter requires stubbing network clients or reimplementing protocol logic, a port is missing or mis-shaped. The test is not about coverage; it is about whether the core can be executed with nothing but the language runtime.
Where the style stops
Ports and adapters describe the edge of one application and say nothing about how applications are composed. Splitting the core into several deployable pieces, and deciding where one boundary becomes a network hop, is a different set of concerns; the same interfaces become contracts between processes, and every in-process assumption about latency and transactional consistency stops holding. Within a single process the style delivers a testable core and a stable place for protocol code. Its cost is a pair of models at each boundary and the mapping between them, and that cost is paid whether or not the boundary is ever exercised by a second adapter.
References
- Hexagonal Architecture (Ports and Adapters) — Alistair Cockburn
- The Clean Architecture — Robert C. Martin
- Agile Software Development: Principles, Patterns, and Practices — Robert C. Martin — Prentice Hall
- RFC 9110: HTTP Semantics — IETF