Чистая архитектура в Node.js: как организовать код чтобы не стать заложником
Типичный путь Node.js проекта: всё начинается красиво, через 6 месяцев бизнес-логика размазана по контроллерам, сервисы напрямую импортируют Prisma, тесты требуют поднимать реальную базу данных. Это решаемо — если с самого начала применить несколько принципов.
Главный принцип
Бизнес-логика не должна зависеть от базы данных, фреймворка или любого внешнего сервиса.
Ваш код обработки заказов не должен знать что существует Prisma или Express. Он должен знать только бизнес-правила: как рассчитать стоимость, можно ли отменить заказ, что происходит при оплате.
Почему это важно:
- Можно тестировать бизнес-логику без базы данных
- Можно поменять Prisma на Drizzle не трогая бизнес-логику
- Логика в одном месте, а не размазана по хендлерам
Структура проекта
src/
├── domain/ # Чистая бизнес-логика — никаких зависимостей
│ ├── entities/ # Сущности с методами
│ │ └── Order.ts
│ └── repositories/ # Интерфейсы (что умеет репозиторий)
│ └── IOrderRepository.ts
│
├── application/ # Use cases — что делает приложение
│ └── orders/
│ ├── CreateOrder.ts
│ └── CancelOrder.ts
│
├── infrastructure/ # Конкретные реализации
│ └── database/
│ └── PrismaOrderRepository.ts
│
└── presentation/ # Express routes и controllers
└── http/
└── OrderController.ts
Правило зависимостей: domain ← application ← infrastructure/presentation
Доменная сущность
// src/domain/entities/Order.ts
export class Order {
constructor(
readonly id: string,
readonly userId: string,
private _items: OrderItem[],
private _status: 'pending' | 'paid' | 'shipped' | 'cancelled' = 'pending',
) {}
get status() { return this._status; }
get items() { return [...this._items]; }
get total() {
return this._items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
cancel() {
if (this._status !== 'pending') {
throw new Error(`Нельзя отменить заказ со статусом "${this._status}"`);
}
this._status = 'cancelled';
}
markPaid() {
if (this._status !== 'pending') {
throw new Error(`Заказ уже обработан`);
}
this._status = 'paid';
}
}
export interface OrderItem {
productId: string;
name: string;
price: number;
quantity: number;
}
Никаких импортов. Чистый TypeScript. Тестируется мгновенно.
Интерфейс репозитория
// src/domain/repositories/IOrderRepository.ts
import { Order } from '../entities/Order';
export interface IOrderRepository {
findById(id: string): Promise<Order | null>;
findByUserId(userId: string): Promise<Order[]>;
save(order: Order): Promise<void>;
update(order: Order): Promise<void>;
}
Это контракт. Application layer зависит от этого интерфейса — не от Prisma.
Use Case
// src/application/orders/CreateOrder.ts
import { Order } from '../../domain/entities/Order';
import { IOrderRepository } from '../../domain/repositories/IOrderRepository';
import { IProductRepository } from '../../domain/repositories/IProductRepository';
import { randomUUID } from 'crypto';
export class CreateOrderUseCase {
constructor(
private orders: IOrderRepository,
private products: IProductRepository,
) {}
async execute(input: { userId: string; items: Array<{ productId: string; quantity: number }> }) {
// Проверяем наличие и собираем данные продуктов
const orderItems = await Promise.all(
input.items.map(async (item) => {
const product = await this.products.findById(item.productId);
if (!product) throw new Error(`Продукт ${item.productId} не найден`);
if (product.stock < item.quantity) throw new Error(`Недостаточно товара: ${product.name}`);
return { productId: product.id, name: product.name, price: product.price, quantity: item.quantity };
})
);
const order = new Order(randomUUID(), input.userId, orderItems);
await this.orders.save(order);
return { orderId: order.id, total: order.total };
}
}
Тест без базы данных:
describe('CreateOrderUseCase', () => {
it('создаёт заказ с правильной суммой', async () => {
const orders = { save: jest.fn(), findById: jest.fn(), findByUserId: jest.fn(), update: jest.fn() };
const products = {
findById: jest.fn().mockResolvedValue({ id: 'p1', name: 'Товар', price: 1000, stock: 5 }),
};
const useCase = new CreateOrderUseCase(orders, products);
const result = await useCase.execute({ userId: 'u1', items: [{ productId: 'p1', quantity: 3 }] });
expect(result.total).toBe(3000);
expect(orders.save).toHaveBeenCalledTimes(1);
});
});
Реализация репозитория (Prisma)
// src/infrastructure/database/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 prisma: PrismaClient) {}
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(i => ({
productId: i.productId,
name: i.name,
price: i.price,
quantity: i.quantity,
})),
},
},
},
});
}
async findById(id: string): Promise<Order | null> {
const record = await this.prisma.order.findUnique({
where: { id },
include: { items: true },
});
return record ? new Order(record.id, record.userId, record.items, record.status as any) : null;
}
async findByUserId(userId: string): Promise<Order[]> {
const records = await this.prisma.order.findMany({
where: { userId },
include: { items: true },
});
return records.map(r => new Order(r.id, r.userId, r.items, r.status as any));
}
async update(order: Order): Promise<void> {
await this.prisma.order.update({ where: { id: order.id }, data: { status: order.status } });
}
}
Composition Root
// src/container.ts
import { PrismaClient } from '@prisma/client';
import { PrismaOrderRepository } from './infrastructure/database/PrismaOrderRepository';
import { PrismaProductRepository } from './infrastructure/database/PrismaProductRepository';
import { CreateOrderUseCase } from './application/orders/CreateOrderUseCase';
import { CancelOrderUseCase } from './application/orders/CancelOrderUseCase';
const prisma = new PrismaClient();
const orderRepo = new PrismaOrderRepository(prisma);
const productRepo = new PrismaProductRepository(prisma);
export const useCases = {
createOrder: new CreateOrderUseCase(orderRepo, productRepo),
cancelOrder: new CancelOrderUseCase(orderRepo),
};
// src/presentation/http/OrderController.ts
import { useCases } from '../../container';
import { z } from 'zod';
export async function createOrder(req: Request, res: Response) {
const schema = z.object({
items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1) })),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
try {
const result = await useCases.createOrder.execute({ userId: req.user.id, items: parsed.data.items });
res.status(201).json(result);
} catch (err: any) {
res.status(422).json({ error: err.message });
}
}
Когда применять
Этот подход добавляет слои. Применяйте его когда:
- Бизнес-логика начинает усложняться
- Нужно тестировать логику без базы данных
- Несколько источников вызова (HTTP + очередь задач)
- Команда больше одного разработчика
Для простого CRUD — хорошо организованный Express без лишних слоёв достаточно.
Aunimeda проектирует масштабируемые Node.js архитектуры для production. Обсудим ваш проект.
Смотрите также: Next.js 15 Server Components, Мониторинг Node.js production