open atlas
↑ Back to track
Code patterns & craft CP · 06 · 03

Ports and adapters

Hexagonal architecture scales DIP to whole systems — the domain owns the ports (interfaces it needs), infrastructure provides the adapters, and every dependency points inward. The domain is the stable center; I/O is replaceable at the edge. The structure must earn its cost.

CP Senior ◷ 20 min
Level
FoundationsJuniorMiddleSenior

Open the order-processing module and import Stripe from "stripe" is the first line. The pricing rules, the refund logic, the “a paid order ships within 24h” invariant — all of it now transitively depends on a payments vendor’s SDK. You can’t run the domain in a unit test without a network and an API key. You can’t swap Stripe for Adyen without touching the rules. The business logic, the part that is actually yours, has been welded to a detail you don’t control.

That weld is the problem ports and adapters solves. The dependency-inversion principle said “depend on abstractions”; hexagonal architecture asks the same question at the scale of a whole system: which way should the arrows point? The answer — always inward, toward the domain — is what keeps the stable center stable while the I/O at the edge stays replaceable.

Goal

After this lesson you can explain hexagonal architecture as DIP applied at architecture scale: the domain declares the ports (the interfaces it needs), infrastructure supplies the adapters (the implementations), and all dependencies point inward so the domain knows nothing of Stripe, Postgres, or HTTP. You can show how an in-memory adapter makes the domain testable without I/O, and — the senior half — you can name the failure mode: imposing full hexagonal ceremony on a CRUD app that has no real domain, where the layering costs more than it ever returns.

1

A port is an interface the domain owns; the domain declares what it needs, never what provides it. The domain says “to do my job I need to charge a payment” — and writes the interface for exactly that, in domain language, living in domain code:

// domain/ports/PaymentPort.ts — owned by the domain, names a domain need
export interface PaymentPort {
  charge(amountCents: number, idempotencyKey: string): Promise<ChargeResult>;
}
export type ChargeResult = { ok: true; receiptId: string } | { ok: false; reason: string };

Notice what is absent: no Stripe, no HTTP, no API key, no SDK type. The port speaks the domain’s vocabulary (amountCents, idempotencyKey, ChargeResult), not the vendor’s. This is the inversion: the high-level policy (the domain) defines the abstraction, and the low-level detail (Stripe) will be forced to conform to it — not the other way around. The port is the contract the domain is willing to depend on.

2

An adapter is an implementation living in infrastructure; it implements the domain’s port and absorbs the vendor. Stripe’s existence is now a fact confined to one file at the edge:

// infrastructure/StripeAdapter.ts — implements the DOMAIN's port
import Stripe from "stripe";
import type { PaymentPort, ChargeResult } from "../domain/ports/PaymentPort";

export class StripeAdapter implements PaymentPort {
  constructor(private stripe: Stripe) {}

  async charge(amountCents: number, idempotencyKey: string): Promise<ChargeResult> {
    try {
      const intent = await this.stripe.paymentIntents.create(
        { amount: amountCents, currency: "usd", confirm: true },
        { idempotencyKey },
      );
      return { ok: true, receiptId: intent.id };
    } catch (e) {
      return { ok: false, reason: stripeReason(e) }; // translate vendor errors to domain terms
    }
  }
}

The adapter’s whole job is translation: domain language in, vendor calls out, vendor errors mapped back to ChargeResult. The domain depends on PaymentPort; StripeAdapter depends on PaymentPort too (it implements it) and on Stripe. The arrow from infrastructure points into the domain. Stripe is now a leaf, not a root — replaceable by writing a second adapter, with nothing in the domain to touch.

3

The domain uses the port and stays ignorant of the implementation — the dependency is injected at the edge. The use case is written entirely against the port:

// domain/PlaceOrder.ts — pure policy, zero vendor knowledge
export class PlaceOrder {
  constructor(private payments: PaymentPort) {} // depends on the port, not Stripe

  async run(order: Order): Promise<OrderResult> {
    if (order.totalCents <= 0) return { status: "rejected", reason: "empty order" };
    const charge = await this.payments.charge(order.totalCents, order.id);
    if (!charge.ok) return { status: "declined", reason: charge.reason };
    return { status: "placed", receiptId: charge.receiptId };
  }
}

PlaceOrder is the stable center. It can be read, reasoned about, and changed without ever opening Stripe’s docs. The wiring — “in production, PaymentPort is a StripeAdapter” — happens once, in a composition root at the outermost edge:

// main.ts — the only place that knows both sides
const placeOrder = new PlaceOrder(new StripeAdapter(stripe));

That single line is where the abstract meets the concrete. Everything inward of it is vendor-agnostic by construction.

4

The payoff is concrete: an in-memory adapter makes the domain testable with no network, no keys, no flakiness. Because the domain depends only on PaymentPort, any implementation works — including a fake that records calls in a list:

