Lesson 16 / 19
Ports and Adapters
Presenting hexagonal architecture as an arrangement: turning the places the domain opens to the outside into named ports, counting the domain module's import closure and its direct links to the outside world across two arrangements, running the same domain file with two adapter teams, and reporting an unsatisfied port at run time.
Contents
The previous lesson’s scenario pushed decisions down to the aggregate root, but one of its steps still carried an outside-world name. Recording an arrival, in its real form, does not settle for a single repository either: the carrier’s own point code is read, an arrival number is generated, a notice is sent to the transfer center. If these three names enter the domain module as direct links, the domain model starts knowing the outside.
What this lesson measures is the count of those links. Building the measure first requires naming what the outside is. A port is the name of a place the domain opens to the outside and the set of operations it expects; an adapter is the implementation that satisfies that set. More than one adapter can satisfy the same port. This name is the same word as the transport-layer port in Computer Networks, but it is not related: there it is a number, here it is a contract for a set of operations.
What Is New
Where the boundary is drawn, the distinction between a stable rule and a variable detail, and the dependency arrow’s direction were established and measured in the Design Principles course’s Policy and Detail Separation and Drawing Boundaries lessons; the layers’ responsibilities were established in the Data Access Layer and Business Logic course’s Layer Responsibilities lesson. The adapter pattern itself was covered in Design Patterns. None of this is re-established here.
The only thing that is new is an arrangement: when the same principles are placed so that they put the domain at the center and turn every place it opens to the outside into a named point, the resulting layout is called hexagonal architecture. The number six has no meaning; it was chosen to say the shape does not have only two faces, that is, there are not two sides called input and output. The number of points can just as well be three or nine.
The Outside
Three external modules represent the three separate worlds the domain has to talk to.
mkdir -p external closed/domain closed/application
// external/record-table.mjs — external table holding delivery records const TABLE = new Map(); export const recordTable = { read: (id) => structuredClone(TABLE.get(id) ?? null), write: (record) => { TABLE.set(record.id, structuredClone(record)); }, seed: (id) => { TABLE.set(id, { id, state: "accepted", route: ["34", "06", "35"], passed: [] }); }, };
// external/tracking-service.mjs — external system giving the carrier's own point codes and arrival number const CODE = { "34": "IST-A1", "06": "ANK-T2", "35": "IZM-D7" }; let counter = 0; export const trackingService = { pointCode: (n) => CODE[n] ?? "UNKNOWN", arrivalNumber: () => `V${String((counter += 1)).padStart(4, "0")}`, };
// external/notification-channel.mjs — external channel sending a notice to the transfer center const SENT = []; export const notificationChannel = { send: (m) => SENT.push(m), count: () => SENT.length };
The Arrangement That Wires the Link Directly
In the first arrangement, the aggregate root imports the three modules itself. The decisions still live inside it; the only thing that changes is how it talks to the outside.
// closed/domain/delivery.mjs — the aggregate root imports the three external modules directly import { recordTable } from "../../external/record-table.mjs"; import { trackingService } from "../../external/tracking-service.mjs"; import { notificationChannel } from "../../external/notification-channel.mjs"; export class Delivery { #record; static load(id) { const record = recordTable.read(id); if (record === null) throw new RangeError(`delivery not found: ${id}`); return new Delivery(record); } constructor(record) { this.#record = record; } recordArrival(point) { const r = this.#record; if (r.state === "delivered") throw new Error("cannot record arrival on a closed delivery"); if (!r.route.includes(point)) throw new Error(`point not on route: ${point}`); const order = r.route.indexOf(point); if (order !== r.passed.length) throw new Error("route order broken"); r.passed.push(point); r.state = order === r.route.length - 1 ? "out-for-delivery" : "in-transfer"; const number = trackingService.arrivalNumber(), code = trackingService.pointCode(point); recordTable.write(r); notificationChannel.send({ number, id: r.id, code, state: r.state }); return { identity: r.id, number, code, state: r.state, passed: r.passed.length }; } }
// closed/application/record-arrival.mjs — the scenario has nothing left to provide import { Delivery } from "../domain/delivery.mjs"; export const recordArrival = (id, point) => Delivery.load(id).recordArrival(point);
The scenario shrank to one line, because it has nothing left to give. All the links were wired one level down.
Naming the Ports
In the second arrangement, the domain side writes the names it expects into a file. This file belongs to the domain: the stable side writes the contract, and the side that satisfies it conforms.
mkdir -p open/domain open/application open/adapters
// open/domain/ports.mjs — the three points the domain opens to the outside, and the five names it expects export const PORTS = { records: ["read", "write"], tracking: ["pointCode", "arrivalNumber"], notifications: ["send"], }; export function validateEnvironment(environment) { const missing = []; for (const [port, names] of Object.entries(PORTS)) { for (const name of names) if (typeof environment?.[port]?.[name] !== "function") missing.push(`${port}.${name}`); } if (missing.length > 0) throw new TypeError(`unsatisfied port: ${missing.join(", ")}`); }
// open/domain/delivery.mjs — the same five decisions; no external module name, environment arrives as a parameter import { validateEnvironment } from "./ports.mjs"; export class Delivery { #record; #environment; static load(id, environment) { validateEnvironment(environment); const record = environment.records.read(id); if (record === null) throw new RangeError(`delivery not found: ${id}`); return new Delivery(record, environment); } constructor(record, environment) { this.#record = record; this.#environment = environment; } recordArrival(point) { const r = this.#record, e = this.#environment; if (r.state === "delivered") throw new Error("cannot record arrival on a closed delivery"); if (!r.route.includes(point)) throw new Error(`point not on route: ${point}`); const order = r.route.indexOf(point); if (order !== r.passed.length) throw new Error("route order broken"); r.passed.push(point); r.state = order === r.route.length - 1 ? "out-for-delivery" : "in-transfer"; const number = e.tracking.arrivalNumber(), code = e.tracking.pointCode(point); e.records.write(r); e.notifications.send({ number, id: r.id, code, state: r.state }); return { identity: r.id, number, code, state: r.state, passed: r.passed.length }; } }
// open/application/record-arrival.mjs — the scenario takes the environment from outside and passes it to the domain model import { Delivery } from "../domain/delivery.mjs"; export const recordArrival = (id, point, environment) => Delivery.load(id, environment).recordArrival(point);
Two Adapter Teams
The same three ports are satisfied in two different ways. One connects to the external modules, the other stays in memory.
// open/adapters/production.mjs — adapters connecting the three ports to the external modules import { recordTable } from "../../external/record-table.mjs"; import { trackingService } from "../../external/tracking-service.mjs"; import { notificationChannel } from "../../external/notification-channel.mjs"; export const productionEnvironment = { records: { read: (id) => recordTable.read(id), write: (r) => recordTable.write(r) }, tracking: { pointCode: (n) => trackingService.pointCode(n), arrivalNumber: () => trackingService.arrivalNumber() }, notifications: { send: (m) => notificationChannel.send(m) }, };
// open/adapters/test.mjs — adapters satisfying the same three ports in memory const TABLE = new Map(); let counter = 0; export const SENT = []; export const testEnvironment = { records: { read: (id) => structuredClone(TABLE.get(id) ?? null), write: (r) => { TABLE.set(r.id, structuredClone(r)); }, }, tracking: { pointCode: (n) => `TEST-${n}`, arrivalNumber: () => `S${String((counter += 1)).padStart(4, "0")}` }, notifications: { send: (m) => SENT.push(m) }, }; export const seed = (id) => TABLE.set(id, { id, state: "accepted", route: ["34", "06", "35"], passed: [] });
Measurement
The measurement starts at the domain module’s root and walks its import links: how many files
enter the closure, how many links land in the external/ directory. It then runs the same
domain file with two adapter teams and looks at what happens with an unsatisfied port.
// link-count.mjs — the domain module's import closure, direct link count, and a run with two adapter teams import { readFileSync } from "node:fs"; import { dirname, join, normalize } from "node:path"; import { recordTable } from "./external/record-table.mjs"; import { notificationChannel } from "./external/notification-channel.mjs"; import { PORTS } from "./open/domain/ports.mjs"; import { recordArrival as closedArrival } from "./closed/application/record-arrival.mjs"; import { recordArrival as openArrival } from "./open/application/record-arrival.mjs"; import { productionEnvironment } from "./open/adapters/production.mjs"; import { testEnvironment, seed, SENT } from "./open/adapters/test.mjs"; const IMPORT = /^import[^"']*["']([^"']+)["']/gm; function closure(root) { const seen = new Set(), stack = [root], edges = []; while (stack.length > 0) { const file = stack.pop(); if (seen.has(file)) continue; seen.add(file); for (const [, path] of readFileSync(file, "utf8").matchAll(IMPORT)) { if (path.startsWith(".") === false) continue; const target = normalize(join(dirname(file), path)); edges.push([file, target]); stack.push(target); } } return { seen, edges }; } for (const [name, root] of [["closed", "closed/domain/delivery.mjs"], ["open", "open/domain/delivery.mjs"]]) { const { seen, edges } = closure(root); const outward = edges.filter(([, target]) => target.startsWith("external/")); console.log(`${name} domain module: import closure = ${seen.size}, direct link to the outside world = ${outward.length}`); for (const [f, t] of edges) console.log(` ${f} -> ${t}`); } const nameCount = Object.values(PORTS).flat().length; console.log(`ports = ${Object.keys(PORTS).length}, names in contract = ${nameCount}`); recordTable.seed("T-K"); recordTable.seed("T-U"); seed("T-S"); const runs = [ ["closed + external modules ", closedArrival("T-K", "34")], ["open + production team ", openArrival("T-U", "34", productionEnvironment)], ["open + test team ", openArrival("T-S", "34", testEnvironment)], ]; for (const [name, s] of runs) console.log(`${name} ${JSON.stringify(s)}`); const fields = runs.map(([, s]) => `${s.state}/${s.passed}`); console.log(`domain fields equal = ${new Set(fields).size === 1}, distinct values = ${new Set(fields).size}`); console.log(`notices sent to external channel = ${notificationChannel.count()}, to test channel = ${SENT.length}`); try { openArrival("T-S", "06", { records: testEnvironment.records }); } catch (error) { console.log(`missing environment: ${error.message}`); }
node link-count.mjs
closed domain module: import closure = 4, direct link to the outside world = 3
closed/domain/delivery.mjs -> external/record-table.mjs
closed/domain/delivery.mjs -> external/tracking-service.mjs
closed/domain/delivery.mjs -> external/notification-channel.mjs
open domain module: import closure = 2, direct link to the outside world = 0
open/domain/delivery.mjs -> open/domain/ports.mjs
ports = 3, names in contract = 5
closed + external modules {"identity":"T-K","number":"V0001","code":"IST-A1","state":"in-transfer","passed":1}
open + production team {"identity":"T-U","number":"V0002","code":"IST-A1","state":"in-transfer","passed":1}
open + test team {"identity":"T-S","number":"S0001","code":"TEST-34","state":"in-transfer","passed":1}
domain fields equal = true, distinct values = 1
notices sent to external channel = 2, to test channel = 1
missing environment: unsatisfied port: tracking.pointCode, tracking.arrivalNumber, notifications.send
Reading the Numbers
The domain module’s import closure dropped from 4 to 2, its direct links to the outside world from 3 to 0. The second number is decisive: in the closed arrangement, loading the aggregate root means loading all three external modules; in the open arrangement, the domain module knows only its own contract file.
Three links turned into three ports and five names. The port count came out equal to the link count; this is not a coincidence — the transformation consists of naming the links. The five names are the surface the outside sees of the domain: the other side only has to provide these five operations, and how many files it has inside is not the domain’s concern.
The three runs’ domain results came out equal: same state, same passed count, 1 distinct value. Only the fields coming from the adapter differ — the arrival number and point code changed by team, because the domain does not produce them. The notice counters confirm this: 2 notices went to the external channel, 1 to the test channel. The same domain file ran with both teams, and not one line of it was touched.
The last line is the contract file’s counterpart at run time. Given an incomplete environment, the three unsatisfied names were reported by name. In the closed arrangement there can be no such error message, because there is no written contract to satisfy; the missing piece shows up as an import error instead.
Counting the Cost
The arrangement is not free. The open arrangement adds 1 file (the contract) plus 1 file per adapter team; with two teams, 3 files total. Every construction of the aggregate root gained one more parameter. This parameter grows as the number of ports grows: in a domain with nine ports, the environment object carries nine fields, and every use case passes it through from end to end.
The gain depends on two conditions. First, a port must genuinely have more than one adapter; a port with a single adapter only adds indirection. Second, the domain module being runnable on its own has to be useful for something. If the domain has no decisions — if the decisions still sit outside — a port is an empty contract; the measurement would show this as the direct link count coming out 0, but the decision point count also coming out 0.
Summary
- A port is the name of a place the domain opens to the outside and the set of operations it expects; an adapter is that set’s implementation, and more than one can exist for the same port.
- Hexagonal architecture introduces no new principle; it arranges boundary drawing, the policy–detail distinction, and the dependency direction so that they put the domain at the center.
- The domain module’s import closure dropped from 4 to 2, and its direct links to the outside world dropped from 3 to 0; three links turned into three ports and five names.
- The same domain file ran with two adapter teams; the three runs’ domain results came out equal, and only the number and code coming from the adapter changed.
- The cost is 3 files and one parameter per construction; the gain arises when a port has more than one adapter and the domain genuinely holds a decision.
Next Step
Every measure up to this point pointed toward writing: which decisions an arrival record passed through, which ports it went out through. The operation desk’s real work, though, is reading — which deliveries are behind their route, how many packages are waiting at which transfer center, which carrier’s delay rate is high. If the answer to these questions is sought through the aggregate roots, dozens of objects are walked for every question, and the walk grows with the number of deliveries. The next lesson takes up keeping the same data in a separate model for reading: it counts the number of objects walked for a query across two arrangements, measures how many commands behind the separate model falls, and writes down, as the cost, the number of files and mappings added in return.
To keep your progress and take notes, Log in
My notes
Log in to take notes.