Hexagonal Architecture in Node
Hexagonal architecture (ports and adapters) keeps your business rules at the center and pushes frameworks, databases, and queues to the edges. In Node.js, that means Express routes and Prisma clients never leak into domain code.
Recipe
Quick-reference recipe card - copy-paste ready.
// domain/ports/notification-port.ts
export interface NotificationPort {
send(to: string, body: string): Promise<void>;
}
// application/place-order.ts
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly notifications: NotificationPort
) {}
async execute(input: { customerId: string; sku: string }) {
const order = { id: crypto.randomUUID(), ...input };
await this.orders.save(order);
await this.notifications.send(input.customerId, `Order ${order.id} placed`);
return order;
}
}When to reach for this:
- You need to swap Postgres for DynamoDB or Express for Fastify without rewriting business logic
- Tests must run in milliseconds without spinning up HTTP or databases
- Multiple entry points (HTTP, CLI, queue consumer) share the same use cases
- Brownfield services grew framework-coupled and need a migration path
Working Example
// src/modules/billing/domain/ports/invoice-repository.ts
export type Invoice = { id: string; customerId: string; amountCents: number };
export interface InvoiceRepository {
save(invoice: Invoice): Promise<void>;
}
// src/modules/billing/domain/ports/payment-gateway.ts
export interface PaymentGateway {
charge(customerId: string, amountCents: number): Promise<{ chargeId: string }>;
}
// src/modules/billing/application/charge-invoice.ts
export class ChargeInvoice {
constructor(
private readonly invoices: InvoiceRepository,
private readonly payments: PaymentGateway
) {}
async execute(invoiceId: string) {
// load, charge, persist - no HTTP types here
const invoice = { id: invoiceId, customerId: "c1", amountCents: 5000 };
const { chargeId } = await this.payments.charge(invoice.customerId, invoice.amountCents);
await this.invoices.save({ ...invoice, id: chargeId });
return { invoiceId, chargeId };
}
}
// src/modules/billing/infrastructure/http/routes.ts
import express from "express";
import type { ChargeInvoice } from "../../application/charge-invoice";
export function billingRouter(chargeInvoice: ChargeInvoice) {
const router = express.Router();
router.post("/invoices/:id/charge", async (req, res, next) => {
try {
const result = await chargeInvoice.execute(req.params.id);
res.json({ data: result });
} catch (err) {
next(err);
}
});
return router;
}
// src/modules/billing/infrastructure/stripe-payment-gateway.ts
import type { PaymentGateway } from "../../domain/ports/payment-gateway";
export class StripePaymentGateway implements PaymentGateway {
async charge(customerId: string, amountCents: number) {
return { chargeId: `ch_${customerId}_${amountCents}` };
}
}
// src/main.ts - composition root
import express from "express";
import { ChargeInvoice } from "./modules/billing/application/charge-invoice";
import { billingRouter } from "./modules/billing/infrastructure/http/routes";
import { StripePaymentGateway } from "./modules/billing/infrastructure/stripe-payment-gateway";
const chargeInvoice = new ChargeInvoice(
{ save: async () => {} },
new StripePaymentGateway()
);
const app = express();
app.use(express.json());
app.use("/billing", billingRouter(chargeInvoice));
app.listen(3000);What this demonstrates:
ChargeInvoicedepends only on port interfaces, not Stripe or Express- HTTP adapter is a thin translation layer with injected use case
- Composition root in
main.tsis the single place that picks concrete adapters - Adding a queue consumer reuses
ChargeInvoicewith the same ports
Deep Dive
How It Works
- Domain holds entities, value objects, and port interfaces (outbound dependencies as abstractions)
- Application orchestrates use cases; one class or function per user story
- Infrastructure implements ports: HTTP routes, Prisma repos, S3 clients, BullMQ workers
- Driving adapters (HTTP, CLI) call into application services; driven adapters (DB, email) are called through ports
- Dependency direction always points inward: infrastructure depends on application/domain, never the reverse
Folder Layout
| Folder | Contains | Must not import |
|---|---|---|
domain/ | Entities, domain errors, port interfaces | express, fastify, prisma, ioredis |
application/ | Use cases, DTOs between layers | HTTP request types, ORM clients |
infrastructure/http/ | Routes, controllers, middleware | Other modules' domain |
infrastructure/persistence/ | Repository adapters | Application use case internals |
main.ts | Wiring, server boot | Business rules |
src/modules/billing/
├── domain/
│ ├── invoice.ts
│ └── ports/
│ ├── invoice-repository.ts
│ └── payment-gateway.ts
├── application/
│ └── charge-invoice.ts
└── infrastructure/
├── http/routes.ts
├── postgres-invoice-repository.ts
└── stripe-payment-gateway.tsTypeScript Notes
// Prefer small port interfaces - easier to fake in tests
export interface Clock {
now(): Date;
}
export class ExpireSessions {
constructor(private readonly clock: Clock) {}
isExpired(createdAt: Date, ttlMs: number): boolean {
return this.clock.now().getTime() - createdAt.getTime() > ttlMs;
}
}
// Test double
const fixedClock: Clock = { now: () => new Date("2026-01-01T00:00:00Z") };Gotchas
- Fat controllers pretending to be adapters - Routes with 80 lines of business logic are not hexagonal. Fix: Extract a use case; leave the route at 5-10 lines.
- Ports that mirror ORM shapes -
save(prismaOrder)couples domain to Prisma. Fix: Map between domain entities and persistence DTOs inside the adapter only. - One giant composition root -
main.tsbecomes 400 lines of wiring. Fix: Per-moduleregisterBillingModule()factories that return routers and services. - Skipping in-memory adapters - Teams only write Postgres adapters and skip fast tests. Fix: Ship
InMemoryInvoiceRepositoryalongside the real one. - Hexagonal ceremony on a 3-route API - Three endpoints and one developer do not need four folders. Fix: Start with domain + routes; extract ports when a second adapter appears.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Layered MVC (controllers/services/repos) | Small CRUD APIs, junior-heavy teams | Multiple entry points or frequent infra swaps |
| NestJS modules with DI | Team already standardized on Nest 11 | Lightweight Fastify services where decorators feel heavy |
| Transaction script in route handlers | Spikes and prototypes under a week | Production code expected to live 12+ months |
| Clean Architecture (same idea, more layers) | Large domains with complex rules | Simple BFFs with no domain logic |
FAQs
What is the difference between a port and an adapter?
A port is an interface your application defines (NotificationPort). An adapter is the concrete implementation that talks to the real world (SendGridNotificationAdapter, InMemoryNotificationAdapter).
Does hexagonal architecture require a class per use case?
No. A function chargeInvoice(deps, input) works if dependencies are passed explicitly. Classes help when use cases carry state or you use a DI container.
Where does Zod validation belong?
At the HTTP adapter boundary. Parse req.body into a typed DTO, then pass plain objects into the use case. Domain validation covers invariants Zod cannot express (e.g., "invoice must not be double-charged").
Can I use hexagonal layout with NestJS?
Yes. Nest providers implement ports; controllers are driving adapters. Keep domain folders free of @Injectable() if you want framework-free unit tests.
How do I share ports across modules?
Prefer module-local ports. If two modules need the same abstraction, move the port to shared/ports/ only when a second consumer exists - avoid premature shared kernels.
Should queue workers be adapters?
Yes. A BullMQ consumer is a driving adapter that deserializes a job payload and calls the same use case as your HTTP route.
How many ports per module is too many?
If every external call has its own port, you may be over-abstracting. Start with repositories and gateways you expect to swap or fake in tests.
Does this work with Prisma?
Wrap Prisma in a repository adapter. Never export PrismaClient from domain or application layers.
How do I migrate a god-service file incrementally?
Extract one use case and one port at a time. Leave legacy routes calling old code until the new path is tested, then delete the old block.
Is hexagonal the same as DDD?
Related but not identical. Hexagonal is about dependency direction; DDD adds bounded contexts, aggregates, and ubiquitous language. You can use hexagonal without full DDD.
Related
- Architecture Basics - modular monolith starting point
- Modular Monolith - package-by-feature boundaries
- Use Cases & Services - application layer patterns
- Repository Pattern - swapping Prisma for in-memory tests
- Dependency Injection Patterns - wiring adapters
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.