Clean Architecture in Node.js: The Four-Layer Dependency Rule
Clean Architecture separates Node.js applications into four concentric layers — Domain, Application, Infrastructure, and Interface — enforced by the Dependency Rule: source-code dependencies must point inward only. This article draws that boundary precisely and shows why crossing it causes the untestable, tightly coupled backends that plague production Node.js services.
Backend Clean Architecture & Domain-Driven Design
Clean Architecture in Node.js: The Four-Layer Dependency Rule
Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. But an autonomous domain object living in the same file as a Prisma query and an Express route handler is not autonomous at all. It is a prisoner of the infrastructure it was meant to be independent from. Clean Architecture solves this with a single, non-negotiable rule: source-code dependencies must point inward only.
This article makes that rule concrete. We will define the four layers, draw the dependency arrows precisely, show what breaks when they are violated, and build the TypeScript project structure that enforces the rule at the compiler level — not at code review.
Prerequisites: Part 1 (OOP Theory Foundations) — specifically the Dependency Inversion Principle (§5.5). This article operationalizes DIP into a complete project structure. Every architectural decision here is a direct consequence of DIP applied at the package boundary level.
1. The Problem: Every Layer Knows About Everything
This is a real placeOrder function from a production Node.js service, lightly anonymized:
Count the concerns: HTTP parsing, input validation, discount calculation, two Prisma queries, Stripe API call, email dispatch, HTTP response formatting. Seven distinct responsibilities in one function.
The consequences are predictable and severe:
- Untestable in isolation: every test requires a live database, live Stripe sandbox, and a working email server.
- Zero reusability: the Kafka consumer that also places orders must copy-paste this function or extract it into a
service.tsthat still has all the same dependencies. - Coupled to infrastructure versions: upgrading from Prisma 5 to Prisma 6 requires reading and modifying this business logic file.
- Fragile to change: a new discount policy requires modifying the same file that handles HTTP parsing — an unrelated concern.
Clean Architecture solves this by separating these concerns into four distinct layers with a strict dependency rule between them.
2. The Four Layers
2.1 Domain Layer: The Innermost Circle
The Domain layer contains the core business model: Entities, Value Objects, Aggregates, Domain Events, Repository Interfaces, and Domain Service interfaces.
The iron rule: The Domain layer imports nothing from any outer layer. No Express, no Prisma, no Kafka, no Redis. Not even Node.js fs or path. The Domain layer is pure TypeScript business logic.
A domain file's import section looks like this:
2.2 Application Layer: The Orchestration Ring
The Application layer contains Use Cases, Application Services, and Command/Query DTOs.
What it does: Receives a Command (a plain data object describing the user's intent), loads Aggregates from Repository ports, invokes domain methods, and dispatches collected Domain Events. It does not contain business rules.
What it imports: Only Domain layer interfaces and types. Never Prisma, Express, or any infrastructure package.
A use case's import section:
2.3 Infrastructure Layer: The Adapter Ring
The Infrastructure layer contains concrete implementations of all Domain ports: Repository adapters, payment gateway adapters, email adapters, message queue publishers, and cache adapters.
What it imports: Prisma, Stripe, Kafka, Redis, Nodemailer — all the real infrastructure libraries. It also imports from the Domain layer to implement its interfaces.
A repository adapter's import section:
Prisma types (PrismaOrder, PrismaLineItem) must never leave the PrismaOrderRepository file. The findById method returns a domain Order, not a PrismaOrder. The translation happens at the repository boundary. This is the Anti-Corruption Layer (Part 13).
2.4 Interface Layer: The Outermost Shell
The Interface layer contains HTTP route handlers, GraphQL resolvers, CLI commands, and WebSocket handlers — anything that represents a delivery mechanism.
What it does: Parses the incoming protocol (HTTP, CLI, Kafka) into a Command DTO, calls the appropriate Application Use Case, and formats the output back into the protocol's response format.
An HTTP controller:
The controller knows nothing about PrismaClient, StripeClient, or the Order domain object. It knows PlaceOrderCommand and PlaceOrderUseCase. That is all.
3. The Dependency Rule
The Dependency Rule is the single constraint that defines Clean Architecture:
Source-code dependencies must only point inward. Nothing in an inner circle can know about anything in an outer circle.
Notice that Infrastructure and Interface both point inward to Domain or Application, but neither Domain nor Application points outward. The Domain layer has zero import arrows leaving it.
3.1 The Classic Violation: Prisma in the Use Case
This violation is ubiquitous in Node.js codebases:
What breaks:
- Testability: every test for
PlaceOrderUseCaserequires a running PostgreSQL instance. - Deliverability: the Kafka consumer that places orders cannot reuse this use case without also importing
PrismaClientinto the message handler — which means the message handler now must be configured with database credentials. - Upgradeability: migrating from Prisma 4 to Prisma 6 requires reading the business logic in the use case to understand what changed.
- TypeScript project isolation: TypeScript project references (§4) would correctly reject this import as a boundary violation at compile time.
3.2 The Correct Direction: IOrderRepository as the Pivot
The IOrderRepository interface is defined in src/domain/order/ports/IOrderRepository.ts. The PrismaOrderRepository is defined in src/infrastructure/persistence/PrismaOrderRepository.ts and imports from both Prisma and the Domain layer. The dependency arrow for PrismaOrderRepository points inward — to the interface it implements. The dependency arrow for the use case points inward — to the interface it depends on.
Both arrows point the same direction: toward the Domain.
4. Enforcing the Rule with TypeScript Project References
TypeScript project references (--composite flag) allow you to define module boundary rules that the compiler enforces at build time. A violation of the Dependency Rule becomes a TypeScript compiler error, not a code review comment.
4.1 Project Structure for TypeScript References
Create separate tsconfig.json files for each layer:
4.2 Domain tsconfig.json
The Domain layer has zero references. It cannot import from any other layer.
4.3 Application tsconfig.json
The Application layer can only import from Domain. An attempt to import from Infrastructure produces:
4.4 Infrastructure tsconfig.json
Infrastructure references Domain (to implement its interfaces) but not Application. This correctly prevents the Infrastructure layer from calling Application Use Cases directly.
Run tsc --build --force from the repo root during CI. If any file in the Domain layer attempts to import from Application or Infrastructure, the build fails before any test runs. This is architecture enforcement by compiler, not by convention.
5. The Complete Folder Structure for the Billing Engine
5.1 The main.ts Composition Root
The entry point is the only place in the application where all four layers are combined:
main.ts is the one file that legitimately imports from all layers. Every other file imports only from layers inward of itself. In Part 9, we replace this manual wiring with an IoC container.
6. The Dependency Rule in Production: What Actually Changes
The concrete benefit of the Dependency Rule is demonstrated by three real scenarios:
6.1 Swapping Databases
You decide to move from PostgreSQL/Prisma to MongoDB. The scope of change:
- Create
MongoOrderRepository.tsin the Infrastructure layer implementingIOrderRepository - Update
main.tsto injectMongoOrderRepositoryinstead ofPrismaOrderRepository - Zero changes to Domain, Application, or Interface layers
The PlaceOrderUseCase source code does not change. The Order domain object does not change. The HTTP controller does not change.
6.2 Adding a New Delivery Mechanism
You need to place orders from a Kafka consumer in addition to the HTTP API. The scope of change:
- Create
OrderCommandConsumer.tsin the Interface layer - Parse the Kafka message into
PlaceOrderCommand - Call
placeOrderUseCase.execute(command)— the same use case the HTTP controller already calls
Zero changes to Domain, Application, or Infrastructure layers.
6.3 Running Tests Without Infrastructure
A test for PlaceOrderUseCase:
This test runs in under 5ms. No database. No HTTP server. No Stripe sandbox. The InMemoryOrderRepository and MockPaymentProcessor are real implementations (not mocks) that satisfy the Domain interfaces — they are just backed by in-memory Maps instead of network calls.
Summary
| Layer | Contains | Must NOT Import |
|---|---|---|
| Domain | Entities, Value Objects, Aggregates, Domain Events, Repository interfaces | Anything from outer layers |
| Application | Use Cases, Command/Query DTOs, Application Services | Infrastructure, Interface, or framework packages |
| Infrastructure | Repository adapters, Payment adapters, Message adapters | Application layer (though it references Domain interfaces) |
| Interface | HTTP controllers, Kafka consumers, CLI commands | Domain or Infrastructure directly — routes through Application |
What's Next
In Part 3, we implement the first Domain layer objects — defining
Order,LineItem, andMoneyas Entities and Value Objects with compile-time nominal typing and runtime invariant guards. Part 3: Entities & Value Objects →
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.