Hexagonal architecture — also called ports and adapters, coined by Alistair Cockburn — is one of the most cited and least understood patterns in the industry. It is not about layers, and it is not about hexagons. It is a single structural claim: put the domain at the center, let it define the interfaces it needs in its own vocabulary, and make every piece of technology outside implement those interfaces. Get that right and your core logic becomes testable without infrastructure and replaceable without surgery. Draw the boundaries in the wrong place and you get layers of interfaces that exist only to be interfaces — ceremony with a diagram attached.
The mechanism: ports and adapters
A port is an interface the domain core defines for itself: something the core needs, expressed in domain terms. An adapter is a concrete implementation of that port, bound to one piece of real technology. The dependency arrows point at the port from both sides — the core depends on the port, and the adapter implements the port. Nothing in the core mentions a framework, a driver, or a wire protocol.
// Port — defined by the core, in domain language
export interface OrderRepository {
findUnshipped(): Promise<Order[]>;
save(order: Order): Promise<void>;
}
// Adapter — knows Postgres, speaks the port's language
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async findUnshipped(): Promise<Order[]> {
const { rows } = await this.pool.query(
"SELECT * FROM orders WHERE shipped_at IS NULL"
);
return rows.map(rowToOrder);
}
}Notice what the port doesn't contain: no SQL, no RowDataPacket, no HTTP status codes.
The moment your port types mention your ORM or your framework, the boundary has drifted
outward and the pattern has collapsed into layering.
Where the boundary actually goes
The most common mistake is drawing the boundary at the framework: UserController →
UserService → UserRepository in a stack that mirrors the database. That is not
hexagonal — that is anemic layering with extra interfaces.
The real boundary belongs where the domain's vocabulary ends and the outside world's
vocabulary begins. A driving adapter (HTTP, CLI, queue consumer) translates outside
requests into domain calls. A driven adapter (database, message broker, email) translates
domain calls into outside operations. Between them sits the core, which knows nothing
about either side — it only knows findUnshipped(), chargePayment(), markShipped().
Two signs your boundary is in the right place:
- You can describe a core behavior without naming a technology. "When an order is
paid, ship it" — not "when the
POST /paymentshandler callspaymentService". - Every adapter is an equal citizen. Swapping the HTTP adapter for a CLI and the Postgres adapter for an in-memory store changes zero core files.
The measurable payoff: test speed and replacement
The benefit that justifies the pattern is concrete: core tests run without a database, without a broker, without HTTP. Because the core only depends on ports, tests supply in-memory fakes:
const repo: OrderRepository = new InMemoryOrderRepository(seedOrders());
const service = new OrderService(repo);
await service.fulfill(orders[0].id);A thousand tests of this shape run in milliseconds per test and are deterministic — no containers, no fixtures, no retries. Compare that with the service whose unit tests spin up a test database: every test inherits the DB's startup, connection-pool contention, and flakiness. The test suite's total runtime is the direct, measurable return on the pattern's indirection.
When it is ceremony
Honesty requires the other side. Hexagonal structure is ceremony when:
- The core is anemic. If your "domain" is CRUD passthrough —
savein,findout, no rules, no state machines — the ports are interfaces over nothing, and the extra layer is indirection without a seam to exploit. - Every port has exactly one adapter, forever. The theoretical replaceability is worth nothing if it is never exercised. If there is exactly one database and one UI, you are paying for a future that may never arrive.
- The ports mirror the framework.
UserRow,UserEntity,UserDTO— three types that serialize each other with no behavior attached. That's tax without benefit.
The honest tradeoff
Hexagonal architecture costs roughly 10–20% more types and mapping code in a typical service: an interface, an adapter, and a mapping function for every external dependency. That is a real tax, paid up front and continuously.
It pays back when two conditions hold: the core contains nontrivial behavior worth protecting, and there is more than one way in or out — multiple clients, multiple databases, or a replacement pending. When both conditions are absent, a well-typed monolith with focused modules gives you most of the clarity at a fraction of the files. The pattern is a tool, not a badge: apply it where the seam earns its keep, and skip it where the core is thin.