AboutBlogContact
DevelopmentApril 18, 2026 8 min read 2

Clean Architecture in Node.js: A Practical Guide Without the Academic Fluff

AunimedaAunimeda
📋 Table of Contents

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 layers
  • application/ imports only from domain/
  • infrastructure/ and presentation/ 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 /users that 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

Read Also

Node.js vs Bun vs Deno in 2026: Runtime Comparison with Real Benchmarksaunimeda
Development

Node.js vs Bun vs Deno in 2026: Runtime Comparison with Real Benchmarks

Bun 1.x is production-stable. Deno 2.0 supports npm packages. Node.js 22 has native TypeScript. The runtime landscape changed. Here's what the numbers actually show and when each runtime makes sense for real projects.

YooKassa (ЮKassa) Integration Guide for Node.js and TypeScript (2026)aunimeda
Development

YooKassa (ЮKassa) Integration Guide for Node.js and TypeScript (2026)

Complete guide to integrating YooKassa payment gateway in Node.js and TypeScript apps. Payments, webhooks, refunds, split payments, and 54-FZ receipt generation.

Web Vitals & Lighthouse 100: Practical Optimization Guide 2026aunimeda
Development

Web Vitals & Lighthouse 100: Practical Optimization Guide 2026

Achieving Lighthouse 100 on a real-world production Next.js app — not a blank page. Covers LCP, INP (replaced FID in 2024), CLS, TTFB, font optimization, image optimization, JS bundle analysis, and CSS critical path — with specific code changes.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles