Layers, ports and adapters
Keep the rules in a core that imports nothing outside it; plug the outside in through ports, and port only what lives outside.
Predict first
Before you read: predict
A payments ledger's core settles card payments through its PaymentGateway port:
This week's merge request changed main.ts from StripeGateway to PaylaneGateway and nothing else. The card behind payment p-7 is refused.
What does ledger.settle('p-7', 2500) do?
// core/ports.tsexport interface PaymentGateway { capture(reference: string, pence: number): Promise<void>;} // core/ledger.tsimport type { PaymentGateway } from './ports'; export class Ledger { constructor(private payments: PaymentGateway) {} async settle(reference: string, pence: number): Promise<'settled' | 'declined'> { try { await this.payments.capture(reference, pence); return 'settled'; } catch (e) { if ((e as { type?: string }).type === 'StripeCardError') return 'declined'; throw e; } }} // adapters/stripe-gateway.tsimport Stripe from 'stripe';import type { PaymentGateway } from '../core/ports'; export class StripeGateway implements PaymentGateway { private stripe = new Stripe(process.env.STRIPE_KEY ?? ''); async capture(reference: string, pence: number) { await this.stripe.paymentIntents.capture(reference, { amount_to_capture: pence }); }} // adapters/paylane-gateway.tsimport { Paylane } from '@paylane/sdk';import type { PaymentGateway } from '../core/ports'; export class PaylaneGateway implements PaymentGateway { private paylane = new Paylane({ apiKey: process.env.PAYLANE_KEY ?? '' }); async capture(reference: string, pence: number) { const r = await this.paylane.capture({ reference, pence }); if (r.outcome === 'refused') throw new Error(`Paylane refused ${reference}: ${r.reason}`); }} // main.tsimport { Ledger } from './core/ledger';import { PaylaneGateway } from './adapters/paylane-gateway'; export const ledger = new Ledger(new PaylaneGateway());The 30-second version
A core holds the business rules and declares ports: interfaces for what it needs from outside, in its own terms. Adapters plug in from outside: HTTP, a database, a vendor SDK, the clock. Dependencies point inward: adapters import the core, never the reverse. Put ports only in front of outside or changing things; pure code stays a plain import.
Keep the rules in a core that imports nothing outside it; plug the outside in through ports, and port only what lives outside.
How it works
Step 1 of 15
Analogy: An escape room's controller and its wired props (leisure venues)
An escape-room company runs each room from one controller box. The puzzle logic lives there: which solved clue opens which door.
| In the escape room | In ports and adapters |
|---|---|
| each socket on the box is labelled by what the room needs: "puzzle solved", "open door", "play sound" | ports, declared by the core, named by purpose (steps 2, 9) |
| a supplier's magnetic lock or keypad is wired to give "solved" the way the box defines it; the box never learns the brand | adapters plugged in from outside, dependencies pointing inward (steps 3–4) |
| players and the game master set things going; the box itself fires the door release and the lights | driving and driven adapters (step 7) |
| the technician wires every prop to its socket before the doors open | one place wires the adapters (step 8) |
| the countdown display sits on its own socket; in a rehearsal the designer sets it to "two minutes left" to test the ending | the clock behind a port, and a fixed time in tests (steps 13–14) |
| the printed riddle on the wall needs no socket | pure code stays a plain part (step 12) |
In real terms: the core's files import only the core; every database, vendor, framework and clock call reaches it through a port that an adapter fills, chosen in one startup file.
Breaks down when: the technician rewires a prop in the middle of a game. In code, the startup file picks each adapter once, before the first request.
Contrasting case: a paper treasure hunt in a park has no electronics to wire; a script with no outside world to reach has nothing to put behind a port.
Examples, run for real
4.1 ExamHall EXH-2630: the grading core that needs a database and a day
EXH-2630: the grading core scored an attempt by reading the answer key through Prisma and judged lateness with the system clock:
// core/grade-attempt.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function gradeAttempt(examId: string, answers: Record<number, string>, deadline: Date) {
const key = await prisma.question.findMany({ where: { examId } });
const right = key.filter((q) => answers[q.number] === q.correct).length;
return { score: Math.round((100 * right) / key.length), late: new Date() > deadline };
}
Its four tests ran on CI, which has no database, and on a laptop with one, on two days:
4.1 EXH-2630 as written · CI, no database: 0 of 4 on 2026-10-02, 0 of 4 on 2026-11-02 · laptop with the database: 3 of 4 on 2026-10-02 (fails: late after the deadline), 3 of 4 on 2026-11-02 (fails: on time before the deadline)
4.1 EXH-2630 inward check: 1 import leaves core/ (core/grade-attempt.ts -> @prisma/client)
Every run needed the database, and on the laptop each day failed a different test (steps 13–14). MR !2930 gives the core two ports. Adapters implement them, and main.ts wires both in:
// core/ports.ts
export interface AnswerKeys { forExam(examId: string): Promise<{ number: number; correct: string }[]>; }
export interface Clock { now(): Date; }
// core/grade-attempt.ts
import type { AnswerKeys, Clock } from './ports';
export async function gradeAttempt(keys: AnswerKeys, clock: Clock, examId: string, answers: Record<number, string>, deadline: Date) {
const key = await keys.forExam(examId);
const right = key.filter((q) => answers[q.number] === q.correct).length;
return { score: Math.round((100 * right) / key.length), late: clock.now() > deadline };
}
// adapters/prisma-answer-keys.ts
import { PrismaClient } from '@prisma/client';
import type { AnswerKeys } from '../core/ports';
export class PrismaAnswerKeys implements AnswerKeys {
private prisma = new PrismaClient();
forExam(examId: string) { return this.prisma.question.findMany({ where: { examId } }); }
}
// adapters/system-clock.ts
import type { Clock } from '../core/ports';
export const systemClock: Clock = { now: () => new Date() };
// main.ts
import { gradeAttempt } from './core/grade-attempt';
import { PrismaAnswerKeys } from './adapters/prisma-answer-keys';
import { systemClock } from './adapters/system-clock';
const keys = new PrismaAnswerKeys();
export const grade = (examId: string, answers: Record<number, string>, deadline: Date) =>
gradeAttempt(keys, systemClock, examId, answers, deadline);
The same four tests now pass an in-memory answer key and a fixed clock:
4.1 MR !2930 with an in-memory answer key and a fixed clock · CI, no database: 4 of 4 on 2026-10-02, 4 of 4 on 2026-11-02 · laptop with the database: 4 of 4 on 2026-10-02, 4 of 4 on 2026-11-02
4.1 MR !2930 inward check: 0 imports leave core/ · main.ts in production on 2026-10-02 (Prisma, system clock): score 75, late false
The inward check finds no import leaving core/, and the same core still grades through Prisma in production (step 4).
4.2 Proof domain, payments ledger: two ways in, one core
A ledger core posts entries through its own port. An HTTP adapter and a nightly CSV adapter both drive it:
// core/ledger.ts
export interface Entry { account: string; pence: number; ref: string; }
export interface EntryStore { add(e: Entry): void; }
export function postEntry(store: EntryStore, e: Entry) {
if (!Number.isInteger(e.pence) || e.pence === 0) throw new Error(`bad amount on ${e.ref}`);
store.add(e);
}
// adapters/http-entries.ts
import { postEntry, type EntryStore } from '../core/ledger';
export const handle = (store: EntryStore) => (req: { body: string }) => {
postEntry(store, JSON.parse(req.body));
return { status: 201 };
};
// adapters/csv-entries.ts
import { postEntry, type EntryStore } from '../core/ledger';
export function importCsv(store: EntryStore, text: string) {
for (const line of text.trim().split('\n').slice(1)) {
const [account, pence, ref] = line.split(',');
postEntry(store, { account, pence: Number(pence), ref });
}
}
4.2 HTTP adapter, 3 requests (201, 201, 201): 3 entries (acc-1 +1250 r-1, acc-2 -300 r-2, acc-1 +40 r-3)
4.2 CSV adapter, 3 lines: 3 entries, the same · inward check: 0 imports leave core/
Both drivers call the same port, and the core names neither (steps 3, 7).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A ticketing site's order core keeps buyers informed: confirmations, tickets ready, refunds. Today it holds two ports, SmtpPort and TwilioSmsPort, and notifyBuyer() chooses one by each buyer's contact setting. From next quarter, buyers may also choose app push (Firebase) or WhatsApp.
How should the core's ports look after that?
Question 2 of 5
ExamHall's booking core reserves a candidate's exam seat and takes the fee. Today it calls five things directly:
Its six tests book seats for exams that close at 17:00 UTC on 10 October 2026. The team will move some of the five behind ports: each test then passes an in-memory adapter for every port, and no test patches a global or a module. CI runs the tests offline, on whatever day a build happens.
Which ports are the fewest that let all six tests pass on every run?
// core/book-seat.tsimport Stripe from 'stripe';import nodemailer from 'nodemailer';import Decimal from 'decimal.js';import { z } from 'zod'; const Booking = z.object({ candidate: z.string().email(), exam: z.string(), feePence: z.number().int().positive() }); export async function bookSeat(input: unknown, closesAt: Date) { const b = Booking.parse(input); if (new Date() >= closesAt) return { status: 'closed' as const }; const total = new Decimal(b.feePence).mul('1.2').toDecimalPlaces(0).toNumber(); const stripe = new Stripe(process.env.STRIPE_KEY ?? ''); const charge = await stripe.paymentIntents.create({ amount: total, currency: 'gbp' }); await nodemailer.createTransport({ host: 'smtp.examhall.test' }).sendMail({ to: b.candidate, subject: `Seat booked: ${b.exam}` }); return { status: 'booked' as const, total, chargeId: charge.id };}Question 3 of 5
A shop's catalog team is writing the acceptance spec for one rule: a product with nothing left in the warehouse cannot go into a basket.
Which spec should it write?
Question 4 of 5
A streaming service's subscription core charges each renewal through its card adapter, which calls the billing provider's API. When a renewal charge goes through, the billing provider sends POST /hooks/billing to the service with the charge id, and the core marks that subscription renewed.
In ports-and-adapters terms, what is the code that handles POST /hooks/billing?
Question 5 of 5
A chat app is moving its messages from Firestore to Postgres. Its core today:
The merge request deletes adapters/firestore-messages.ts and removes the firebase-admin package.
Which change to core/, in the same merge request, leaves tsc with nothing to report in core/?
// core/message.tsimport { Timestamp } from 'firebase-admin/firestore'; export interface Message { id: string; room: string; text: string; sentAt: Timestamp;} // core/edit-window.tsimport { Timestamp } from 'firebase-admin/firestore';import type { Message } from './message'; export function canEdit(m: Message, now: Timestamp): boolean { return now.toMillis() - m.sentAt.toMillis() <= 15 * 60 * 1000;}Go deeper
Pro tip
- Find the composition root with one search: list the files that import from both
core/andadapters/. A healthy codebase has one, the startup file; any other is wiring that has leaked into a core or an adapter. When: reviewing where a new adapter is plugged in (step 8). Verify:tip 1 MR !2930, 5 files: importing from both core/ and adapters/: main.ts tip 1 the others: core/ports.ts (neither), core/grade-attempt.ts (core/), adapters/prisma-answer-keys.ts (core/), adapters/system-clock.ts (core/)
Source
Cockburn's article is a dated 2005 technical report and makes no vendor claim. The vendor pages are documentation that changes with releases, so their claims are labelled as of 2026-10-02.
- Alistair Cockburn, The Hexagonal (Ports & Adapters) Architecture, HaT Technical Report 2005.02, the curriculum row's first source: https://alistair.cockburn.us/hexagonal-architecture/ (
cockburn-hexagonal.txt). - NestJS documentation, Custom providers (as of 2026-10-02), the row's second source: https://docs.nestjs.com/fundamentals/custom-providers. Its static HTML holds no chapter text, so the text was read from its source file at the file's latest commit on 2026-10-02: https://raw.githubusercontent.com/nestjs/docs.nestjs.com/9507239be102d8031d75ef71e4c30c1394199564/content/fundamentals/dependency-injection.md (
nestjs-custom-providers.txt). Teaching for step 8:useClass,useValueanduseFactorybind a token in the module, which is where Nest wires each adapter. - Stripe documentation, Error handling (Node.js, as of 2026-10-02): https://docs.stripe.com/error-handling?lang=node (
stripe-error-handling.txt). Behind the opening question's code: in the Stripe Node.js library each error carries atype, and a refused payment's isStripeCardError. - Google Cloud, Firestore for Node.js: Timestamp (as of 2026-10-02): https://cloud.google.com/nodejs/docs/reference/firestore/latest/firestore/timestamp (
gcloud-firestore-timestamp.txt). Behind D2's code:toMillis()returns the milliseconds since the Unix epoch, andfromDate()builds aTimestampfrom aDate.
Related lessons
- Read first: Dependency inversion and NestJS dependency injection
- Related: Wrappers: adapter, decorator, proxy, facade
- Related: Test doubles and boundaries
- Related: Single responsibility as one reason to change
- Related: The repository pattern over an ORM
- Related: Modular monolith vs microservices
- Related: Types vanish at runtime, validate at the boundary
- Related: When a pattern is over-engineering
Checked against the tools and docs as of 2026-10-02.