Node.js Hexagonal Architecture: Ports & Adapters Guide 2026
If your Node.js codebase reaches the point where a small change in Postgres queries forces edits across controllers, services, and tests, your architecture is leaking infrastructure into the business logic. Hexagonal architecture — also called Ports & Adapters — fixes this by drawing a hard boundary around the domain so the rest of the system plugs in around it. In 2026 it's the architecture pattern most consistently asked about in senior Node.js interviews, and the one most teams adopt after their first painful database migration.
This guide walks through the pattern from first principles: what ports and adapters actually are, the file layout that works in production TypeScript projects, a real example with Express + Postgres + Kafka, the test strategy that comes for free, and the practical trade-offs you'll hit on the way. Every example is runnable and matches what we look for when vetting senior engineers.
Why hexagonal architecture matters for Node.js in 2026
Node.js applications have a tendency to grow outward from a single Express handler. The handler calls a service, the service calls Knex or Prisma directly, and within months your business rules and your database schema are two sides of the same object. Hexagonal architecture inverts that by making the domain a self-contained module that knows nothing about HTTP, Postgres, Kafka or any other runtime concern.
The core idea: dependencies point inward
Picture a hexagon. Inside it lives your domain — entities, value objects, and use cases written in plain TypeScript. Outside it live adapters — code that talks to the outside world. Between them sit ports: TypeScript interfaces the domain owns, that adapters implement. The domain depends on ports it defines; adapters depend on ports the domain exposes. Nothing in the domain ever imports from an adapter. That single rule is what makes refactors cheap, tests fast, and teams productive.
What changed in 2026
Three things made hexagonal especially relevant this year. First, native TypeScript execution in Node.js 24+ removed the build-step friction that made layered projects feel heavier. Second, the rise of Drizzle, Kysely, and Prisma 6 means you'll likely switch ORMs at least once per product lifecycle — and hexagonal makes that swap take hours instead of weeks. Third, AI-assisted refactors work dramatically better on codebases where modules have explicit interfaces, because the model can reason about each port in isolation.

