Антон Ларичев

Введение
Domain-Driven Design (DDD) — подход к разработке, при котором модель предметной области становится центром архитектуры. TypeScript с его строгой типизацией идеально подходит для реализации принципов DDD: типы помогают выразить доменные концепции явно и защитить инварианты прямо на уровне компилятора.
В этом руководстве разберём ключевые строительные блоки DDD и реализуем их на примере системы управления заказами.
Объекты-значения (Value Objects)
Объект-значение не имеет идентичности — два объекта с одинаковыми данными считаются равными. Они всегда иммутабельны.
class Money {
private constructor(
private readonly amount: number,
private readonly currency: string
) {
// сумма не может быть отрицательной
if (amount < 0) throw new Error('Сумма не может быть отрицательной');
}
static create(amount: number, currency: string): Money {
return new Money(amount, currency);
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Нельзя складывать разные валюты');
}
return new Money(this.amount + other.amount, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
getAmount(): number { return this.amount; }
getCurrency(): string { return this.currency; }
}
Приватный конструктор и фабричный метод create гарантируют проверку инвариантов в одной точке. Внешний код не может создать невалидный объект в обход этой проверки.
Сущности (Entities)
Сущность имеет уникальный идентификатор. Два объекта с одинаковыми данными, но разными ID — это разные сущности.
class OrderId {
constructor(private readonly value: string) {
if (!value) throw new Error('ID заказа не может быть пустым');
}
getValue(): string { return this.value; }
equals(other: OrderId): boolean { return this.value === other.value; }
}
class OrderItem {
constructor(
private readonly productId: string,
private readonly quantity: number,
private readonly price: Money
) {
// количество должно быть строго положительным
if (quantity <= 0) throw new Error('Количество должно быть больше нуля');
}
getTotalPrice(): Money {
return Money.create(
this.price.getAmount() * this.quantity,
this.price.getCurrency()
);
}
}
Агрегаты (Aggregates)
Агрегат — кластер сущностей и объектов-значений с единым корнем (Aggregate Root). Все изменения состояния проходят только через корень.
type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'cancelled';
class Order {
private items: OrderItem[] = [];
private status: OrderStatus = 'pending';
private readonly domainEvents: DomainEvent[] = [];
private constructor(private readonly id: OrderId) {}
static create(id: OrderId): Order {
const order = new Order(id);
// публикуем событие о создании заказа
order.addEvent(new OrderCreatedEvent(id.getValue()));
return order;
}
addItem(item: OrderItem): void {
if (this.status !== 'pending') {
throw new Error('Нельзя добавить товар в уже подтверждённый заказ');
}
this.items.push(item);
}
confirm(): void {
if (this.items.length === 0) {
throw new Error('Нельзя подтвердить пустой заказ');
}
this.status = 'confirmed';
this.addEvent(new OrderConfirmedEvent(this.id.getValue()));
}
getTotalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.getTotalPrice()),
Money.create(0, 'RUB')
);
}
private addEvent(event: DomainEvent): void {
this.domainEvents.push(event);
}
getUncommittedEvents(): DomainEvent[] { return [...this.domainEvents]; }
clearEvents(): void { this.domainEvents.length = 0; }
getId(): OrderId { return this.id; }
}
Доменные события (Domain Events)
События описывают значимые факты, которые уже произошли в домене. Они позволяют компонентам реагировать на изменения, оставаясь слабосвязанными.
interface DomainEvent {
readonly occurredAt: Date;
readonly eventType: string;
}
class OrderCreatedEvent implements DomainEvent {
readonly occurredAt = new Date();
readonly eventType = 'OrderCreated';
constructor(readonly orderId: string) {}
}
class OrderConfirmedEvent implements DomainEvent {
readonly occurredAt = new Date();
readonly eventType = 'OrderConfirmed';
constructor(readonly orderId: string) {}
}
Репозитории (Repositories)
Репозиторий абстрагирует работу с хранилищем. В доменном слое живёт только интерфейс — реализация находится в инфраструктурном слое.
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
nextId(): OrderId;
}
// реализация в инфраструктурном слое
class InMemoryOrderRepository implements OrderRepository {
private orders = new Map<string, Order>();
async findById(id: OrderId): Promise<Order | null> {
return this.orders.get(id.getValue()) ?? null;
}
async save(order: Order): Promise<void> {
this.orders.set(order.getId().getValue(), order);
// здесь публикуем доменные события через шину событий
order.clearEvents();
}
nextId(): OrderId {
return new OrderId(crypto.randomUUID());
}
}
Доменные службы (Domain Services)
Если бизнес-логика не принадлежит ни одной сущности — она выносится в доменную службу.
class OrderPricingService {
calculateDiscount(order: Order, customerTier: string): Money {
const total = order.getTotalAmount();
// скидка 10% для VIP-клиентов
if (customerTier === 'vip') {
return Money.create(total.getAmount() * 0.1, total.getCurrency());
}
return Money.create(0, total.getCurrency());
}
}
Частые ошибки
Анемичная модель домена. Сущности содержат только геттеры и сеттеры, вся логика вынесена в сервисы. Это антипаттерн: инварианты должны защищаться внутри агрегатов, а не снаружи.
Нарушение границ агрегата. Прямое изменение дочерних сущностей в обход корня разрушает инкапсуляцию и делает инварианты неконтролируемыми.
Утечка доменной логики в прикладной слой. Application Services оркестрируют, но не содержат бизнес-правила. Условия if/else на основе бизнес-правил в UseCase — сигнал переместить логику в домен.
Слишком большие агрегаты. Одна транзакция должна изменять один агрегат. Стремитесь к минимально возможному размеру.
Отсутствие единого языка (Ubiquitous Language). Если имена классов расходятся с терминами бизнеса, команда постоянно теряет время на «перевод» между кодом и требованиями.
Заключение
Domain-Driven Design на TypeScript даёт мощный инструментарий для моделирования сложных предметных областей. Строгая типизация усиливает DDD: Value Objects защищают инварианты, Aggregates инкапсулируют бизнес-правила, Domain Events делают систему реактивной и слабосвязанной.
Начните с небольшого Bounded Context, выстройте единый язык с командой, и DDD из теоретической концепции превратится в рабочий инструмент для решения реальных задач.






Комментарии
0