Ports and Adapters (Hexagonal)
The integration test for our checkout suite took fifty-one seconds to run and failed about one time in six. Not because the code was wrong. Because the code could only be exercised by booting Postgres, pointing a fake SMTP server at a random port, and waiting on a real queue, and any one of those three could hiccup.
Here is the function under all that ceremony. One export, and it reaches out and grabs everything it touches.
// order-service.js. The rule is in here somewhere
import { Pool } from "pg";
import sgMail from "@sendgrid/mail";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
sgMail.setApiKey(process.env.SENDGRID_KEY);
export async function placeOrder(userId, sku, qty) {
const { rows } = await pool.query(
"select stock, price_cents from products where sku = $1",
[sku],
);
const product = rows[0];
if (!product) throw new Error("no_such_product");
if (qty > product.stock) throw new Error("out_of_stock"); // the actual rule
const total = product.price_cents * qty;
await pool.query(
"insert into orders (user_id, sku, qty, total_cents) values ($1,$2,$3,$4)",
[userId, sku, qty, total],
);
await pool.query("update products set stock = stock - $1 where sku = $2", [qty, sku]);
await sgMail.send({ to: userId, subject: "Order confirmed", text: "Thanks!" });
return { ok: true, total };
}
The whole business rule is one line: if (qty > product.stock). That is the thing product managers argue about, the thing that has edge cases, the thing most worth a test. And it is wedged between two SQL calls and a network send, welded to a specific database driver and a specific email vendor, unreachable without all three running. To check “you cannot order five of something we have three of” I had to boot a small data centre.
This is the corner layered architecture starts pulling you out of: it says point your dependencies downward so the domain stops importing the edges. Ports and adapters takes that same instinct and finishes the job. Push the rules to the middle, push everything that touches the outside world to the rim, and make the two talk only through interfaces the middle defines.
Turn the layers inside out
Layered architecture draws a stack and points every arrow down. Hexagonal draws a shape with a middle and points every arrow in. Same rule wearing a different picture, and the different picture buys you something: the database is no longer “the bottom layer” that the domain still, secretly, knows the shape of. It is just one more thing plugged into the edge, no more special than the HTTP server on the other side.
Two things about that shape, since people fixate on the wrong one.
The six sides mean nothing. It is not a hexagon because there are six kinds of anything. Alistair Cockburn, who named this in 2005, drew a polygon instead of a stack precisely so there would be room to hang more than one port on the outside without implying a top-to-bottom ordering. Draw it as a pentagon if you like. The shape is a hint that the edges are peers, not a ladder.
The load-bearing thing is the arrows. Every adapter on the rim points at the core, and the core points at nothing. That single asymmetry is the whole pattern, and everything below is just what it takes to make it true in JavaScript.
A port is a shape the core asks for
A port is an interface. Not the Java kind necessarily, just the answer to “what does the core need the outside world to do for it?” The core needs to look up a product and save an order, so it declares a port for that. It needs to tell someone an order went through, so it declares another. The port is written in the core’s vocabulary, using the core’s names, and it says nothing about how the job actually gets done.
In TypeScript that is literally an interface. Naming it is the entire act.
// core/ports.ts. Interfaces the core OWNS, in the core's own words
export interface OrderRepository {
findProduct(sku: string): Promise<Product | null>;
saveOrder(order: Order): Promise<void>;
decrementStock(sku: string, qty: number): Promise<void>;
}
export interface Notifier {
orderConfirmed(userId: string, order: Order): Promise<void>;
}
Notice what those signatures refuse to say. No SELECT, no connection, no Pool. No to: and subject:, nothing that smells of email. orderConfirmed is an intention, not a mechanism. Whether that becomes an email, a push notification, a row in an outbox, or a no-op in a test is somebody else’s problem, out at the rim.
Now the core use case. It takes its ports in, uses them, and imports nothing from the edge. Compare it line for line with the tangle at the top: same rule, but nothing around it knows what a database is.
// core/place-order.js. Depends only on ports: no pg, no email SDK, no process.env.
import { DomainError } from "./errors.js";
export function makePlaceOrder({ orders, notifier }) {
return async function placeOrder({ userId, sku, qty }) {
const product = await orders.findProduct(sku);
if (!product) throw new DomainError("no_such_product");
if (qty > product.stock) throw new DomainError("out_of_stock"); // the rule, in the open
const order = { userId, sku, qty, totalCents: product.priceCents * qty };
await orders.saveOrder(order);
await orders.decrementStock(sku, qty);
await notifier.orderConfirmed(userId, order);
return order;
};
}
Handing the ports in through makePlaceOrder({ orders, notifier }) is just dependency injection, the closure form. That is not incidental. Ports and adapters is what DI is for at the scale of a whole application: the seam that made one service testable, applied as the organizing rule for the entire codebase.
The adapters are where the real world lives. Each one implements a port for one specific technology, and each one is allowed to be as messy as the technology demands, because the mess stops at its own door.
// adapters/postgres-orders.js. A driven adapter that IS an OrderRepository
export function makePostgresOrders(pool) {
return {
async findProduct(sku) {
const { rows } = await pool.query(
"select sku, stock, price_cents from products where sku = $1",
[sku],
);
const r = rows[0];
// map the DB row into the core's language, right here at the edge
return r ? { sku: r.sku, stock: r.stock, priceCents: r.price_cents } : null;
},
async saveOrder(order) {
await pool.query(
"insert into orders (user_id, sku, qty, total_cents) values ($1,$2,$3,$4)",
[order.userId, order.sku, order.qty, order.totalCents],
);
},
async decrementStock(sku, qty) {
await pool.query("update products set stock = stock - $1 where sku = $2", [qty, sku]);
},
};
}
And the fake, which is not a testing afterthought but a first-class adapter behind the exact same port. It is backed by a Map and it is thirty lines shorter.
// adapters/memory-orders.js. The same port, no database in sight
export function makeMemoryOrders(seed = []) {
const products = new Map(seed.map((p) => [p.sku, { ...p }]));
const saved = [];
return {
async findProduct(sku) {
return products.get(sku) ?? null;
},
async saveOrder(order) {
saved.push(order);
},
async decrementStock(sku, qty) {
products.get(sku).stock -= qty;
},
saved, // a test can read this to assert what was written
};
}
The composition root is the one file allowed to name concrete things. It builds the real adapters and pours them into the core, and it is the only place pg and the email vendor appear anywhere in the app.
// main.js. The only file that knows Postgres and the email vendor exist
import { Pool } from "pg";
import { makePostgresOrders } from "./adapters/postgres-orders.js";
import { makeEmailNotifier } from "./adapters/email-notifier.js";
import { makePlaceOrder } from "./core/place-order.js";
import { startHttp } from "./adapters/http.js";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const placeOrder = makePlaceOrder({
orders: makePostgresOrders(pool),
notifier: makeEmailNotifier(process.env.SENDGRID_KEY),
});
startHttp({ placeOrder });
Grep the core folder for pg or @sendgrid and you get nothing. That absence is the product. The rule you care about now lives somewhere a test can reach without a network.
Two kinds of ports, and they are not symmetric
Look again at the hexagon and you will see the adapters split into two camps that behave differently. The split matters more than the count.
Driving ports (also called primary, or inbound). These are how the outside world pokes the core. An HTTP request, a CLI command, a cron tick, a test runner. The adapter on this side calls the core. The core exposes a use case; the driving adapter invokes it.
Driven ports (secondary, or outbound). These are how the core reaches back out for things it needs. A repository, a notifier, a payment gateway, a clock. The core calls these. The adapter on this side gets called.
Same word, “port”, opposite direction of call. The driving adapter drives; the driven adapter is driven.
The HTTP adapter is worth seeing, because it is the part people forget is an adapter at all. Your Express routes are not “the app”. They are a driving adapter that translates an HTTP request into a use-case call and translates the result back into a response. Swap them for a GraphQL resolver or a gRPC handler and the core never knows.
// adapters/http.js. A DRIVING adapter. Express is a detail; the core is the point.
export function startHttp({ placeOrder }) {
app.post("/orders", async (req, res, next) => {
try {
const order = await placeOrder({
userId: req.user.id,
sku: req.body.sku,
qty: req.body.qty,
});
res.status(201).json({ sku: order.sku, qty: order.qty });
} catch (err) {
next(err); // one place maps DomainError codes to HTTP status
}
});
}
That handler reads a request, calls one function, and shapes a response. Everything it knows about ordering fits in the word placeOrder. This is the thin controller from layered architecture, now correctly filed as an adapter rather than a layer, and the same error-mapping trick from the onion model turns out_of_stock into a 409 in exactly one spot.
The dependency rule, and who owns the interface
Here is the part that trips everyone, because the arrows do something that looks backwards at first.
For a driven port, the core calls the adapter at runtime: orders.saveOrder(...). So you would expect the core to depend on the Postgres adapter. It does not. The core depends on the OrderRepository interface, and that interface lives in the core. The Postgres adapter is the one that reaches across the boundary, imports the port, and declares “I am one of these.” The source-code dependency and the runtime call point in opposite directions.
This is dependency inversion, the actual thing, not the framework annotation people mean when they say it. The trick is ownership. Put the interface in the core, and any adapter that wants to satisfy it has to reach toward the core to get the definition. The dependency graph is now shaped so that a hurricane at the edge, a new database, a swapped vendor, a rewritten HTTP layer, cannot blow inward, because nothing inward is looking outward.
The whole point: swap an edge, touch nothing inside
Now the reward. Because every adapter hides behind a port the core owns, you can unplug one and plug in another, and the core does not notice. Tests are the everyday case. Production migrations are the dramatic one.
The stock rule that needed a data centre at the top of this article is now this, and it runs in under a millisecond with nothing booted:
import { test, expect } from "vitest";
import { makePlaceOrder } from "../core/place-order.js";
import { makeMemoryOrders } from "../adapters/memory-orders.js";
test("an order beyond stock is rejected, no infrastructure", async () => {
const orders = makeMemoryOrders([{ sku: "TSHIRT", stock: 3, priceCents: 1999 }]);
const notifier = { orderConfirmed: async () => {} };
const placeOrder = makePlaceOrder({ orders, notifier });
await expect(placeOrder({ userId: "u1", sku: "TSHIRT", qty: 5 }))
.rejects.toThrow("out_of_stock");
});
test("a valid order is saved and the customer is told", async () => {
const orders = makeMemoryOrders([{ sku: "TSHIRT", stock: 3, priceCents: 1999 }]);
const sent = [];
const notifier = { orderConfirmed: async (u, o) => sent.push(o) };
const placeOrder = makePlaceOrder({ orders, notifier });
await placeOrder({ userId: "u1", sku: "TSHIRT", qty: 2 });
expect(orders.saved).toHaveLength(1);
expect(sent).toHaveLength(1);
});
That fake notifier, { orderConfirmed: async (u, o) => sent.push(o) }, is a driven adapter you wrote inline in the test. It is not a mock in the ceremonial sense. It is the real port with a two-line body, and it lets you assert “the customer got told” without an email server drawing breath. See testing for what to do with a seam this clean once you have one.
The production version of the same move is the one that saves a quarter. “We are moving off this database.” If forty files spoke SQL, that is a forty-file rewrite. Here it is one new adapter that satisfies OrderRepository, one line in main.js, and the core stays shut. Uniform ports are also exactly what make fallbacks and routing possible: two adapters behind one port, and something in the middle choosing.
Flip the adapter yourself. The core makePlaceOrder below is fixed, shown so you can watch it not change. Swap the repository under it between an in-memory Map and a flaky database that is slow and sometimes drops the connection, and place a few orders. The domain rule fires identically on both. Only the edge behaves differently.
Two things to try. Set the quantity to 7 and place it on both adapters: the core rejects out_of_stock instantly on each, before any I/O happens, because that decision lives above the port. Then set it to 2 and run the flaky adapter a few times: same core, same call, but the edge is slow and occasionally surfaces ECONNRESET. The behaviour that differs is all at the rim. The rule never moved.
The anti-corruption layer at the edge
Real external systems have opinions, and their opinions are ugly. A shipping provider’s API returns { trk_no, st: "IT", addr_ln_1 }. A legacy billing system calls a customer a “party” and an order a “transaction header”. If those names leak into your core, your domain slowly starts speaking the vendor’s dialect, and you are now coupled to a system you do not control at the level of vocabulary.
The adapter is where you stop that. An adapter that does this deliberately, translating a foreign model into your core’s language and refusing to let the foreign terms cross, is called an anti-corruption layer. The name is grand; the job is a mapping function.
// adapters/shipping-acme.js. An anti-corruption layer around a vendor we do not control
const STATUS = { IT: "in_transit", DL: "delivered", RT: "returned" };
export function makeAcmeShipping(client) {
return {
// the core asked for a Shipment. It never hears the words "trk_no" or "st".
async track(orderId) {
const raw = await client.getTracking({ ref: orderId }); // their shape, their names
return {
trackingNumber: raw.trk_no,
status: STATUS[raw.st] ?? "unknown",
address: { line1: raw.addr_ln_1, city: raw.city },
};
},
};
}
The core sees a clean Shipment. When ACME renames a field or a competitor’s API replaces theirs entirely, one file changes and the domain does not flinch. This is the adapter pattern doing exactly its job, just placed on purpose at an architectural boundary. Ports and adapters is, in a sense, the adapter pattern promoted to the organizing principle of the whole app: the edge is made of adapters.
It is the same idea as clean and onion architecture
If you have read about “clean architecture” or “onion architecture” and wondered how they differ from this, the honest answer is: barely. They are siblings, and they all insist on the one rule, dependencies point inward, business logic depends on nothing external.
The relationship to layered architecture is worth stating plainly too, since that is the pattern most teams already have. Layers point down and the database sits at the bottom, still a little privileged. Hexagonal takes the bottom layer and the top layer and treats them the same: both are just adapters plugged into the rim. If your layered app already put the domain in a folder that imports neither Express nor your ORM, you are most of the way here. Ports and adapters is the name for finishing the turn.
The honest cost, and when to skip it
This is real overhead and I am not going to pretend otherwise.
Every driven port is an interface to define, an adapter to write, and a mapping between the adapter’s shape and the core’s shape. That mapping is genuine work and a genuine place for bugs. Your “create an order” flow now spans a use case, a port, an adapter, and a composition root instead of one function. On a domain with real rules and a long life ahead of it, that structure pays for itself many times over, in tests you can actually write and edges you can actually swap. On a thin CRUD service where the table is the resource and the “business logic” is validation plus a SELECT, it is pure ceremony, and wrapping a two-endpoint admin panel in ports and adapters is the kind of thing that gives patterns a bad name.
There is an over-engineered version of this pattern too, and it is common enough to name. A port with exactly one adapter that will never have a second, wrapped in an interface anyway “for flexibility”, is a false promise: a seam that says “something can be swapped here” when nothing ever will be. If your Notifier has one implementation and always will, the port earns its keep the moment a test wants a fake, and not before. The what a pattern actually is argument applies in full here. Add each port when a real second thing shows up, whether that second thing is a fake in a test or a new vendor in production. Do not build the whole hexagon on aspiration.
My rule of thumb: reach for ports and adapters when the domain is the hard part of the app. If the interesting complexity is the business rules, put them in a core and defend it. If the interesting complexity is just moving rows between a table and a JSON response, layered architecture or even a set of tidy handlers is the right amount of structure, and the hexagon is a costume.
Summary
- Ports and adapters (hexagonal) puts business rules in a core and pushes everything that touches the outside world (database, HTTP, email, queues) to the rim as interchangeable adapters. It is layered architecture taken to its conclusion: the database stops being a privileged bottom layer and becomes just another plug.
- A port is an interface the core owns, written in the core’s own vocabulary. In TypeScript it is an
interface; in plain JavaScript it is a duck-typed shape. The core declares what it needs; the port says nothing about how. - Adapters implement ports for a specific technology. The Postgres adapter and the in-memory fake satisfy the same
OrderRepository; the core cannot tell them apart, which is the entire point. - Two kinds of ports. Driving (primary, inbound) adapters call the core, like an HTTP route or a CLI. Driven (secondary, outbound) ports are called by the core, like a repository or notifier. Same word, opposite call direction.
- The dependency rule is the mechanism: every source-code arrow points inward. For a driven port the core calls the adapter at runtime, yet the adapter depends on the core, because the interface lives in the core. That inversion is what keeps a storm at the edge from reaching the middle.
- The payoff is a core testable with zero infrastructure (swap real adapters for in-memory fakes, which is just dependency injection at app scale) and swappable edges (a new database is a new adapter and one line in the composition root, not a rewrite).
- An anti-corruption layer is an adapter that translates a messy external model into the core’s language so the vendor’s vocabulary never leaks inward. It is the adapter pattern placed deliberately at a boundary.
- Clean and onion architecture are the same idea with a different diagram and the same one rule. Pick the picture that explains it best and move on.
- The honest cost is indirection and mapping, real work and a real place for bugs. It pays back on a domain-rich, long-lived app and is overkill for a thin CRUD service where the table is the resource.
- When to use: the domain is the hard part, you have rules worth testing in isolation, and you expect to swap or fake an edge. When to avoid: small, short-lived, or mostly-plumbing services, and any port that will only ever have one implementation and no test fake.