Ports and adapters explained with TypeScript
The two key concepts are easy to confuse. A port is a TypeScript interface that lives inside the domain layer; it describes a capability the domain needs from the outside world, or one it offers to the outside. An adapter is the concrete implementation that lives in the infrastructure layer.
Driving (inbound) vs driven (outbound) ports
Driving ports are the ways the outside world invokes the domain — typically your use cases. An HTTP adapter, a CLI adapter, or a Kafka consumer adapter all call into a driving port. Driven ports are the capabilities the domain requires — a UserRepository to load users, an EmailSender to dispatch confirmations, an EventPublisher to emit domain events. Adapters implement the driven ports.
A minimal example — the OrderService use case
The use case below is the entire domain. Notice it has no Express, no Prisma, no AWS SDK — only TypeScript interfaces and pure logic. It is the kind of code you can unit test with zero mocks and reason about in code review without scrolling.
// src/domain/order/ports.ts
export interface OrderRepo {
save(order: Order): Promise<void>
findById(id: string): Promise<Order | null>
}
export interface PaymentGateway {
charge(amount: number, currency: string, customerId: string): Promise<{ chargeId: string }>
}
export interface EventPublisher {
publish(event: DomainEvent): Promise<void>
}
// src/domain/order/order.ts
export class Order {
constructor(public readonly id: string, public readonly items: LineItem[]) {}
total(): number { return this.items.reduce((s, i) => s + i.price * i.qty, 0) }
}
// src/domain/order/place-order.ts
export class PlaceOrder {
constructor(
private readonly orders: OrderRepo,
private readonly payments: PaymentGateway,
private readonly events: EventPublisher,
) {}
async execute(input: PlaceOrderInput): Promise<{ orderId: string }> {
const order = new Order(crypto.randomUUID(), input.items)
const { chargeId } = await this.payments.charge(order.total(), 'USD', input.customerId)
await this.orders.save(order)
await this.events.publish({ type: 'OrderPlaced', orderId: order.id, chargeId })
return { orderId: order.id }
}
}Folder layout that scales past 100 modules
There's no canonical hexagonal layout, but after auditing dozens of Node.js codebases for the HireNodeJS vetting process, the structure below holds up consistently. The top-level split is the most important: domain, application, infrastructure, interfaces.
Recommended top-level structure
src/
├── domain/ # pure TypeScript — entities, value objects, ports
│ ├── order/
│ │ ├── order.ts # entity
│ │ ├── ports.ts # OrderRepo, PaymentGateway, EventPublisher
│ │ └── place-order.ts # use case
│ └── user/
├── application/ # orchestrates multiple use cases, transactions
│ └── order-service.ts
├── infrastructure/ # adapters — implement domain ports
│ ├── persistence/
│ │ ├── postgres-order-repo.ts
│ │ └── drizzle-schema.ts
│ ├── payments/
│ │ └── stripe-payment-gateway.ts
│ └── messaging/
│ └── kafka-event-publisher.ts
├── interfaces/ # driving adapters — HTTP, CLI, queue consumers
│ ├── http/
│ │ ├── server.ts
│ │ └── routes/orders.ts
│ └── cli/
└── composition-root.ts # wires adapters into use casesComposition root — the only place the wiring happens
The composition root is the single file (or tiny module) where concrete adapters are instantiated and passed to use cases. Nothing else in the codebase calls a constructor like new PostgresOrderRepo(). This is what makes swapping infrastructure a one-file change.
// src/composition-root.ts
import { PlaceOrder } from './domain/order/place-order'
import { PostgresOrderRepo } from './infrastructure/persistence/postgres-order-repo'
import { StripePaymentGateway } from './infrastructure/payments/stripe-payment-gateway'
import { KafkaEventPublisher } from './infrastructure/messaging/kafka-event-publisher'
import { createDbPool } from './infrastructure/persistence/db'
import { createKafkaProducer } from './infrastructure/messaging/kafka'
export async function compose(env: Env) {
const db = await createDbPool(env.DATABASE_URL)
const kafka = await createKafkaProducer(env.KAFKA_BROKERS)
const orderRepo = new PostgresOrderRepo(db)
const payments = new StripePaymentGateway(env.STRIPE_KEY)
const events = new KafkaEventPublisher(kafka)
return {
placeOrder: new PlaceOrder(orderRepo, payments, events),
}
}
Testing strategy: why hexagonal makes tests fast and meaningful
Most Node.js test suites are slow not because of test logic but because they spin up Postgres, Kafka, or Redis to exercise a service that should be a pure function. Hexagonal removes that pressure: the domain becomes pure, and you write in-memory fakes for the ports.
Hire Pre-Vetted Node.js Developers
Skip the months-long search. Our exclusive talent network has senior Node.js experts ready to join your team in 48 hours.
Fakes vs mocks vs stubs
A fake is a working implementation kept simple enough for tests — a Map-backed OrderRepo, an array-backed EventPublisher. Because ports are domain interfaces, you write the fakes once and reuse them across every use-case test. The result: thousands of unit tests running in under a second, no Docker, no fixtures.
// tests/fakes/in-memory-order-repo.ts
import { Order, OrderRepo } from '../../src/domain/order/ports'
export class InMemoryOrderRepo implements OrderRepo {
private store = new Map<string, Order>()
async save(o: Order) { this.store.set(o.id, o) }
async findById(id: string) { return this.store.get(id) ?? null }
}
// tests/place-order.test.ts
import { PlaceOrder } from '../src/domain/order/place-order'
import { InMemoryOrderRepo } from './fakes/in-memory-order-repo'
import { FakePaymentGateway, RecordingEventPublisher } from './fakes'
import { test, expect } from 'vitest'
test('places an order and publishes OrderPlaced', async () => {
const repo = new InMemoryOrderRepo()
const pay = new FakePaymentGateway({ chargeId: 'ch_1' })
const evts = new RecordingEventPublisher()
const useCase = new PlaceOrder(repo, pay, evts)
const { orderId } = await useCase.execute({
customerId: 'cust_42',
items: [{ sku: 'A', price: 1000, qty: 2 }]
})
expect(await repo.findById(orderId)).not.toBeNull()
expect(evts.events[0].type).toBe('OrderPlaced')
expect(pay.lastChargeAmount).toBe(2000)
})Common mistakes when adopting hexagonal in Node.js
Putting Prisma types in domain entities
It happens fast: someone imports type Order from @prisma/client and uses it as the domain entity. Suddenly the domain knows about Prisma, schema migrations affect business logic, and the boundary is gone. The fix is to keep domain entities as plain TypeScript classes and translate to/from persistence models inside the adapter.
Treating use cases as pass-through controllers
A use case is not a controller that forwards arguments to a repository. It owns the orchestration: validations, invariants, transaction boundaries, and which events to publish. If your use case is two lines that calls repo.save(input), you don't yet have a domain — and your test suite will reflect that.
Building hexagonal for a CRUD app
Hexagonal pays off when there's meaningful domain logic. For an admin dashboard that maps HTTP verbs to SQL CRUD operations, a thin layered approach is simpler and faster. The signal that hexagonal is worth it: you have invariants, workflows, or domain events that don't map cleanly to single tables.
Migration playbook: from layered Express to hexagonal in 6 steps
If you already have a layered Express + Sequelize app, the path to hexagonal is incremental and low-risk. Most teams complete the migration in four to six weeks for a single bounded context, working alongside feature development.
The six concrete steps
Step 1 — Identify one bounded context with the worst coupling and freeze new features in it for one sprint. Step 2 — Extract the domain entities and value objects into src/domain, removing all ORM decorators. Step 3 — Define driven ports for everything the domain currently calls directly (DB, message bus, external APIs). Step 4 — Wrap existing infrastructure code into adapter classes that implement those ports. Step 5 — Move orchestration into use case classes; route Express handlers through them via the composition root. Step 6 — Build the unit test suite with fakes and add a CI rule that blocks imports from infrastructure into domain.
Tooling that helps the boundary stay clean
Once the layout is in place, use eslint-plugin-boundaries or dependency-cruiser to enforce that nothing in src/domain imports from src/infrastructure. Hook the rule into pre-commit and CI. Pair this with strict TypeScript and Zod validation at the adapter boundary so invalid data never reaches the domain. If you're standing up a new Node.js team to drive this kind of work, HireNodeJS can supply senior backend engineers with hexagonal experience in 48 hours.
Hire Expert Node.js Developers — Ready in 48 Hours
Building the right system is only half the battle — you need the right engineers to build it. HireNodeJS.com specialises exclusively in Node.js talent: every developer is pre-vetted on hexagonal and clean architecture, TypeScript-first codebases, event-driven systems, and production deployments.
Unlike generalist platforms, our curated pool means you speak only to engineers who live and breathe Node.js. Most clients have their first developer working within 48 hours of getting in touch. Engagements start as short-term contracts and can convert to full-time hires with zero placement fee. For architecture-heavy projects we can match you with senior engineers comfortable with NestJS, TypeScript strict mode, and refactors at scale.
If you need someone who can refactor a Sequelize-tangled backend without breaking production, browse our backend developers, or jump straight into the TypeScript-focused engineers if your codebase is already TypeScript-first. Both pools include engineers who've shipped hexagonal architectures in production at companies with 100M+ requests per day.
Conclusion: when hexagonal is the right call
Hexagonal architecture is not a fashion statement; it is a deliberate trade. You accept a little more upfront structure — interfaces, a composition root, explicit fakes — in exchange for a domain that survives every infrastructure change, a test suite that finishes in seconds, and a codebase new engineers can navigate in their first week. For Node.js services with meaningful business logic, those trades are almost always worth it in 2026.
Start small. Pick one painful module, draw the boundary, write the ports, and let the wins compound. The teams shipping the fastest in 2026 aren't the ones writing the most code; they're the ones whose architecture keeps getting out of the way.
Frequently Asked Questions
What is hexagonal architecture in Node.js?
Hexagonal architecture (also called Ports & Adapters) is a pattern that isolates your Node.js domain logic from infrastructure like databases, HTTP frameworks, and message brokers. The domain defines TypeScript interfaces (ports), and infrastructure code implements those interfaces (adapters), so you can swap Postgres for Aurora, or Express for Fastify, without touching business logic.
Is hexagonal architecture the same as clean architecture?
They share the same dependency-inversion principle but differ in layer count and terminology. Clean Architecture, popularised by Robert C. Martin, has four concentric layers (entities, use cases, interface adapters, frameworks). Hexagonal collapses those into domain + ports + adapters. Hexagonal is simpler and a better starting point for most Node.js teams.
Does hexagonal architecture work with Express, NestJS, or Fastify?
Yes. The HTTP framework becomes a driving adapter that calls into your use cases. Express, NestJS, and Fastify all fit naturally — NestJS modules even map cleanly onto the composition root pattern. The domain code is identical regardless of which framework you choose.
When should I NOT use hexagonal architecture?
Skip it for CRUD-only services, admin dashboards, or one-off scripts where business logic is minimal. The overhead of ports, adapters, and a composition root only pays off when you have meaningful invariants, workflows, or domain events worth isolating.
How long does it take to migrate a Node.js codebase to hexagonal?
For a single bounded context (e.g. orders, billing, onboarding), most teams finish in 4–6 weeks alongside feature work. Doing the entire monolith at once is a bad idea — pick the most painful module, isolate it, and let the wins compound across the codebase.
What tools enforce the hexagonal boundary in TypeScript?
Use eslint-plugin-boundaries or dependency-cruiser to block imports from infrastructure into domain, run it in CI, and pair with TypeScript strict mode + Zod validation at adapter edges so invalid data never reaches the domain.
Vivek Singh is the founder of Witarist and HireNodeJS.com — a platform connecting companies with pre-vetted Node.js developers. With years of experience scaling engineering teams, Vivek shares insights on hiring, tech talent, and building with Node.js.
Need a Node.js architect who has shipped hexagonal in production?
HireNodeJS connects you with senior backend engineers fluent in hexagonal & clean architecture, TypeScript strict mode, and event-driven systems — ready to start within 48 hours.
