Hexagonal Architecture (Ports & Adapters)
The application core exposes ports — interfaces it needs and interfaces it offers — and every external thing (REST, a database, a queue, a payment provider) is an adapter plugged into a port, so the domain is isolated from technology and any adapter can be swapped for an in-memory one in a test.
The application core keeps getting entangled with the specific technologies at its edges — the HTTP framework, the ORM, the broker client — so it cannot be tested without them or moved away from them. Ports & Adapters puts a hard boundary around the core and makes every technology a replaceable plug-in.
Ports, adapters, and the two sides of the hexagon
A port is an interface at the boundary of the application, defined in the language of the application. There are two kinds. Driving (primary) ports are what the application offers: PlaceOrder, GetOrderStatus. Driven (secondary) ports are what the application needs: OrderRepository, PaymentGateway, EventPublisher. An adapter connects a port to a technology. A REST controller is a driving adapter that turns HTTP into PlaceOrder calls; a Postgres repository is a driven adapter that turns OrderRepository calls into SQL; a Kafka publisher is a driven adapter for EventPublisher. Tests are driving adapters too: a test calls the same PlaceOrder port the controller does.
The hexagon is a drawing convenience — six sides say "many ports, no privileged one" — but the idea is exactly the dependency inversion of Clean Architecture with a different emphasis. Clean Architecture is about *rings and the direction of dependency*; Hexagonal is about *the boundary and what crosses it*. Everything inside the boundary knows nothing about the outside; everything outside adapts to the inside’s interfaces. The domain does not know whether it was called from HTTP or a test, or whether it saved to Postgres or a Map.
One port, two adapters
The value shows up the first time you write the second adapter. The in-memory adapter is not a mock — it is a real, behaviour-complete implementation with a Map behind it. Every use-case test runs against it in microseconds; the Postgres adapter gets its own contract test that asserts both adapters behave identically. If a test passes in memory and fails in Postgres, the bug is in the adapter, not the domain, and you know that before opening a debugger.
1// port (inside the hexagon)2export interface OrderRepository {3 byId(id: OrderId): Promise<Order | null>4 save(order: Order): Promise<void>5}6 7// driven adapter 1: production8export class PgOrderRepository implements OrderRepository {9 constructor(private readonly sql: Sql) {}10 async byId(id: OrderId) {11 const [row] = await this.sql`SELECT * FROM orders WHERE id = ${id}`12 return row ? Order.rehydrate(row) : null13 }14 async save(order: Order) {15 await this.sql`INSERT INTO orders ${this.sql(order.toRow())}16 ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status`17 }18}19 20// driven adapter 2: tests and local dev — a real implementation, not a mock21export class InMemoryOrderRepository implements OrderRepository {22 private readonly rows = new Map<OrderId, Order>()23 async byId(id: OrderId) { return this.rows.get(id) ?? null }24 async save(order: Order) { this.rows.set(order.id, order) }25}Clean vs Hexagonal
In practice teams that say "Clean" and teams that say "Hexagonal" write nearly the same code. The differences are emphasis and vocabulary, and the matrix is mostly here so you can answer the interview question (Layered Architecture is included as the baseline they both improve on).
The mistakes are shared too. A port that leaks technology — OrderRepository.query(sql: string) — is not a port; it is the database with a hat on. A port per class instead of per *need* produces dozens of one-method interfaces. And "we have ports" while the core imports the ORM for "just this one join" is the same boundary violation as everywhere else; the boundary only protects you if it is absolute.
| Layered | Clean Architecture | Hexagonal (Ports & Adapters) | |
|---|---|---|---|
| Central idea | Stack of layers, imports point down | Concentric rings, imports point inward | Core with ports; adapters plug in from outside |
| Dependency inversion | Optional; often absent | Mandatory (the Dependency Rule) | Mandatory (core owns the ports) |
| Vocabulary | Controller, service, repository | Entity, use case, adapter, driver | Driving/driven port, adapter |
| Entry points treated | Presentation is a special top layer | Adapters, one ring out | Symmetric: HTTP and tests are both driving adapters |
| Testability of rules | Depends on discipline | High: inner rings import nothing | High: swap driven adapters for in-memory |
| Ceremony | Low | High (ports, presenters, models per use case) | Medium (one port per need) |
| Best fit | Simple services, junior-heavy teams | Long-lived rules, many delivery mechanisms | Many integrations, heavy test emphasis |
Key points
- Driving ports are what the core offers; driven ports are what it needs. Adapters translate a port to a technology in either direction.
- HTTP and a test are both driving adapters; Postgres and a Map are both driven adapters. The core cannot tell them apart.
- The in-memory adapter is a real implementation; a contract test proves it and the production adapter behave alike.
- Clean and Hexagonal are the same inversion with different emphasis: rings and direction versus boundary and plugs.
- A port that exposes SQL, HTTP or a vendor type is not a port; the boundary only protects you if it is absolute.
Swap an adapter, keep the domain
POST /orders {"sku":"A1","qty":2} → PlaceOrderInput{ sku: "A1", qty: 2 }// PlaceOrder.ts — this block never changes, whatever you pick
export class PlaceOrder {
constructor(private orders: OrderRepository, private events: EventPublisher) {}
async execute(input: PlaceOrderInput) {
const order = Order.create(input.sku, input.qty) // rule: qty ≥ 1, price from catalog
order.applyDiscount() // rule: 10% over 100 €
await this.orders.save(order) // outbound port
await this.events.publish(new OrderPlaced(order.id)) // outbound port
return { id: order.id, total: order.total }
}
}How data moves through it
One request or event, hop by hop.
- 1Client → HTTP adapter: request parsed and validated for shape, converted into the driving port’s input type.
- 2HTTP adapter → Core: the driving port
PlaceOrder.execute()is invoked; the core applies domain rules in memory. - 3Core → OrderRepository port → Postgres adapter:
save()becomes an upsert; the core never sees SQL. - 4Core → EventPublisher port → Queue adapter:
OrderPlacedis serialised and published; in tests it lands in an array. - 5Core → HTTP adapter → Client: the result is mapped to a status code and body by the driving adapter.
When to use — and when not
- A core with many external integrations — database, broker, payment provider, email, search — that you want to test offline.
- Systems that will change an integration: a provider migration becomes "write one adapter", not "touch every use case".
- Teams that want the same use cases callable from HTTP, a queue consumer, a CLI and a test harness with no duplication.
- Small services with one entry point and one store, where the only adapter that will ever exist is the one you have.
- Thin integration glue whose entire job *is* the technology (a webhook relay, a log shipper); there is no core to protect.
- When ports would be written with one implementation and no test using a second — that is an interface tax, not an architecture.
Tradeoffs
In-process; no runtime cost. Fewer moving parts than full Clean Architecture, and the in-memory adapters double as local-dev infrastructure.
How it fails
- A leaky port (
find(whereClause: string)) couples the core to SQL syntax; the "swap" to another store is a rewrite after all. - The in-memory adapter drifts from the real one — it ignores a unique constraint — and tests pass while production throws.
- Port-per-class explosion: forty single-method interfaces, each with one implementation, each a file to open.
- Transactions across ports: two driven adapters each open their own connection, and "save order, then publish event" is no longer atomic — the outbox pattern in Distributed Transactions is the fix.
How it scales
- Runtime scaling is that of the process it lives in; the pattern is about code, not deployment.
- Scales integrations: a fifth external system is a fifth adapter, and the core does not grow.
- Scales toward services: a driven port whose adapter becomes an HTTP client to another team’s service is the natural seam for extraction from a Modular Monolith.
How it interacts with databases, queues, caches, APIs and external systems
- Database: a driven adapter behind a repository port; the in-memory adapter runs the same tests.
- Queue: a driven adapter behind a publisher port for outbound events; a consumer is a driving adapter for inbound ones.
- Cache: a decorating adapter that wraps the repository adapter; the port and the core are unchanged.
- External APIs: each provider is a driven adapter; a fake adapter makes checkout testable offline and demo-able without credentials.
- Other services: an HTTP client adapter behind a port — the seam a service extraction uses (Microservices).