// test/InMemoryPayment.ts — a second adapter, for tests
class InMemoryPayment implements PaymentPort {
  public charged: { amountCents: number; key: string }[] = [];
  constructor(private decline = false) {}
  async charge(amountCents: number, key: string): Promise<ChargeResult> {
    this.charged.push({ amountCents, key });
    return this.decline
      ? { ok: false, reason: "card_declined" }
      : { ok: true, receiptId: "rcpt_test" };
  }
}

test("declined payment yields a declined order", async () => {
  const payments = new InMemoryPayment(true);
  const result = await new PlaceOrder(payments).run(makeOrder({ totalCents: 5000 }));
  expect(result.status).toBe("declined");
  expect(payments.charged).toHaveLength(1); // we asserted the side effect without a network
});

These tests run in milliseconds, deterministically, with no secrets. The same seam that lets you swap Stripe for Adyen in production lets you swap it for a fake in tests — test doubles are just another adapter. This is the everyday dividend of pointing dependencies inward, and it is the most honest evidence that the boundary is real: if the domain were welded to Stripe, this test could not exist.

Worked example

From welded to inverted. A refund feature, written the obvious way, reaches straight for the SDK from inside the business rule:

// BEFORE — the domain rule imports the vendor
import Stripe from "stripe";

export async function refundOrder(order: Order, stripe: Stripe) {
  if (order.status !== "placed") throw new Error("only placed orders refund");
  if (daysSince(order.paidAt) > 30) throw new Error("refund window closed"); // a real domain rule
  await stripe.refunds.create({ payment_intent: order.receiptId }); // vendor welded into policy
  order.status = "refunded";
}

The 30-day window is genuine domain knowledge worth protecting, but it is now unreachable without a Stripe instance. You cannot test the rule without the SDK; you cannot change vendors without editing the rule. Invert it — the domain declares a RefundPort, infrastructure implements it:

// AFTER — domain owns the port, vendor lives at the edge
export interface RefundPort {
  refund(receiptId: string): Promise<void>;
}

export async function refundOrder(order: Order, refunds: RefundPort) {
  if (order.status !== "placed") throw new Error("only placed orders refund");
  if (daysSince(order.paidAt) > 30) throw new Error("refund window closed");
  await refunds.refund(order.receiptId); // depends on the port, not Stripe
  order.status = "refunded";
}

// infrastructure/StripeRefundAdapter.ts
export class StripeRefundAdapter implements RefundPort {
  constructor(private stripe: Stripe) {}
  refund = (receiptId: string) =>
    this.stripe.refunds.create({ payment_intent: receiptId }).then(() => {});
}

Now the 30-day rule is testable with a one-line fake RefundPort, the vendor is one adapter at the edge, and the policy reads without any Stripe noise. Nothing clever happened — the dependency arrow was just turned around so it points at something the domain owns.

Why this works

Why must the domain own the port, rather than the infrastructure exporting an interface the domain imports? Because ownership is what makes the inversion real. If infrastructure defined PaymentGateway and the domain imported it, the domain would still depend on a module that exists to wrap a vendor — the arrow would point outward, and the interface would drift toward the vendor’s shape (you’d see currency, paymentMethodId, Stripe-flavoured fields leaking in). When the domain declares the port, the abstraction is shaped by the domain’s needs, the vendor is forced to conform, and the dependency direction is structurally guaranteed: nothing in the domain folder imports anything from infrastructure. That is the whole content of “dependencies point inward” — it is a rule about which package imports which, enforceable by a lint boundary, not a vibe.

Common mistake

The senior failure mode is applying hexagonal ceremony to an app that has no domain. A CRUD admin panel — read a row, validate three fields, write it back — has no stable policy at its center; it is I/O almost end to end. Wrapping it in ports, adapters, a composition root, and a mapping layer per table buys you nothing to protect (there is no business rule that must stay vendor-agnostic) while taxing every feature with three extra files and a translation step. The structure must earn its cost: ports and adapters pay off exactly when there is a genuine domain — rules and invariants with real value — that you want to keep stable and testable while the I/O around it changes. If swapping the database would change your business rules, you don’t have a domain to protect yet; reach for the boundary when the domain appears, not before. Premature hexagon is just as much over-engineering as no boundary at all.

Check yourself
Quiz

In a hexagonal architecture, where does the PaymentPort interface live, and why does that placement matter?

Recap

Ports and adapters (hexagonal architecture) is the dependency-inversion principle at architecture scale: the domain declares the ports — interfaces in its own vocabulary for the things it needs — and infrastructure supplies the adapters that implement them, so every dependency arrow points inward toward the domain. The domain becomes the stable center that knows nothing of Stripe, Postgres, or HTTP; I/O is a replaceable detail at the edge, wired once in a composition root. The everyday dividend is testability: a test double is just another adapter, so the domain runs with no network or secrets. But the structure must earn its cost — imposing the full ceremony on a CRUD app with no real domain buys you no stability to protect while taxing every feature. Reach for the boundary when there is a genuine domain worth keeping stable; not before.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.