The idea
Alistair Cockburn's hexagonal architecture states one rule: the application must be equally usable by a user, a test harness, or another program, and equally able to run against a real database or an in-memory one. To achieve that, the domain defines interfaces — ports — for everything it needs from the outside world, and infrastructure supplies adapters implementing them.
All dependency arrows point inward. The domain imports nothing from infrastructure; not the ORM, not the HTTP framework, not the payment SDK.
Driving and driven ports
| Driving (primary) | Driven (secondary) | |
|---|---|---|
| Direction | Outside → domain | Domain → outside |
| Who defines it | The domain (use case interface) | The domain (required capability) |
| Who implements it | The domain | Infrastructure |
| Examples | PlaceOrder, CancelBooking | OrderRepository, PaymentGateway, Clock, EventPublisher |
| Adapters | HTTP controller, CLI, queue consumer, cron | Postgres repo, Stripe client, SMTP sender, in-memory fake |
PaymentGateway.charge(amount, idempotencyKey) — not a thin wrapper that leaks Stripe's object model. If swapping providers changes the port, the port was an adapter in disguise.What it looks like in code
// domain/ports.ts — no framework imports anywhere in here
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
export interface PaymentGateway {
charge(amount: Money, key: IdempotencyKey): Promise<ChargeResult>;
}
export interface Clock { now(): Date }
// domain/use-cases/place-order.ts
export class PlaceOrder {
constructor(
private orders: OrderRepository,
private payments: PaymentGateway,
private clock: Clock,
) {}
async execute(cmd: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(cmd, this.clock.now()); // invariants live here
const result = await this.payments.charge(order.total, cmd.idempotencyKey);
order.recordPayment(result);
await this.orders.save(order);
return order.id;
}
}
// infrastructure/postgres-order-repository.ts
export class PostgresOrderRepository implements OrderRepository { /* SQL here */ }
// infrastructure/http/order-controller.ts (driving adapter)
router.post("/orders", async (req, res) => {
const id = await placeOrder.execute(toCommand(req.body, req.user));
res.status(201).json({ id });
});Injecting Clock looks pedantic until you need to test "expires after 30 days" without sleeping. Time, randomness and IDs are dependencies like any other.
Why testing gets dramatically better
- Use-case tests run in milliseconds against in-memory adapters — no database, no container, no HTTP. You can afford thousands of them.
- They test behaviour, not plumbing. Because the ports are domain-shaped, tests read like business rules.
- Fakes beat mocks. Write one real in-memory
OrderRepositoryand reuse it everywhere, instead of stubbing method calls in every test. - Contract tests run the same suite against the in-memory fake and the real Postgres adapter, guaranteeing the fake does not lie.
- Integration tests shrink to adapter-only coverage, so the slow tests are few.
Project layout
src/
domain/ entities, value objects, invariants (zero external deps)
application/ use cases + port interfaces
infrastructure/
persistence/ postgres adapters
http/ controllers, routes, serialisers
external/ stripe, sendgrid, s3 clients
memory/ in-memory adapters for tests and local dev
main.ts composition root: builds adapters, injects into use cases
rule: domain/ and application/ may not import infrastructure/Everything is wired in one place — the composition root. That is the only file that knows both sides exist.
Hexagonal, onion, clean — the differences
They are the same principle with different diagrams. Hexagonal (2005) emphasises symmetry between inputs and outputs. Onion (2008) adds concentric layers with domain services between entities and application. Clean (2012) adds explicit entity/use-case/interface-adapter rings and names the dependency rule. If a team argues about which one they use, they are already doing all three.
Common mistakes
- Ports that mirror the ORM. A repository with
findAll(where, orderBy, limit)is a database, not a port. Ports should express domain queries:findOverdueInvoices(asOf). - Anaemic domain. Entities with only getters and setters, all logic in "services". You get the indirection cost without the modelling benefit.
- Framework types in the domain. One
Requestimport and the boundary is gone. - Interface-per-class. Only ports need interfaces. Adding one for every class is ceremony.
- Transactions leaking. Decide explicitly whether the use case controls the transaction boundary (usually yes, via a unit-of-work port) or the adapter does.
When it is not worth it
For CRUD with no real invariants — an admin panel, an internal form-over-data tool — the mapping layers cost more than they return. Use the framework directly and keep the honesty. Hexagonal pays off when the domain has genuine rules, a long life, or infrastructure you expect to replace.
Interview framing
Strong answer
"Inside each service I'd use ports and adapters: the domain defines OrderRepository, PaymentGateway and Clock; Postgres, Stripe and the system clock are adapters wired at the composition root. Business rules live in entities, so the pricing and cancellation rules are tested in memory in milliseconds, and contract tests run the same suite against the real Postgres adapter. That also means swapping the payment provider is one adapter, not a search across the codebase. The cost is mapping code and indirection, which I would skip for pure CRUD modules."
Follow-ups
- Who owns the transaction boundary?
- How do you keep an in-memory fake honest?
- Where do cross-cutting concerns like authorisation live?