Here's a Node.js route you've definitely seen, probably yesterday:
app.post("/checkout", async (req, res) => {
const cart = await db.collection("carts").findOne({ _id: req.body.cartId });
if (!cart) return res.status(404).json({ error: "cart not found" });
if (cart.items.length === 0) return res.status(400).json({ error: "empty cart" });
const total = cart.items.reduce((s, i) => s + i.price * i.qty, 0);
const charge = await stripe.charges.create({ amount: total, currency: "usd", source: req.body.token });
await db.collection("orders").insertOne({ cartId: cart._id, total, chargeId: charge.id });
await sendgrid.send({ to: cart.email, subject: "Order placed", text: `Thanks!` });
res.json({ ok: true, chargeId: charge.id });
});
This compiles. It serves traffic. It will also pin you to Express, Mongo, Stripe, and SendGrid for the rest of the codebase's natural life. Swap Mongo for Postgres and you're rewriting checkout. Add a CLI command that places orders for ops and you're copy-pasting the whole thing. Write a unit test and you're spinning up a Mongo replica set in CI.
Hexagonal architecture is the thing that fixes this. It's also the thing people over-apply, over-abstract, and use to turn a 200-line CRUD app into 47 folders. So let's actually talk about what it is, where it earns its keep in Node, and where it'll quietly waste your afternoon.
Where the pattern actually comes from
Alistair Cockburn first sketched hexagonal architecture on the Portland Pattern Repository wiki in the late 90s. He renamed it "Ports and Adapters" in 2005 with a short technical report, and that's the name that stuck in academic write-ups even though "hexagonal" is what everyone says out loud. In April 2024 Cockburn and Juan Manuel Garrido de Paz published a full book on it, which means the pattern is now older than Node.js itself and still being actively rewritten.
The hexagon shape is not load-bearing. Cockburn drew six sides because he wanted something that wasn't a layer cake: a shape with multiple "sides" you could plug things into, none of which was privileged as "top" or "bottom." A pentagon would have worked. A nonagon would have worked. The point is the symmetry: your HTTP layer and your database are the same kind of thing, outside stuff that talks to your core through a hole in the wall.
That single idea, the database is not special, it's just another outside thing, is the whole insight. Everything else follows from it.
Ports are interfaces. Adapters are implementations. That's the entire vocabulary.
A port is a TypeScript interface that your core domain owns and depends on. An adapter is a class outside the core that implements that interface (a "driven" adapter) or that calls into the core (a "driving" adapter).
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
findPendingForUser(userId: UserId): Promise<Order[]>;
}
That file lives inside the core. It mentions zero things from mongodb, zero things from pg, zero things from express. If you grep the file for any npm package name, you should find nothing.
The adapter lives outside:
import { Collection } from "mongodb";
import { OrderRepository } from "@/core/ports/order-repository";
export class MongoOrderRepository implements OrderRepository {
constructor(private readonly orders: Collection) {}
async save(order: Order): Promise<void> {
await this.orders.updateOne(
{ _id: order.id.value },
{ $set: toDoc(order) },
{ upsert: true },
);
}
// ...
}
The arrow of the dependency points into the hexagon. The core does not know Mongo exists. Mongo knows the core exists, because it has to implement the core's interface. This is dependency inversion in one sentence, and it's the only architectural trick the pattern actually uses.

