Чистая архитектура и DDD в Node.js: практическое руководство для production
Большинство Node.js проектов начинаются чисто и деградируют в «controllers, которые делают всё». Полгода разработки — бизнес-логика в route-хендлерах, Prisma везде, тесты либо отсутствуют либо требуют базу данных. Решение давно известно — нужно только применить правильно.
Ключевая идея: Dependency Rule
Зависимости всегда направлены внутрь — к домену:
Presentation → Application → Domain
Infrastructure → Domain
Domain — ничего не импортирует.
Application — импортирует только Domain.
Infrastructure/Presentation — импортируют всё нужное.
Структура проекта
src/
├── domain/
│ ├── entities/
│ │ ├── Order.ts ← бизнес-логика
│ │ └── Product.ts
│ ├── repositories/
│ │ ├── IOrderRepository.ts ← контракты
│ │ └── IProductRepository.ts
│ ├── services/
│ │ └── IPricingService.ts
│ └── value-objects/
│ ├── Money.ts ← ценностные объекты
│ └── Email.ts
│
├── application/
│ └── orders/
│ ├── CreateOrder.ts ← use case
│ ├── CancelOrder.ts
│ └── GetUserOrders.ts
│
├── infrastructure/
│ ├── database/
│ │ └── prisma/
│ │ └── PrismaOrderRepository.ts
│ ├── payment/
│ │ └── KaspiPayService.ts
│ └── container.ts
│
└── presentation/
└── http/
├── routes/
└── controllers/
Domain: Value Objects
Value Objects — неизменяемые объекты без идентичности. Логика «что значит быть денежной суммой» — здесь:
// src/domain/value-objects/Money.ts
export class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: 'KZT' | 'USD' | 'EUR',
) {
if (_amount < 0) throw new Error('Amount cannot be negative');
if (!Number.isFinite(_amount)) throw new Error('Amount must be finite');
}
static of(amount: number, currency: 'KZT' | 'USD' | 'EUR'): Money {
return new Money(Math.round(amount * 100) / 100, currency);
}
static zero(currency: 'KZT' | 'USD' | 'EUR'): Money {
return new Money(0, currency);
}
get amount(): number { return this._amount; }
get currency(): string { return this._currency; }
add(other: Money): Money {
if (other._currency !== this._currency) {
throw new Error(`Currency mismatch: ${this._currency} vs ${other._currency}`);
}
return new Money(this._amount + other._amount, this._currency);
}
multiply(factor: number): Money {
return new Money(this._amount * factor, this._currency);
}
equals(other: Money): boolean {
return this._amount === other._amount && this._currency === other._currency;
}
toString(): string {
return `${this._amount.toFixed(2)} ${this._currency}`;
}
}
// Использование:
const price = Money.of(4500, 'KZT');
const quantity = 3;
const total = price.multiply(quantity); // 13500.00 KZT
Domain: Entity с бизнес-логикой
// src/domain/entities/Order.ts
import { Money } from '../value-objects/Money';
export type OrderStatus = 'pending' | 'paid' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
export interface OrderItem {
productId: string;
name: string;
unitPrice: Money;
quantity: number;
}
export class Order {
private _items: OrderItem[];
private _status: OrderStatus;
private readonly _events: DomainEvent[] = [];
constructor(
readonly id: string,
readonly userId: string,
readonly storeId: string,
items: OrderItem[],
status: OrderStatus = 'pending',
) {
if (items.length === 0) throw new Error('Order must have at least one item');
this._items = [...items];
this._status = status;
}
get status(): OrderStatus { return this._status; }
get items(): Readonly<OrderItem[]> { return this._items; }
get events(): Readonly<DomainEvent[]> { return this._events; }
get subtotal(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.unitPrice.multiply(item.quantity)),
Money.zero('KZT')
);
}
markPaid(paymentReference: string): void {
if (this._status !== 'pending') {
throw new Error(`Cannot pay order with status: ${this._status}`);
}
this._status = 'paid';
this._events.push({
type: 'OrderPaid',
orderId: this.id,
paymentReference,
amount: this.subtotal,
occurredAt: new Date(),
});
}
cancel(reason: string): void {
const cancellableStatuses: OrderStatus[] = ['pending', 'paid'];
if (!cancellableStatuses.includes(this._status)) {
throw new Error(`Cannot cancel order with status: ${this._status}`);
}
this._status = 'cancelled';
this._events.push({
type: 'OrderCancelled',
orderId: this.id,
reason,
occurredAt: new Date(),
});
}
clearEvents(): void {
this._events.length = 0;
}
}
interface DomainEvent {
type: string;
orderId: string;
occurredAt: Date;
[key: string]: unknown;
}
Application: Use Case
// src/application/orders/CreateOrder.ts
import { Order, OrderItem } from '../../domain/entities/Order';
import { Money } from '../../domain/value-objects/Money';
import { IOrderRepository } from '../../domain/repositories/IOrderRepository';
import { IProductRepository } from '../../domain/repositories/IProductRepository';
import { IEventBus } from '../../domain/services/IEventBus';
import { randomUUID } from 'crypto';
interface CreateOrderInput {
userId: string;
storeId: string;
items: Array<{
productId: string;
quantity: number;
}>;
}
interface CreateOrderOutput {
orderId: string;
subtotal: string;
itemCount: number;
}
export class CreateOrderUseCase {
constructor(
private readonly orders: IOrderRepository,
private readonly products: IProductRepository,
private readonly eventBus: IEventBus,
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
// Validate & build order items
const orderItems: OrderItem[] = [];
for (const input_item of input.items) {
const product = await this.products.findById(input_item.productId);
if (!product) {
throw new Error(`Продукт не найден: ${input_item.productId}`);
}
if (!product.isAvailable) {
throw new Error(`Продукт недоступен: ${product.name}`);
}
if (product.stock < input_item.quantity) {
throw new Error(`Недостаточно товара "${product.name}": доступно ${product.stock}, запрошено ${input_item.quantity}`);
}
orderItems.push({
productId: product.id,
name: product.name,
unitPrice: Money.of(product.priceKzt, 'KZT'),
quantity: input_item.quantity,
});
}
// Create domain entity — business rules enforced here
const order = new Order(
randomUUID(),
input.userId,
input.storeId,
orderItems,
);
// Persist
await this.orders.save(order);
// Publish domain events
for (const event of order.events) {
await this.eventBus.publish(event);
}
order.clearEvents();
return {
orderId: order.id,
subtotal: order.subtotal.toString(),
itemCount: order.items.length,
};
}
}
Тест — нет базы данных:
describe('CreateOrderUseCase', () => {
it('создаёт заказ и считает сумму в тенге', async () => {
const mockOrders = { save: jest.fn(), findById: jest.fn(), findByUserId: jest.fn(), update: jest.fn() };
const mockProducts = {
findById: jest.fn().mockResolvedValue({
id: 'p1', name: 'Ноутбук', priceKzt: 450000, stock: 5, isAvailable: true,
}),
};
const mockEventBus = { publish: jest.fn() };
const useCase = new CreateOrderUseCase(mockOrders, mockProducts, mockEventBus);
const result = await useCase.execute({
userId: 'u1',
storeId: 's1',
items: [{ productId: 'p1', quantity: 2 }],
});
expect(result.subtotal).toBe('900000.00 KZT');
expect(mockOrders.save).toHaveBeenCalledTimes(1);
});
});
Infrastructure: Prisma Repository
// src/infrastructure/database/prisma/PrismaOrderRepository.ts
import { PrismaClient } from '@prisma/client';
import { Order, OrderItem } from '../../../domain/entities/Order';
import { Money } from '../../../domain/value-objects/Money';
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,
storeId: order.storeId,
status: order.status,
subtotalKzt: order.subtotal.amount,
items: {
createMany: {
data: order.items.map(i => ({
productId: i.productId,
name: i.name,
unitPriceKzt: i.unitPrice.amount,
quantity: i.quantity,
})),
},
},
},
});
}
async findById(id: string): Promise<Order | null> {
const r = await this.prisma.order.findUnique({
where: { id },
include: { items: true },
});
return r ? this.toDomain(r) : null;
}
async findByUserId(userId: string): Promise<Order[]> {
const records = await this.prisma.order.findMany({
where: { userId },
include: { items: true },
orderBy: { createdAt: 'desc' },
});
return records.map(r => this.toDomain(r));
}
async update(order: Order): Promise<void> {
await this.prisma.order.update({
where: { id: order.id },
data: { status: order.status },
});
}
private toDomain(record: any): Order {
const items: OrderItem[] = record.items.map((i: any) => ({
productId: i.productId,
name: i.name,
unitPrice: Money.of(i.unitPriceKzt, 'KZT'),
quantity: i.quantity,
}));
return new Order(record.id, record.userId, record.storeId, items, record.status);
}
}
Когда применять DDD + Clean Architecture
Эта архитектура оправдана при:
- Сложных бизнес-правилах (e-commerce, fintech, logistics)
- Команде из 2+ разработчиков
- Планируемой долгосрочной поддержке
- Необходимости тестировать бизнес-логику быстро и надёжно
Избыточно для:
- Простых CRUD-приложений
- Прототипов и MVP
- Команды из одного разработчика с ограниченным временем
Aunimeda проектирует масштабируемые Node.js системы для казахстанского рынка. Обсудим архитектуру.
Смотрите также: tRPC + Zod fullstack типобезопасность, Event-driven архитектура с Kafka