The Runtime Theory
Software Architecture

Hexagonal Architecture: Ports, Adapters, and Where the Boundary Really Goes

Hexagonal architecture isolates your domain behind ports and adapters — here is where the boundary actually belongs, what it buys you in tests, and when it is pure ceremony.

The Runtime Theory Team3 min read#hexagonal-architecture#ports-and-adapters#domain-driven-design#testing
On this page

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.

typescript
// 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: UserControllerUserServiceUserRepository 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:

  1. You can describe a core behavior without naming a technology. "When an order is paid, ship it" — not "when the POST /payments handler calls paymentService".
  2. 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:

typescript
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:

  1. The core is anemic. If your "domain" is CRUD passthrough — save in, find out, no rules, no state machines — the ports are interfaces over nothing, and the extra layer is indirection without a seam to exploit.
  2. 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.
  3. 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.