Node.js clean architecture in 2026 cover image with concentric layers
product-development14 min readintermediate

Node.js Clean Architecture in 2026: Hexagonal, DDD & Testable Layers

Vivek Singh
Founder & CEO at Witarist · May 2, 2026

Most Node.js codebases start out as a single Express file with a few routes. Two years in, that file is 4,000 lines, every change risks a regression, and onboarding a new developer takes a week. Clean architecture is the discipline that prevents this — separating business rules from frameworks, databases, and HTTP concerns so the code that matters most stays small, pure, and testable.

In 2026, the case for clean architecture is stronger than ever. Teams ship faster when domain logic is independent of Express, NestJS, Prisma, or whatever runtime is fashionable. This guide walks through the four layers, the dependency rule, the practical Node.js folder structure that works, and the patterns we use when vetting senior backend engineers at HireNodeJS.

What Is Clean Architecture in Node.js?

Clean architecture is Robert C. Martin's synthesis of patterns that came before it — hexagonal (ports and adapters), onion architecture, and DDD's strategic design. The single rule that defines it: source-code dependencies must point only inward, from outer rings (frameworks) toward the innermost ring (the domain).

The four concentric layers

The domain holds entities and business rules. The application layer holds use cases and ports (interfaces). Interface adapters translate between use cases and the outside world — controllers, presenters, repositories. Frameworks and drivers are the outer ring: Express, Fastify, Prisma, Redis, AWS SDKs.

Why the inversion matters

[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` propa Node.js engineer[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` prop

Clean architecture concentric layers diagram for Node.js — domain, application, adapters, frameworks
Figure 1 — The four concentric layers. Source-code dependencies always point inward.

Folder Structure That Works for Node.js Projects

There is no canonical layout, but the layout you pick should make the dependency rule obvious. New developers should be able to look at the folder tree and immediately know what is allowed to import what.

A 4-folder skeleton

Most production Node.js teams converge on something close to this: src/domain (pure entities and rules), src/application (use cases, ports), src/infrastructure (Prisma, Redis, S3, queues), and src/interfaces (HTTP, GraphQL, CLI, workers).

src/index.ts
// Domain layer — pure, zero dependencies
// src/domain/order/Order.ts
export class Order {
  constructor(
    public readonly id: string,
    public readonly customerId: string,
    private items: OrderItem[],
    private status: 'draft' | 'placed' | 'paid' | 'shipped'
  ) {}

  total(): Money {
    return this.items.reduce((sum, i) => sum.add(i.subtotal()), Money.zero('USD'));
  }

  place(): void {
    if (this.items.length === 0) throw new Error('cannot place empty order');
    this.status = 'placed';
  }
}

// Application layer — depends only on domain
// src/application/orders/PlaceOrderUseCase.ts
export interface OrderRepository {
  save(order: Order): Promise<void>;
  byId(id: string): Promise<Order | null>;
}

export class PlaceOrderUseCase {
  constructor(private readonly orders: OrderRepository) {}

  async exec(orderId: string): Promise<void> {
    const order = await this.orders.byId(orderId);
    if (!order) throw new Error('order not found');
    order.place();
    await this.orders.save(order);
  }
}

// Infrastructure layer — implements the port
// src/infrastructure/db/PrismaOrderRepository.ts
import { PrismaClient } from '@prisma/client';
import { OrderRepository } from '@/application/orders/PlaceOrderUseCase';

export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly db: PrismaClient) {}
  async save(order: Order) { /* ...prisma.order.upsert... */ }
  async byId(id: string)   { /* ...prisma.order.findUnique... */ }
}
🚀Pro Tip
Use a path-based import linter (eslint-plugin-boundaries or dependency-cruiser) to fail the build when src/domain imports anything from src/infrastructure. The rule is only useful if it is mechanically enforced.
Figure 2 — Architecture pattern scores across testability, modularity, refactor ease, onboarding speed, and time-to-ship.

The Dependency Rule in Practice

