Siddhant Deval
Siddhant Deval
backend5 min read

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.

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.

Architectural Note

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:

TYPESCRIPT
// ❌ Anti-Pattern: No architectural layers — every concern is co-located
import { PrismaClient } from '@prisma/client';
import Stripe from 'stripe';
import { Request, Response } from 'express';
import { sendConfirmationEmail } from '../email/mailer';

const db = new PrismaClient();
const stripe = new Stripe(process.env.STRIPE_KEY!);

export async function placeOrderHandler(req: Request, res: Response): Promise<void> {
  // Layer 1 concern: parse HTTP input
  const { customerId, items, promoCode } = req.body;

  // Layer 2 concern: domain validation (mixed with HTTP)
  if (!customerId || !items?.length) {
    res.status(400).json({ error: 'Invalid order' });
    return;
  }

  // Layer 3 concern: business rule (buried in handler)
  const customer = await db.customer.findUnique({ where: { id: customerId } });
  let discount = 0;
  if (customer?.tier === 'VIP') discount = 0.20;

  // Layer 4 concern: persistence
  const order = await db.order.create({
    data: { customerId, status: 'PENDING', promoCode }
  });
  await db.orderItem.createMany({ data: items.map(i => ({ ...i, orderId: order.id })) });

  // Layer 5 concern: payment (mixed with HTTP response)
  const total = items.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
  const intent = await stripe.paymentIntents.create({
    amount: Math.round(total * (1 - discount) * 100),
    currency: 'usd',
    metadata: { orderId: order.id },
  });

  // Layer 6 concern: notification
  await sendConfirmationEmail(customer!.email, order.id);

  res.status(201).json({ orderId: order.id, paymentIntentId: intent.id });
}

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.ts that 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.

src/domain/
├── order/
│   ├── Order.ts               ← Aggregate Root (Entity)
│   ├── LineItem.ts            ← Child Entity
│   ├── OrderStatus.ts         ← Domain enumeration
│   ├── OrderId.ts             ← Branded Value Object
│   ├── events/
│   │   ├── OrderPlaced.ts     ← Domain Event
│   │   └── OrderShipped.ts    ← Domain Event
│   └── ports/
│       └── IOrderRepository.ts  ← Repository interface (Port)
├── payment/
│   ├── Money.ts               ← Value Object
│   ├── PaymentReceipt.ts      ← Value Object
│   └── ports/
│       └── IPaymentProcessor.ts  ← Payment port (interface)
├── customer/
│   └── Customer.ts            ← Aggregate Root
└── shared/
    ├── Entity.ts              ← Base class
    ├── AggregateRoot.ts       ← Base class + event collection
    ├── ValueObject.ts         ← Base class
    ├── DomainEvent.ts         ← Base class
    └── DomainError.ts         ← Typed domain exception

A domain file's import section looks like this:

TYPESCRIPT
// ✅ Domain layer — zero framework imports
import { AggregateRoot } from '../shared/AggregateRoot';
import { LineItem } from './LineItem';
import { OrderId } from './OrderId';
import { OrderStatus } from './OrderStatus';
import { Money } from '../payment/Money';
import { CustomerId } from '../customer/CustomerId';
import { OrderPlaced } from './events/OrderPlaced';
import { DomainError } from '../shared/DomainError';
// That is it. No Prisma. No Express. No 'node:*'.

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.

src/application/
├── order/
│   ├── PlaceOrderUseCase.ts       ← Use Case
│   ├── CancelOrderUseCase.ts      ← Use Case
│   ├── ProcessRefundUseCase.ts    ← Use Case
│   ├── commands/
│   │   ├── PlaceOrderCommand.ts   ← DTO (plain TypeScript interface)
│   │   └── CancelOrderCommand.ts  ← DTO
│   └── queries/
│       └── GetOrderSummaryQuery.ts ← Read-model query DTO
└── shared/
    └── IEventBus.ts               ← Event dispatch port (belongs here or in domain)

A use case's import section:

TYPESCRIPT
// ✅ Application layer — imports only from Domain layer
import { IOrderRepository } from '../../domain/order/ports/IOrderRepository';
import { IPaymentProcessor } from '../../domain/payment/ports/IPaymentProcessor';
import { Order } from '../../domain/order/Order';
import { OrderId } from '../../domain/order/OrderId';
import { PlaceOrderCommand } from './commands/PlaceOrderCommand';
import { IEventBus } from '../shared/IEventBus';
// No PrismaClient. No StripeClient. No Request/Response.

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.

src/infrastructure/
├── persistence/
│   ├── PrismaOrderRepository.ts        ← implements IOrderRepository
│   ├── PrismaCustomerRepository.ts     ← implements ICustomerRepository
│   └── InMemoryOrderRepository.ts      ← implements IOrderRepository (for tests)
├── payment/
│   ├── StripePaymentProcessor.ts       ← implements IPaymentProcessor
│   └── MockPaymentProcessor.ts         ← implements IPaymentProcessor (for tests)
├── messaging/
│   ├── KafkaDomainEventPublisher.ts    ← implements IEventBus
│   └── InMemoryEventBus.ts             ← implements IEventBus (for tests)
└── email/
    └── SMTPEmailAdapter.ts             ← implements IEmailPort

