Archtin
All articles
ArchitectureSystem DesignDesign Patterns12 min read

Hexagonal Architecture (Ports and Adapters): Dependencies Point Inward

The domain defines the interfaces it needs; databases, HTTP handlers and third-party APIs are adapters that plug into them. Hexagonal architecture makes business logic testable in milliseconds and infrastructure genuinely replaceable.

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.

Domainentities, use cases,port interfacesHTTP APICLIQueue consumerPostgreSQLPayment APIEmail providerdriving adapters (they call the domain)driven adapters (the domain calls them)
The hexagon is arbitrary — what matters is that adapters depend on the domain, never the reverse.

Driving and driven ports

Driving (primary)Driven (secondary)
DirectionOutside → domainDomain → outside
Who defines itThe domain (use case interface)The domain (required capability)
Who implements itThe domainInfrastructure
ExamplesPlaceOrder, CancelBookingOrderRepository, PaymentGateway, Clock, EventPublisher
AdaptersHTTP controller, CLI, queue consumer, cronPostgres repo, Stripe client, SMTP sender, in-memory fake
The detail everyone gets wrong
A driven port is defined by the domain's needs, not by the vendor's API. The port is 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 });
});
The domain owns the interface; infrastructure implements it.

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

  1. Use-case tests run in milliseconds against in-memory adapters — no database, no container, no HTTP. You can afford thousands of them.
  2. They test behaviour, not plumbing. Because the ports are domain-shaped, tests read like business rules.
  3. Fakes beat mocks. Write one real in-memory OrderRepository and reuse it everywhere, instead of stubbing method calls in every test.
  4. Contract tests run the same suite against the in-memory fake and the real Postgres adapter, guaranteeing the fake does not lie.
  5. 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/
Enforce the dependency rule with an import linter.

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 Request import 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?

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free