Реализация Domain-Driven Design (DDD) для веб-приложения
DDD — методология проектирования ПО, при которой архитектура системы отражает бизнес-домен. Код говорит на том же языке, что и эксперты предметной области. Не «обновить запись в таблице users», а «заблокировать аккаунт за нарушение политики». DDD оправдан для сложных доменов — e-commerce с нетривиальными правилами ценообразования, финансовых систем, SaaS с гибкими тарифами.
Ubiquitous Language
Первый шаг — создание единого словаря с бизнес-экспертом. Термины кодируются в код напрямую.
Плохо: updateUserStatus(userId, 1) или setActive(true)
Хорошо: customer.activate(), subscription.suspend(reason), order.submit()
Словарь фиксируется в документе и поддерживается в актуальности при изменении требований.
Bounded Context
Большую систему делят на Bounded Context — ограниченные контексты, в каждом из которых термины имеют строго определённый смысл. «Продукт» в контексте Каталога — это описание, фото, SEO. «Продукт» в контексте Заказов — SKU, цена, количество. «Продукт» в контексте Склада — физическая единица с местом хранения.
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Catalog Context │ │ Orders Context │ │ Inventory Context │
│ │ │ │ │ │
│ Product │ │ OrderItem │ │ StockItem │
│ Category │ │ Order │ │ Warehouse │
│ PriceList │ │ Customer │ │ StockMovement │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
↑ Anti-Corruption Layer между контекстами
Между контекстами — явные границы и маппинг. Context Map документирует взаимодействие.
Строительные блоки
Entity — объект с идентичностью, сохраняющейся при изменении атрибутов:
class Order {
private readonly _id: OrderId;
private _status: OrderStatus;
private _items: OrderItem[] = [];
private _domainEvents: DomainEvent[] = [];
constructor(id: OrderId, customerId: CustomerId) {
this._id = id;
this._status = OrderStatus.Draft;
this.raise(new OrderCreatedEvent(id, customerId));
}
addItem(product: Product, quantity: Quantity): void {
if (this._status !== OrderStatus.Draft) {
throw new OrderNotEditableError(this._id);
}
if (quantity.isZero()) {
throw new InvalidQuantityError();
}
const existing = this._items.find(i => i.productId.equals(product.id));
if (existing) {
existing.increaseQuantity(quantity);
} else {
this._items.push(new OrderItem(product.id, product.price, quantity));
}
}
submit(): void {
this.ensureCanTransitionTo(OrderStatus.Submitted);
if (this._items.length === 0) throw new EmptyOrderError();
this._status = OrderStatus.Submitted;
this.raise(new OrderSubmittedEvent(this._id, this.calculateTotal()));
}
get id(): OrderId { return this._id; }
get total(): Money { return this.calculateTotal(); }
pullDomainEvents(): DomainEvent[] { /* ... */ }
}
Value Object — объект без идентичности, определяемый значениями. Неизменяемый:
class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: Currency
) {
if (_amount < 0) throw new NegativeAmountError();
}
static of(amount: number, currency: Currency): Money {
return new Money(amount, currency);
}
add(other: Money): Money {
if (!this._currency.equals(other._currency)) {
throw new CurrencyMismatchError();
}
return new Money(this._amount + other._amount, this._currency);
}
multiply(factor: number): Money {
return new Money(Math.round(this._amount * factor * 100) / 100, this._currency);
}
equals(other: Money): boolean {
return this._amount === other._amount && this._currency.equals(other._currency);
}
}
class Email {
private constructor(private readonly value: string) {}
static create(email: string): Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new InvalidEmailError(email);
}
return new Email(email.toLowerCase());
}
}
Aggregate — кластер связанных сущностей с единой точкой входа (Aggregate Root). Внешний код работает только с корнем агрегата:
// Order — Aggregate Root
// OrderItem — входит в агрегат Order, не доступен напрямую снаружи
class Order {
// ...всё взаимодействие с items только через Order
removeItem(productId: ProductId): void { /* ... */ }
updateQuantity(productId: ProductId, qty: Quantity): void { /* ... */ }
}
Правило: одна транзакция = один агрегат. Между агрегатами — eventual consistency через доменные события.
Domain Service — операция, не принадлежащая ни одной сущности:
class OrderPricingService {
constructor(
private discountRepo: DiscountRepository,
private taxService: TaxCalculationService
) {}
async calculateTotal(order: Order, customer: Customer): Promise<PricingResult> {
const discounts = await this.discountRepo.findApplicable(
customer.segment, order.items
);
let subtotal = order.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity.value)),
Money.zero(Currency.USD)
);
const discountAmount = this.applyDiscounts(subtotal, discounts, customer);
const taxAmount = await this.taxService.calculate(subtotal, customer.address);
return new PricingResult(subtotal, discountAmount, taxAmount);
}
}
Repository — абстракция доступа к хранилищу для агрегата:
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId, options?: FindOptions): Promise<Order[]>;
save(order: Order): Promise<void>;
delete(id: OrderId): Promise<void>;
}
// Реализация отделена от интерфейса (инверсия зависимостей)
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> {
const row = await this.db.queryOne(
'SELECT * FROM orders WHERE id = $1', [id.value]
);
return row ? this.toDomain(row) : null;
}
private toDomain(row: OrderRow): Order {
return Order.reconstitute({
id: OrderId.from(row.id),
status: OrderStatus[row.status],
customerId: CustomerId.from(row.customer_id),
items: row.items.map(this.itemToDomain)
});
}
}
Application Layer
Тонкий слой, оркестрирующий доменные объекты. Не содержит бизнес-логику:
class PlaceOrderUseCase {
async execute(dto: PlaceOrderDto): Promise<PlaceOrderResult> {
const customer = await this.customerRepo.findById(
CustomerId.from(dto.customerId)
);
if (!customer) throw new CustomerNotFoundError(dto.customerId);
const order = new Order(OrderId.generate(), customer.id);
for (const item of dto.items) {
const product = await this.productRepo.findById(ProductId.from(item.productId));
order.addItem(product, Quantity.of(item.quantity));
}
const pricing = await this.pricingService.calculateTotal(order, customer);
order.applyPricing(pricing);
order.submit();
await this.orderRepo.save(order);
await this.eventBus.publishAll(order.pullDomainEvents());
return { orderId: order.id.value, total: order.total };
}
}
Слоистая архитектура
Domain Layer — Entities, Value Objects, Aggregates, Domain Services, Events
Application Layer — Use Cases, Application Services, DTOs, Ports
Infrastructure Layer — Repositories (PostgreSQL/Redis), External APIs, Message Brokers
Presentation Layer — HTTP Controllers, GraphQL Resolvers, CLI Commands
Зависимости направлены только внутрь: Presentation → Application → Domain. Domain не зависит ни от чего.
Сроки реализации
- Проектирование домена, Context Map, Ubiquitous Language — 1–2 недели
- Реализация одного Bounded Context с 3–5 агрегатами — 3–5 недель
- Полная система с несколькими контекстами и интеграцией — 2–4 месяца







