Чистая архитектура в Node.js: практическое руководство без академизма
Каждые несколько месяцев появляется статья «Чистая архитектура в Node.js» с 15 папками, 6 слоями абстракции и диаграммой. Потом пытаешься внедрить — и тратишь больше времени на управление косвенностью, чем на написание фич.
Это руководство — о том, что реально работает в production Node.js TypeScript проектах. Какие части Clean Architecture стоит сохранить, какие пропустить, и конкретный рабочий код.
Ключевой принцип, который стоит сохранить
В Clean Architecture Боба Мартина есть одна действительно важная идея:
Бизнес-логика не должна зависеть от инфраструктуры (фреймворков, баз данных, внешних сервисов).
Код обработки заказов не должен напрямую импортировать Prisma, Express или Stripe. Почему:
- Тестирование: бизнес-логику можно тестировать без базы данных
- Замена: переход с Prisma на Drizzle не затрагивает бизнес-логику
- Ясность: бизнес-правила в одном месте, не размазаны по Express-хендлерам
Всё остальное — слои, диаграммы, именование — вторично.
Структура папок, которая работает
src/
├── domain/ # Сущности и бизнес-правила
│ ├── entities/
│ │ ├── Order.ts
│ │ └── User.ts
│ └── repositories/ # Интерфейсы (контракты)
│ ├── IOrderRepository.ts
│ └── IUserRepository.ts
│
├── application/ # Use cases (что может делать приложение)
│ ├── orders/
│ │ ├── CreateOrder.ts
│ │ ├── GetUserOrders.ts
│ │ └── CancelOrder.ts
│ └── users/
│ └── RegisterUser.ts
│
├── infrastructure/ # Конкретные реализации
│ ├── database/
│ │ └── prisma/
│ │ ├── PrismaOrderRepository.ts
│ │ └── PrismaUserRepository.ts
│ ├── email/
│ │ └── SendgridEmailService.ts
│ └── payment/
│ └── StripePaymentService.ts
│
└── presentation/ # HTTP, очереди — код фреймворка
├── http/
│ ├── routes/
│ └── controllers/
└── app.ts
Три ключевых правила:
domain/ничего не импортирует из других слоёвapplication/импортирует только изdomain/infrastructure/иpresentation/могут импортировать откуда угодно
Доменные сущности
Сущности содержат бизнес-логику, не только данные:
// 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; }
// Бизнес-правило: можно отменить только pending заказы
cancel(): void {
if (this._status !== 'pending') {
throw new Error(`Нельзя отменить заказ со статусом: ${this._status}`);
}
this._status = 'cancelled';
}
// Бизнес-правило: доставка только после оплаты
markShipped(): void {
if (this._status !== 'paid') {
throw new Error(`Заказ должен быть оплачен перед отправкой`);
}
this._status = 'shipped';
}
private calculateTotal(): number {
return this._items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
Обратите внимание: нет импортов библиотек. Чистый TypeScript. Тестируется без базы данных.
Интерфейс репозитория (инверсия зависимостей)
// 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>;
}
Это контракт. Слой application зависит от этого интерфейса — не от Prisma, не от конкретной базы данных.
Use Cases (слой application)
// 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 }>;
}
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: IOrderRepository,
private readonly productRepository: IProductRepository,
private readonly eventPublisher: IEventPublisher,
) {}
async execute(input: CreateOrderInput) {
// 1. Проверяем что продукты существуют и есть в наличии
const orderItems: OrderItem[] = [];
for (const item of input.items) {
const product = await this.productRepository.findById(item.productId);
if (!product) throw new Error(`Продукт ${item.productId} не найден`);
if (product.stock < item.quantity) {
throw new Error(`Недостаточно товара: ${item.productId}`);
}
orderItems.push({
productId: product.id,
name: product.name,
price: product.price,
quantity: item.quantity,
});
}
// 2. Создаём сущность заказа (бизнес-логика здесь)
const order = new Order({
id: randomUUID(),
userId: input.userId,
items: orderItems,
});
// 3. Сохраняем
await this.orderRepository.save(order);
// 4. Публикуем доменное событие
await this.eventPublisher.publish('order.created', {
orderId: order.id,
userId: order.userId,
total: order.total,
});
return { orderId: order.id, total: order.total };
}
}
Use case тестируется полностью в изоляции — мок-репозитории, без базы данных:
describe('CreateOrderUseCase', () => {
it('должен создать заказ с правильной суммой', async () => {
const mockOrderRepo = {
save: jest.fn(),
findById: jest.fn(),
findByUserId: jest.fn(),
update: jest.fn(),
};
const mockProductRepo = {
findById: jest.fn().mockResolvedValue({
id: 'prod-1',
name: 'Виджет',
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);
});
});
Реализация инфраструктуры
// src/infrastructure/database/prisma/PrismaOrderRepository.ts
import { PrismaClient } from '@prisma/client';
import { Order } 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 без фреймворка
// 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),
};
Фреймворки InversifyJS или tsyringe добавят декораторы, но для большинства приложений ручная composition root проще и понятнее при дебаге.
HTTP контроллер (слой presentation)
// 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) {
if (err.message.includes('Недостаточно товара') || err.message.includes('не найден')) {
res.status(422).json({ error: err.message });
return;
}
throw err;
}
}
}
Контроллер знает про HTTP (статусы, request/response). Ничего не знает про базы данных.
Когда НЕ нужна чистая архитектура
Этот паттерн добавляет реальный overhead. Пропустите его если:
- CRUD без бизнес-логики — простой
POST /usersс INSERT не нуждается в use case - Прототипы и MVP — сначала запустите, рефакторинг когда бизнес-логика реально усложнится
- Маленькие API (<20 эндпоинтов) — церемония часто превышает пользу
- Один разработчик — паттерн окупается когда команды работают параллельно
Добавляйте слои инкрементально. Начните с хорошо организованного Express/Fastify приложения. Выносите use cases когда нужно тестировать бизнес-логику или переиспользовать её в нескольких контекстах (HTTP + очереди).
Aunimeda проектирует и строит масштабируемые Node.js архитектуры для production. Обсудить архитектуру.
Смотрите также: tRPC + Zod типобезопасные API, Supabase vs Firebase 2026