Clean Architecture in Node.js: A Practical Guide Without the Academic Fluff
Every few months there's a "Clean Architecture in Node.js" article that shows 15 folders, 6 abstraction layers, and a diagram. Then you try to implement it and spend more time managing indirection than writing features.
This guide shows what actually works in production Node.js TypeScript projects — the parts of Clean Architecture worth keeping, the parts to skip, and concrete runnable code.
The Core Principle Worth Keeping
Robert C. Martin's Clean Architecture has one idea that genuinely matters:
Business logic should not depend on infrastructure (frameworks, databases, external services).
Your order processing code should not import Prisma, Express, or Stripe directly. Why? Because:
- Testing: you can test business logic without a database
- Swapping: changing from Prisma to Drizzle doesn't touch business logic
- Clarity: business rules live in one place, not scattered across Express handlers
Everything else — the layers, the diagrams, the naming — is secondary.
The Folder Structure That Works
src/
├── domain/ # Business entities and rules
│ ├── entities/
│ │ ├── Order.ts
│ │ └── User.ts
│ └── repositories/ # Interfaces (contracts)
│ ├── IOrderRepository.ts
│ └── IUserRepository.ts
│
├── application/ # Use cases (what the app can do)
│ ├── orders/
│ │ ├── CreateOrder.ts
│ │ ├── GetUserOrders.ts
│ │ └── CancelOrder.ts
│ └── users/
│ └── RegisterUser.ts
│
├── infrastructure/ # Concrete implementations
│ ├── database/
│ │ ├── prisma/
│ │ │ ├── PrismaOrderRepository.ts
│ │ │ └── PrismaUserRepository.ts
│ │ └── prisma.client.ts
│ ├── email/
│ │ └── SendgridEmailService.ts
│ └── payment/
│ └── StripePaymentService.ts
│
└── presentation/ # HTTP, CLI, queues — framework code
├── http/
│ ├── routes/
│ │ └── orderRoutes.ts
│ └── controllers/
│ └── OrderController.ts
└── app.ts
Three key rules:
domain/imports nothing from the other layersapplication/imports only fromdomain/infrastructure/andpresentation/import from anywhere
Domain Entities
Domain entities contain business logic, not just data:
// src/domain/entities/Order.ts
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled';
export class Order {
readonly id: string;
readonly userId: string;
private _status: OrderStatus;
private _items: OrderItem[];
private _total: number;
constructor(props: {
id: string;
userId: string;
items: OrderItem[];
status?: OrderStatus;
}) {
this.id = props.id;
this.userId = props.userId;
this._items = props.items;
this._status = props.status ?? 'pending';
this._total = this.calculateTotal();
}
get status(): OrderStatus { return this._status; }
get items(): Readonly<OrderItem[]> { return this._items; }
get total(): number { return this._total; }
// Business rule: can only cancel pending orders
cancel(): void {
if (this._status !== 'pending') {
throw new Error(`Cannot cancel order in status: ${this._status}`);
}
this._status = 'cancelled';
}
// Business rule: can only mark as shipped if paid
markShipped(): void {
if (this._status !== 'paid') {
throw new Error(`Order must be paid before shipping`);
}
this._status = 'shipped';
}
private calculateTotal(): number {
return this._items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
export interface OrderItem {
productId: string;
name: string;
price: number;
quantity: number;
}
Notice: no imports from any library. This is pure TypeScript. You can test it without a database.
Repository Interface (Dependency Inversion)
// src/domain/repositories/IOrderRepository.ts
import { Order } from '../entities/Order';
export interface IOrderRepository {
findById(id: string): Promise<Order | null>;
findByUserId(userId: string, options?: { limit?: number; offset?: number }): Promise<Order[]>;
save(order: Order): Promise<void>;
update(order: Order): Promise<void>;
}
This is the contract. The application layer depends on this interface — not on Prisma, not on any specific database.
Use Cases (Application Layer)
// src/application/orders/CreateOrder.ts
import { Order, OrderItem } from '../../domain/entities/Order';
import { IOrderRepository } from '../../domain/repositories/IOrderRepository';
import { IProductRepository } from '../../domain/repositories/IProductRepository';
import { IEventPublisher } from '../../domain/services/IEventPublisher';
import { randomUUID } from 'crypto';
interface CreateOrderInput {
userId: string;
items: Array<{ productId: string; quantity: number }>;
}
interface CreateOrderOutput {
orderId: string;
total: number;
}
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: IOrderRepository,
private readonly productRepository: IProductRepository,
private readonly eventPublisher: IEventPublisher,
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
// 1. Validate products exist and have stock
const orderItems: OrderItem[] = [];
for (const item of input.items) {
const product = await this.productRepository.findById(item.productId);
if (!product) throw new Error(`Product ${item.productId} not found`);
if (product.stock < item.quantity) {
throw new Error(`Insufficient stock for product ${item.productId}`);
}
orderItems.push({
productId: product.id,
name: product.name,
price: product.price,
quantity: item.quantity,
});
}
// 2. Create the order entity (business logic runs here)
const order = new Order({
id: randomUUID(),
userId: input.userId,
items: orderItems,
});
// 3. Persist
await this.orderRepository.save(order);
// 4. Publish domain event (async side effects)
await this.eventPublisher.publish('order.created', {
orderId: order.id,
userId: order.userId,
total: order.total,
});
return { orderId: order.id, total: order.total };
}
}
The use case is testable in complete isolation — inject mock repositories, no database needed:
// src/application/orders/__tests__/CreateOrder.test.ts
describe('CreateOrderUseCase', () => {
it('should create order with correct total', async () => {
const mockOrderRepo: jest.Mocked<IOrderRepository> = {
save: jest.fn(),
findById: jest.fn(),
findByUserId: jest.fn(),
update: jest.fn(),
};
const mockProductRepo = {
findById: jest.fn().mockResolvedValue({
id: 'prod-1',
name: 'Widget',
price: 1500,
stock: 10,
}),
};
const mockEventPublisher = { publish: jest.fn() };
const useCase = new CreateOrderUseCase(
mockOrderRepo,
mockProductRepo,
mockEventPublisher,
);
const result = await useCase.execute({
userId: 'user-1',
items: [{ productId: 'prod-1', quantity: 2 }],
});
expect(result.total).toBe(3000);
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1);
expect(mockEventPublisher.publish).toHaveBeenCalledWith('order.created', expect.objectContaining({
total: 3000,
}));
});
it('should throw when product has insufficient stock', async () => {
// ...
});
});
Infrastructure Implementation
// src/infrastructure/database/prisma/PrismaOrderRepository.ts
import { PrismaClient } from '@prisma/client';
import { Order, OrderItem } from '../../../domain/entities/Order';
import { IOrderRepository } from '../../../domain/repositories/IOrderRepository';
export class PrismaOrderRepository implements IOrderRepository {
constructor(private readonly prisma: PrismaClient) {}
async findById(id: string): Promise<Order | null> {
const record = await this.prisma.order.findUnique({
where: { id },
include: { items: true },
});
if (!record) return null;
return this.toDomain(record);
}
async save(order: Order): Promise<void> {
await this.prisma.order.create({
data: {
id: order.id,
userId: order.userId,
status: order.status,
total: order.total,
items: {
createMany: {
data: order.items.map(item => ({
productId: item.productId,
name: item.name,
price: item.price,
quantity: item.quantity,
})),
},
},
},
});
}
async update(order: Order): Promise<void> {
await this.prisma.order.update({
where: { id: order.id },
data: { status: order.status },
});
}
async findByUserId(userId: string, options = {}): Promise<Order[]> {
const records = await this.prisma.order.findMany({
where: { userId },
include: { items: true },
take: options.limit ?? 20,
skip: options.offset ?? 0,
orderBy: { createdAt: 'desc' },
});
return records.map(r => this.toDomain(r));
}
private toDomain(record: any): Order {
return new Order({
id: record.id,
userId: record.userId,
status: record.status,
items: record.items.map((i: any) => ({
productId: i.productId,
name: i.name,
price: i.price,
quantity: i.quantity,
})),
});
}
}
Dependency Injection (Without a DI Framework)
// src/infrastructure/container.ts
import { PrismaClient } from '@prisma/client';
import { PrismaOrderRepository } from './database/prisma/PrismaOrderRepository';
import { PrismaProductRepository } from './database/prisma/PrismaProductRepository';
import { EventEmitterPublisher } from './events/EventEmitterPublisher';
import { CreateOrderUseCase } from '../application/orders/CreateOrderUseCase';
import { CancelOrderUseCase } from '../application/orders/CancelOrderUseCase';
const prisma = new PrismaClient();
const orderRepository = new PrismaOrderRepository(prisma);
const productRepository = new PrismaProductRepository(prisma);
const eventPublisher = new EventEmitterPublisher();
export const useCases = {
createOrder: new CreateOrderUseCase(orderRepository, productRepository, eventPublisher),
cancelOrder: new CancelOrderUseCase(orderRepository, eventPublisher),
};
A framework like InversifyJS or tsyringe can do this with decorators, but for most apps the manual composition root is simpler and easier to debug.
HTTP Controller (Presentation Layer)
// src/presentation/http/controllers/OrderController.ts
import { Request, Response } from 'express';
import { useCases } from '../../../infrastructure/container';
import { z } from 'zod';
const CreateOrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1),
})).min(1),
});
export class OrderController {
async create(req: Request, res: Response): Promise<void> {
const parsed = CreateOrderSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
try {
const result = await useCases.createOrder.execute({
userId: req.user.id,
items: parsed.data.items,
});
res.status(201).json(result);
} catch (err: any) {
// Domain errors (business rule violations) → 422
if (err.message.includes('Insufficient stock') || err.message.includes('not found')) {
res.status(422).json({ error: err.message });
return;
}
throw err; // Let global handler deal with unexpected errors
}
}
}
The controller knows about HTTP (status codes, request/response). It knows nothing about databases.
When NOT to Use Clean Architecture
This pattern adds real overhead. Skip it when:
- CRUD apps with no business logic — a simple
POST /usersthat inserts a row doesn't need a use case layer - Prototypes and MVPs — ship first, refactor when business logic actually gets complex
- Small APIs (<20 endpoints) — the ceremony often exceeds the benefit
- Solo developer — the pattern pays off when teams work in parallel
Add layers incrementally. Start with a well-organized Express/Fastify app. Extract use cases when you need to test business logic or reuse it across HTTP + queue handlers.
Aunimeda designs and builds scalable Node.js architectures for production. Let's talk about your system.
See also: tRPC + Zod for type-safe APIs, Supabase vs Firebase 2026