Codehexagonalports and adaptersdomain isolationtestabilityin-memory adapter

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.

▶ InteractiveInterview question
Progress
What problem does this solve?

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.

Driving adapters call in; driven adapters are called out through ports
HTTPPlaceOrder portimplemented byimplemented byREST APIHTTP adapterApplication / DomainPort: OrderRepositoryPort: EventPublisherPostgres adapterQueue adapter
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

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.

The port is the domain’s vocabulary; adapters translate it
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: production
8export 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) : null
13 }
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 mock
21export 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.

Three ways to keep rules away from technology
LayeredClean ArchitectureHexagonal (Ports & Adapters)
Central ideaStack of layers, imports point downConcentric rings, imports point inwardCore with ports; adapters plug in from outside
Dependency inversionOptional; often absentMandatory (the Dependency Rule)Mandatory (core owns the ports)
VocabularyController, service, repositoryEntity, use case, adapter, driverDriving/driven port, adapter
Entry points treatedPresentation is a special top layerAdapters, one ring outSymmetric: HTTP and tests are both driving adapters
Testability of rulesDepends on disciplineHigh: inner rings import nothingHigh: swap driven adapters for in-memory
CeremonyLowHigh (ports, presenters, models per use case)Medium (one port per need)
Best fitSimple services, junior-heavy teamsLong-lived rules, many delivery mechanismsMany 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

Swap an adapter, keep the domain
Pick a driving adapter and two driven adapters, then run “place order”. Watch which box changes and which never does.
drivesimplementsimplementsREST adapterPort: PlaceOrderDomain ⬡Port: OrderRepositoryPort: EventPublisherPostgres adapterKafka adapter
Driving side
Persistence
Events
step 1: Driving adapter translates
POST /orders {"sku":"A1","qty":2}  →  PlaceOrderInput{ sku: "A1", qty: 2 }
The adapter speaks the outside protocol and produces a plain input object. Nothing inside knows whether this came from HTTP, argv or a test.
domain (unchanged for every combination)
// 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 }
  }
}
With Postgres and Kafka wired in, exercising this code means starting real infrastructure. Switch persistence to In-memory or the driving side to Test harness to see the unit test the ports make possible.
1/5 · Driving adapter translates

How data moves through it

One request or event, hop by hop.

  1. 1Client → HTTP adapter: request parsed and validated for shape, converted into the driving port’s input type.
  2. 2HTTP adapter → Core: the driving port PlaceOrder.execute() is invoked; the core applies domain rules in memory.
  3. 3Core → OrderRepository port → Postgres adapter: save() becomes an upsert; the core never sees SQL.
  4. 4Core → EventPublisher port → Queue adapter: OrderPlaced is serialised and published; in tests it lands in an array.
  5. 5Core → HTTP adapter → Client: the result is mapped to a status code and body by the driving adapter.

When to use — and when not

Use it when
  • 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.
Avoid it when
  • 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

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

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).