The dependency rule sounds abstract until you see it broken. The most common violation in real Node.js codebases: a use case that imports a Prisma type. That single import drags Prisma — and indirectly Postgres, the schema, and the migrations — into something that should be testable in 5 milliseconds with no infrastructure at all.

Use ports, not concrete types

The application layer defines an interface (a port). The infrastructure layer implements it. The use case depends on the abstraction; it never knows which adapter satisfies it. This is the same pattern as Java's Spring or .NET's MediatR — and it works just as well in TypeScript with plain interfaces and a small DI container.

Composition root

[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` propHire a backend developer[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` prop

Keeping wiring in one place has another benefit: it forces you to confront every dependency the application has. If main.ts crosses 200 lines, you have a signal that the system is becoming too coupled and probably needs to be split into bounded contexts, each with its own composition root. Most senior engineers we vet treat the composition root as a living architecture diagram — one file you can read top to bottom and understand the whole shape of the application.

Layer responsibilities table for clean architecture in Node.js — domain, application, infrastructure, interface
Figure 3 — A 4-layer responsibility split for production Node.js codebases.

Testing Strategy for Each Layer

Clean architecture pays off most visibly in tests. The domain runs as pure unit tests with no mocks. The application layer uses fake in-memory adapters for ports. Integration tests cover the infrastructure adapters. End-to-end tests exercise the full stack through HTTP.

The test pyramid, per layer

We see consistently better outcomes in teams who keep ~70% of their tests at the domain and application levels. Those tests run in milliseconds, never flake, and survive infrastructure rewrites untouched. Slow integration and e2e tests at the bottom of the pyramid catch the rest.

Mocking the right things

If you find yourself mocking PrismaClient inside a use-case test, the test is reaching too far. The use case should depend on a repository port — and the test substitutes a hand-written FakeOrderRepository class that holds an in-memory Map. No mock library, no setup ceremony, no flake.

Hand-written fakes have one more advantage that auto-mocks lack: they can enforce real invariants. A FakeOrderRepository can throw if you try to save an order with a duplicate ID, exactly as a real database would. That turns the fake into a small executable specification of how the port behaves — and forces test authors to think about edge cases the production adapter must handle.

Contract tests for adapters

Ready to build your team?

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.

When you have multiple adapters for the same port (a Prisma adapter for production, an in-memory adapter for tests), write a single contract test suite parameterised by the adapter under test. Both implementations must pass the same suite. This catches the subtle bugs where the fake and the real adapter drift apart and your tests start lying about behaviour.

⚠️Warning
Heavy use of jest.mock or vi.mock at the use-case level is a smell. It usually means your application layer is reaching into infrastructure. Replace the mock with a port + fake — the test gets faster and the architecture gets cleaner.
Figure 4 — Onboarding time vs test coverage by architecture pattern, surveyed across 120 Node.js teams in 2026.

Six Mistakes Node.js Teams Make Adopting Clean Architecture

We review hundreds of Node.js codebases each year while vetting candidates and consulting with clients. The same patterns of misadoption come up over and over.

Anaemic domain models

Putting all the logic in services and leaving entities as bags of getters and setters defeats the purpose. The Order class should know how to total itself, when it can be cancelled, and what state transitions are legal — not delegate everything to OrderService.

Premature abstraction

A 6-route CRUD app does not need four layers. Clean architecture starts paying off around 5,000+ lines of business logic and 3+ developers. Below that, the ceremony slows you down. Start simple and refactor toward layers when seams become painful.

Ports for everything

[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` propTypeScript fundamentals[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` prop

Where Domain-Driven Design Fits In

Clean architecture is the structural skeleton; DDD is the modelling discipline. The two pair naturally — DDD tells you what belongs in the domain layer (aggregates, value objects, domain events) and clean architecture tells you how to keep that domain isolated from frameworks.

Aggregates as transaction boundaries

Treat each aggregate root as the unit of consistency. A use case loads one aggregate, calls a method on it, and persists the change atomically. This rule alone eliminates a huge category of concurrency bugs and turns a Node.js codebase into something a team of 8 engineers can actually evolve in parallel.

Domain events

[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` propKafka producer[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` prop

Bounded contexts as deployable units

Once a Node.js codebase grows past two or three teams, bounded contexts deserve their own folders, their own composition roots, and eventually their own deployable services. Clean architecture inside each context makes that split mostly mechanical: the domain code never imported infrastructure to begin with, so extracting it is a matter of moving folders and wiring a new HTTP boundary, not untangling a hairball.

Dependency Injection and Tooling for Node.js

You don't need a heavyweight DI framework to apply clean architecture in Node.js. Three options work well in production today, depending on the size of the codebase and team preferences.

Manual composition

For codebases under ~30k lines, a hand-written composition root in main.ts is the cleanest approach. No framework magic, no metadata, just plain TypeScript constructors. Easy to debug, easy to onboard, easy to delete and replace.

Awilix or tsyringe

For larger projects, Awilix (proxyquire-free, no decorators required) and tsyringe (TC39 decorators, minimal) both work well. NestJS bakes DI into the framework — useful if you want batteries included, but it ties your application layer to NestJS modules.

ℹ️Note
If you adopt NestJS, treat its modules and providers as the interface-adapter layer. Keep the use cases and domain in plain TypeScript classes that NestJS providers wrap — this keeps your business logic portable if you ever leave NestJS.

Hire Expert Node.js Developers — Ready in 48 Hours

[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` propHireNodeJS.com[@portabletext/react] Unknown block type "span", specify a component for it in the `components.types` prop

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.

💡Tip
🚀 Ready to scale your Node.js team? HireNodeJS.com connects you with pre-vetted engineers who can join within 48 hours — no lengthy screening, no recruiter fees. Browse developers at hirenodejs.com/hire

Conclusion: Clean Architecture Is a Multi-Year Bet

Clean architecture is not a 2-week refactor; it is a discipline that compounds over years. The teams who invest early end up shipping faster, onboarding new engineers faster, and surviving the inevitable framework migrations with their business logic intact.

Start small: extract a single bounded context, define one port, write the use case as pure TypeScript, and watch the test suite become trivial to run. Once you feel the difference, the rest of the codebase will follow.

Topics
#clean architecture#node.js#typescript#hexagonal architecture#ddd#testing#software architecture

Frequently Asked Questions

What is clean architecture in Node.js?

Clean architecture is a layered approach that isolates business rules from frameworks, databases, and HTTP. In Node.js it typically means four folders: domain (pure rules), application (use cases), infrastructure (Prisma, Redis, S3), and interface (Express, GraphQL). Source-code dependencies only point inward.

Is clean architecture overkill for small Node.js projects?

For projects under ~5,000 lines or 1–2 developers, full clean architecture adds ceremony without much payoff. Start with a flat folder layout, then extract a domain layer once you have non-trivial business logic that you want to test in isolation.

How is clean architecture different from hexagonal architecture?

Hexagonal (ports and adapters) is the structural pattern at the core. Clean architecture wraps it with named layers — entities, use cases, interface adapters, frameworks — and an explicit dependency rule. In practice the two are interchangeable in casual conversation.

Do I need NestJS to do clean architecture in Node.js?

No. Clean architecture is framework-agnostic. NestJS provides DI and module boundaries that map nicely onto the layers, but Express, Fastify, or Hono with a small Awilix container or manual composition root works equally well.

What is the dependency rule?

Source-code dependencies must point only inward — from frameworks toward the domain. The domain depends on nothing; the application layer depends on the domain; adapters depend on the application layer; frameworks depend on adapters. This is what keeps business rules testable and portable.

How do I test a Node.js codebase that follows clean architecture?

Domain entities run as pure unit tests in milliseconds. Application use cases use hand-written fake adapters for ports. Infrastructure adapters get integration tests against a real database. End-to-end tests cover the HTTP boundary. Aim for ~70% of tests at the domain and application levels.

About the Author
Vivek Singh
Founder & CEO at Witarist

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.

Developers available now

Need a Node.js architect who has shipped clean systems?

HireNodeJS connects you with senior Node.js engineers who have built clean, layered, testable backends in production. No recruiter fees, no lengthy screening — just talent ready to ship.