A repository adapter's import section:

TYPESCRIPT
// ✅ Infrastructure layer — imports Prisma AND Domain interfaces
import { PrismaClient, Order as PrismaOrder } from '@prisma/client';
import { IOrderRepository } from '../../domain/order/ports/IOrderRepository';
import { Order } from '../../domain/order/Order';
import { OrderId } from '../../domain/order/OrderId';

export class PrismaOrderRepository implements IOrderRepository {
  constructor(private readonly prisma: PrismaClient) {}
  // ...
}
Crucial Requirement

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.

src/interface/
├── http/
│   ├── OrderController.ts     ← Express route handler
│   ├── CustomerController.ts  ← Express route handler
│   └── HealthController.ts    ← Health check
├── kafka/
│   └── OrderCommandConsumer.ts ← Kafka message handler
├── cli/
│   └── SeedOrdersCommand.ts   ← CLI seeding script
└── graphql/
    └── OrderResolver.ts       ← GraphQL resolver

An HTTP controller:

TYPESCRIPT
// ✅ Interface layer — parses HTTP, calls use case, formats response
import { Request, Response } from 'express';
import { PlaceOrderUseCase } from '../../application/order/PlaceOrderUseCase';
import { PlaceOrderCommand } from '../../application/order/commands/PlaceOrderCommand';

export class OrderController {
  constructor(private readonly placeOrder: PlaceOrderUseCase) {}

  async create(req: Request, res: Response): Promise<void> {
    const command: PlaceOrderCommand = {
      customerId: req.body.customerId,
      items: req.body.items,
      shippingAddressId: req.body.shippingAddressId,
    };
    try {
      const orderId = await this.placeOrder.execute(command);
      res.status(201).json({ orderId: orderId.value });
    } catch (error) {
      if (error instanceof DomainError) {
        res.status(422).json({ error: error.message });
      } else {
        res.status(500).json({ error: 'Internal server error' });
      }
    }
  }
}

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:

TYPESCRIPT
// ❌ DIP violation: Application layer imports from Infrastructure
import { PrismaClient } from '@prisma/client';   // ← This line is the violation

class PlaceOrderUseCase {
  private db = new PrismaClient();

  async execute(command: PlaceOrderCommand): Promise<OrderId> {
    const customer = await this.db.customer.findUnique({ /* ... */ });
    // ...
  }
}

What breaks:

  1. Testability: every test for PlaceOrderUseCase requires a running PostgreSQL instance.
  2. Deliverability: the Kafka consumer that places orders cannot reuse this use case without also importing PrismaClient into the message handler — which means the message handler now must be configured with database credentials.
  3. Upgradeability: migrating from Prisma 4 to Prisma 6 requires reading the business logic in the use case to understand what changed.
  4. 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

TYPESCRIPT
// ✅ Correct: Application layer depends only on the Domain interface
class PlaceOrderUseCase {
  constructor(
    private readonly orderRepo: IOrderRepository,  // Domain interface — defined in Domain layer
    private readonly payment: IPaymentProcessor,   // Domain interface — defined in Domain layer
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderId> {
    // Same business logic — zero infrastructure imports
  }
}

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:

tsconfig.json              ← Root (references all layers)
src/
  domain/tsconfig.json     ← No references (innermost)
  application/tsconfig.json ← References domain only
  infrastructure/tsconfig.json ← References domain only (implements its interfaces)
  interface/tsconfig.json  ← References application + infrastructure

4.2 Domain tsconfig.json

JSON
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "."
  },
  "references": []
}

The Domain layer has zero references. It cannot import from any other layer.

4.3 Application tsconfig.json

JSON
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "."
  },
  "references": [
    { "path": "../domain" }
  ]
}

The Application layer can only import from Domain. An attempt to import from Infrastructure produces:

error TS6307: File 'src/infrastructure/PrismaOrderRepository.ts' is not under 'rootDir'.

4.4 Infrastructure tsconfig.json

JSON
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "."
  },
  "references": [
    { "path": "../domain" }
  ]
}

Infrastructure references Domain (to implement its interfaces) but not Application. This correctly prevents the Infrastructure layer from calling Application Use Cases directly.

Pro Tip & Optimization

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