Driving vs driven, and why people get them confused
There are two sides to the hexagon and they behave differently:
- Driving adapters call into the application. HTTP controllers, CLI commands, GraphQL resolvers, message-queue consumers, cron jobs. They're the primary side: the reason your app exists. The application exposes "use case" methods (
placeOrder,cancelOrder,refundOrder); driving adapters translate Express requests or Kafka messages into calls on those use cases. - Driven adapters get called by the application. Repositories, payment gateways, mailers, S3 clients, Redis caches. They're the secondary side: stuff the core needs to do its job. The core declares an interface; the adapter satisfies it.
The trap: people draw the hexagon, label both sides "adapters," and end up with one giant adapters/ folder where the Express controller sits next to the Postgres repository. It works, but you've lost the most useful thing the pattern gave you: the direction of the dependency. A Postgres repository implements a core interface. An Express controller does not implement anything in the core; it consumes the core. They're not the same shape.
A folder structure that keeps this honest:
src/
core/
domain/ # entities, value objects, domain events
use-cases/ # placeOrder, cancelOrder, refundOrder
ports/
driving/ # interfaces the core EXPOSES (often: just use-case classes themselves)
driven/ # interfaces the core REQUIRES (OrderRepository, PaymentGateway, Mailer)
adapters/
driving/
http/ # Express routes
cli/ # commander/yargs commands
worker/ # BullMQ/SQS consumers
driven/
mongo/ # MongoOrderRepository
stripe/ # StripePaymentGateway
sendgrid/ # SendGridMailer
in-memory/ # InMemoryOrderRepository (used by tests AND local dev)
composition/
container.ts # wires everything together: the ONLY file that imports both sides
Notice composition/container.ts. That's the only place in the whole codebase that gets to import from both core/ and adapters/. Everything else either lives in the core (and imports nothing from outside) or lives in an adapter (and imports from the core only). If you enforce this with an ESLint no-restricted-imports rule, you'll catch the violations on day one instead of on day 400.
A real use case, end to end
Let's wire placeOrder properly. Start with the domain:
export class Order {
private constructor(
public readonly id: OrderId,
public readonly userId: UserId,
public readonly lines: OrderLine[],
public status: OrderStatus,
) {}
static place(userId: UserId, lines: OrderLine[]): Order {
if (lines.length === 0) throw new EmptyOrderError();
if (lines.some(l => l.qty <= 0)) throw new InvalidQuantityError();
return new Order(OrderId.new(), userId, lines, "pending");
}
total(): Money {
return this.lines.reduce((sum, l) => sum.plus(l.price.times(l.qty)), Money.zero("USD"));
}
markPaid(): void {
if (this.status !== "pending") throw new InvalidStateError(this.status);
this.status = "paid";
}
}
No HTTP. No database. No Stripe. The Order knows what a valid order looks like and how to compute its own total. That's the business logic, and it lives in one file you can read in 30 seconds.
The use case:
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway,
private readonly mailer: Mailer,
private readonly clock: Clock,
) {}
async execute(input: PlaceOrderInput): Promise<PlaceOrderResult> {
const order = Order.place(input.userId, input.lines);
const charge = await this.payments.charge(order.total(), input.paymentToken);
order.markPaid();
await this.orders.save(order);
await this.mailer.send({
to: input.email,
subject: "Order confirmed",
body: `Order ${order.id.value} placed at ${this.clock.now().toISOString()}`,
});
return { orderId: order.id.value, chargeId: charge.id };
}
}
Four ports injected. No new MongoClient, no import stripe, no await fetch. The use case reads like a description of the business operation, which is the test it should pass.
The driving adapter:
export function placeOrderRoute(placeOrder: PlaceOrder) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await placeOrder.execute({
userId: UserId.from(req.user.id),
lines: req.body.lines.map(parseLine),
paymentToken: req.body.paymentToken,
email: req.user.email,
});
res.status(201).json(result);
} catch (err) {
if (err instanceof EmptyOrderError) return res.status(400).json({ error: "empty_order" });
if (err instanceof PaymentDeclinedError) return res.status(402).json({ error: "payment_declined" });
next(err);
}
};
}
This file's whole job is translation: HTTP request in, use-case input out; domain errors in, HTTP status codes out. It doesn't know how an order is placed. It doesn't want to know.
And the composition root:
const mongo = await MongoClient.connect(env.MONGO_URL);
const placeOrder = new PlaceOrder(
new MongoOrderRepository(mongo.db().collection("orders")),
new StripePaymentGateway(stripe),
new SendGridMailer(sendgrid),
new SystemClock(),
);
app.post("/checkout", placeOrderRoute(placeOrder));
That's it. No DI framework needed. Plain constructors. If you've ever fought with Nest's module system to get a circular dependency to resolve, the lack of a framework here is a feature, not a missing piece.
Why this pays the rent: the tests
The reason senior backend engineers stick with hexagonal even when they don't love the file count is the testing story. With the structure above:
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway({ alwaysApprove: true });
const mailer = new InMemoryMailer();
const clock = new FrozenClock("2026-05-18T12:00:00Z");
const useCase = new PlaceOrder(orders, payments, mailer, clock);
it("places a paid order and sends a confirmation", async () => {
const result = await useCase.execute({
userId: UserId.from("u-1"),
lines: [{ sku: "abc", qty: 2, price: Money.of(10, "USD") }],
paymentToken: "tok_visa",
email: "alice@example.com",
});
const saved = await orders.findById(OrderId.from(result.orderId));
expect(saved?.status).toBe("paid");
expect(payments.lastCharge?.amount.value).toBe(20);
expect(mailer.sent).toHaveLength(1);
});
No Mongo container. No Stripe test mode. No SMTP. The whole suite runs in-process, and a few thousand tests like this finish in seconds because there's no I/O. Cockburn often points to running thousands of tests in seconds, all in one process, as the real payoff, and once you've shipped a codebase that does it, you don't go back.
The thing nobody tells you up front: the in-memory adapter is not just for tests. Run your local dev server with InMemoryOrderRepository and you get a working app with zero infrastructure. New hire clones the repo, runs npm start, and clicks through the UI before they've installed Docker. That alone has shipped products faster than I want to admit.
The gotchas: the part that bites you
Hexagonal looks tidy in a blog post and uglier on day 90. The places where it actually hurts:
Port churn. Every time you add a field to a domain method that needs persistence, you change the port interface, the Mongo adapter, the Postgres adapter you maybe-someday wrote, the in-memory adapter, and every test fixture that builds one. If your domain is still in exploratory mode, where you don't yet know what an Order is, this churn is brutal. Hexagonal rewards stable domains. It punishes thrash.
Transactions across ports. This is the one that quietly defeats most Node implementations. PlaceOrder saves an order and writes a payment record and publishes an event. In a relational DB those want to be one transaction. But your OrderRepository and PaymentRepository are separate ports, and neither knows about the other's BEGIN. The honest answers are all annoying: pass a UnitOfWork port into the use case, use the outbox pattern for the event, accept that some sequences need a single "transaction-scoped repository" adapter. There's no clean version of this in pure hexagonal. Pick your trade-off and document it.
The anemic-domain failure mode. People build the hexagon, move all the entities into core/domain/, then write entities that are just public getters and setters with all the logic still in the use case. That's the same coupled mess as before, just with more folders. The fix is to give entities behaviour: order.markPaid(), not order.status = "paid" set from outside. If your Order class has nothing but getters, you've built an expensive DTO library.
Adapter explosion. "What if we want to switch from Mongo to Postgres?" is the canonical pitch for hexagonal and the canonical lie. You're probably not going to switch. What you will do: run the same use case against an in-memory adapter in tests and Mongo in prod. That alone justifies the pattern. Don't write three database adapters because someone might want one. Write the one you use plus the in-memory one.
Framework fights. Nest gives you DI and module boundaries for free, which is great for hexagonal, except Nest also nudges you toward @Injectable() everywhere and decorators on your domain classes, which leaks framework concerns into the core you were trying to keep pure. The discipline: keep core/ Nest-free, even if it means one extra layer of plain TS classes that Nest wraps. If a Nest decorator lives on an entity, the pattern is already broken.
When hexagonal is not the right call
It's the wrong call when:
- The app is a CRUD admin panel that will live for 18 months and die. You're paying for flexibility you'll never use.
- The team is two people who've never used the pattern. The first three months will be a fight about folder names, not features.
- The domain isn't yet a domain, it's still being discovered. Every iteration changes a port; every port change touches five files. Wait until the shape stabilises.
- The "business logic" really is just
req.body-> SQL -> JSON. There's nothing to isolate.
It's the right call when:
- The same operations need to run from HTTP, a CLI, a queue consumer, and a cron job. Hexagonal collapses that to one use case and four thin adapters.
- The domain has rules that survive multiple infrastructure rewrites. Payment processing. Pricing. Subscription state machines. Anything where the what outlives the how.
- The test suite is the bottleneck on shipping. Pulling the I/O out gives you back orders of magnitude in test speed, which compounds for years.
The pattern is a tool, not a religion. Cockburn himself, in his 2024 revisions, mostly talks about what hexagonal isn't: it isn't a layer cake, it isn't a folder structure, it isn't CQRS, it isn't a license to add three abstractions to every class. It's one idea, applied honestly: the outside world plugs into your core through holes you control. Everything else is taste.
Build the hexagon when you have something worth protecting in the middle. If the middle is empty, the hexagon is just six walls around nothing.