src/
├── domain/
│   ├── order/
│   │   ├── Order.ts
│   │   ├── LineItem.ts
│   │   ├── OrderId.ts
│   │   ├── OrderStatus.ts
│   │   ├── events/
│   │   │   ├── OrderPlaced.ts
│   │   │   ├── OrderCancelled.ts
│   │   │   └── OrderShipped.ts
│   │   └── ports/
│   │       └── IOrderRepository.ts
│   ├── customer/
│   │   ├── Customer.ts
│   │   ├── CustomerId.ts
│   │   └── ports/
│   │       └── ICustomerRepository.ts
│   ├── payment/
│   │   ├── Money.ts
│   │   ├── PaymentReceipt.ts
│   │   └── ports/
│   │       └── IPaymentProcessor.ts
│   └── shared/
│       ├── Entity.ts
│       ├── AggregateRoot.ts
│       ├── ValueObject.ts
│       ├── DomainEvent.ts
│       └── DomainError.ts
│
├── application/
│   ├── order/
│   │   ├── PlaceOrderUseCase.ts
│   │   ├── CancelOrderUseCase.ts
│   │   ├── GetOrderSummaryUseCase.ts
│   │   └── commands/
│   │       ├── PlaceOrderCommand.ts
│   │       └── CancelOrderCommand.ts
│   └── shared/
│       └── IEventBus.ts
│
├── infrastructure/
│   ├── persistence/
│   │   ├── PrismaOrderRepository.ts
│   │   └── InMemoryOrderRepository.ts
│   ├── payment/
│   │   ├── StripePaymentProcessor.ts
│   │   └── MockPaymentProcessor.ts
│   ├── messaging/
│   │   ├── KafkaDomainEventPublisher.ts
│   │   └── InMemoryEventBus.ts
│   └── container/
│       └── container.ts               ← DI container bindings (Part 9)
│
├── interface/
│   ├── http/
│   │   ├── OrderController.ts
│   │   └── routes.ts
│   └── kafka/
│       └── OrderCommandConsumer.ts
│
├── main.ts                            ← Entry point — assembles all layers
└── prisma/
    └── schema.prisma

5.1 The main.ts Composition Root

The entry point is the only place in the application where all four layers are combined:

TYPESCRIPT
// main.ts — the Composition Root: the only file that knows about all layers
import { PrismaClient } from '@prisma/client';
import Stripe from 'stripe';
import express from 'express';

// Infrastructure
import { PrismaOrderRepository } from './infrastructure/persistence/PrismaOrderRepository';
import { StripePaymentProcessor } from './infrastructure/payment/StripePaymentProcessor';
import { KafkaDomainEventPublisher } from './infrastructure/messaging/KafkaDomainEventPublisher';

// Application
import { PlaceOrderUseCase } from './application/order/PlaceOrderUseCase';

// Interface
import { OrderController } from './interface/http/OrderController';

// Wire everything together — this is the only place where layers meet
const prisma = new PrismaClient();
const stripeClient = new Stripe(process.env.STRIPE_KEY!);

const orderRepo = new PrismaOrderRepository(prisma);
const paymentProcessor = new StripePaymentProcessor(stripeClient);
const eventBus = new KafkaDomainEventPublisher();

const placeOrderUseCase = new PlaceOrderUseCase(orderRepo, paymentProcessor, eventBus);
const orderController = new OrderController(placeOrderUseCase);

const app = express();
app.use(express.json());
app.post('/orders', (req, res) => orderController.create(req, res));

app.listen(3000, () => console.log('Billing engine running on :3000'));

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.ts in the Infrastructure layer implementing IOrderRepository
  • Update main.ts to inject MongoOrderRepository instead of PrismaOrderRepository
  • 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.ts in 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:

TYPESCRIPT
// ✅ Pure application-layer test — zero infrastructure dependencies
import { PlaceOrderUseCase } from '../../src/application/order/PlaceOrderUseCase';
import { InMemoryOrderRepository } from '../../src/infrastructure/persistence/InMemoryOrderRepository';
import { MockPaymentProcessor } from '../../src/infrastructure/payment/MockPaymentProcessor';
import { InMemoryEventBus } from '../../src/infrastructure/messaging/InMemoryEventBus';

describe('PlaceOrderUseCase', () => {
  let orderRepo: InMemoryOrderRepository;
  let useCase: PlaceOrderUseCase;

  beforeEach(() => {
    orderRepo = new InMemoryOrderRepository();
    useCase = new PlaceOrderUseCase(
      orderRepo,
      new MockPaymentProcessor(),
      new InMemoryEventBus(),
    );
  });

  it('should save order and raise OrderPlaced event', async () => {
    const command = { customerId: 'cust_001', items: [{ /* ... */ }] };
    const orderId = await useCase.execute(command);

    const saved = await orderRepo.findById(orderId);
    expect(saved).not.toBeNull();
    expect(saved!.status).toBe(OrderStatus.PLACED);
    // Assert domain event raised — Part 5 covers event bus spying
  });
});

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, and Money as Entities and Value Objects with compile-time nominal typing and runtime invariant guards. Part 3: Entities & Value Objects →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Clean Architecture#Node.js#TypeScript#OOP#Domain-Driven Design#Backend Architecture#SOLID
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.