В большинстве проектов бизнес-логика постепенно размазывается по сервисам, контроллерам и вспомогательным классам. Через год становится непонятно, где живут правила, кто за что отвечает и почему изменение в одном месте ломает другое.
Тактические паттерны DDD дают структуру для организации этой логики. Каждый паттерн отвечает на конкретный вопрос: что является объектом с уникальной идентичностью, что — просто значением, где граница консистентности, как части системы общаются через события.
Entity — объект с идентичностью
Представьте пользователя в системе. У него есть имя и email — они могут меняться. Но пользователь остаётся тем же пользователем, даже если поменял всё. Его идентичность — это идентификатор, а не набор полей.
Entity — объект, у которого есть уникальный идентификатор. Два объекта с одинаковыми полями, но разными ID — разные сущности.
Ключевые свойства:
- Есть стабильный ID (не меняется в течение жизни объекта).
- Состояние может меняться — но только через методы, которые проверяют бизнес-правила.
- Равенство по ID, не по полям.
- Содержит поведение, а не только данные.
Частые примеры: User, Order, Invoice, Shipment, Contract.
Типичная ошибка — «анемичная» Entity: класс с геттерами и сеттерами без правил. Тогда бизнес-логика утекает в сервисы, а объект становится структурой данных.
export class User {
private readonly id: string;
private email: Email;
private name: string;
private active: boolean;
private constructor(id: string, email: Email, name: string) {
this.id = id;
this.email = email;
this.name = name;
this.active = true;
}
static create(id: string, email: Email, name: string): User {
if (!id) throw new Error('id is required');
if (!email) throw new Error('email is required');
if (!name || name.trim() === '') throw new Error('name is required');
return new User(id, email, name);
}
changeEmail(newEmail: Email): void {
if (!this.active) throw new Error('Inactive user cannot change email');
if (!newEmail) throw new Error('email is required');
this.email = newEmail;
}
deactivate(): void {
this.active = false;
}
equals(other: User): boolean {
return this.id === other.id;
}
}
Метод changeEmail — не просто сеттер. Он проверяет бизнес-правило: неактивный пользователь не может менять email. Это ключевое отличие от простой записи.
Value Object — объект без идентичности
Деньги — хороший пример. Две купюры по 100 рублей одинаковы — нас не интересует, «та же» ли это купюра или другая. Главное — сумма и валюта.
Value Object — объект, у которого нет собственного ID. Равенство определяется по значениям полей. После создания не меняется (иммутабельный).
Ключевые свойства:
- Нет ID.
- Иммутабельный: вместо изменения создаётся новый объект.
- Равенство по всем значимым полям.
- Проверяет свои инварианты при создании.
Частые примеры: Money, Email, PhoneNumber, Address, DateRange.
Зачем нужны Value Object: они убирают «примитивную одержимость» — когда вместо типа Email везде ходит строка string. Правила формата, валидация и операции живут внутри типа, а не разбросаны по коду.
export type Currency = 'RUB' | 'USD' | 'EUR';
export class Money {
private readonly amount: number;
private readonly currency: Currency;
constructor(amount: number, currency: Currency) {
if (amount == null || Number.isNaN(amount)) throw new Error('amount is required');
if (currency == null) throw new Error('currency is required');
this.amount = Math.round(amount * 100) / 100;
this.currency = currency;
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Currency mismatch');
}
return new Money(this.amount + other.amount, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
Метод add возвращает новый объект — исходный не меняется. Проверка совместимости валют встроена в операцию, а не лежит где-то снаружи.
Aggregate — граница консистентности
Вот реальная проблема: у заказа есть строки. Можно ли изменить строку напрямую, минуя заказ? Формально — да. Но тогда заказ оказывается в некорректном состоянии: например, сумма не пересчитана, или добавили строку в уже подтверждённый заказ.
Aggregate — это кластер Entity и Value Object с единой границей консистентности. Снаружи виден только корень (Aggregate Root) — только через него можно менять состояние агрегата.
Правила:
- Все изменения внутри агрегата идут через корень.
- Нельзя хранить прямые ссылки на внутренние объекты другого агрегата — только ID.
- Агрегат должен быть небольшим. «Бог-агрегат» с десятками объектов внутри — антипаттерн.
export type OrderStatus = 'DRAFT' | 'CONFIRMED';
export class Order {
private readonly id: OrderId;
private readonly customerId: CustomerId;
private readonly lines: OrderLine[] = [];
private status: OrderStatus = 'DRAFT';
private constructor(id: OrderId, customerId: CustomerId) {
this.id = id;
this.customerId = customerId;
}
static create(id: OrderId, customerId: CustomerId): Order {
return new Order(id, customerId);
}
addLine(productId: ProductId, qty: number, price: Money): void {
this.ensureDraft();
if (qty <= 0) throw new Error('qty must be > 0');
this.lines.push(new OrderLine(productId, qty, price));
}
confirm(): void {
this.ensureDraft();
if (this.lines.length === 0) throw new Error('Order has no lines');
this.status = 'CONFIRMED';
}
total(): Money {
return this.lines
.map((line) => line.subtotal())
.reduce((sum, subtotal) => sum.add(subtotal), Money.zero('RUB'));
}
private ensureDraft(): void {
if (this.status !== 'DRAFT') {
throw new Error('Order is not editable');
}
}
}
OrderLine — внутренний объект агрегата. Снаружи его напрямую не трогают — только через методы Order.
Domain Event — что произошло в домене
Когда заказ оплачен, нужно: отправить письмо, уведомить склад, начислить бонусы. Один способ — вызвать всё это прямо в методе pay(). Но тогда Order знает про email, склад и бонусы — это нарушает границы ответственности.
Domain Event — объект, который фиксирует факт: что-то значимое произошло в домене. Агрегат публикует событие, а другие части системы реагируют самостоятельно.
Характеристики:
- Иммутабельный — факт в прошлом нельзя изменить.
- Именование в прошедшем времени:
OrderPaid,UserRegistered, неPayOrder. - Несёт контекст:
orderId,amount,paidAt— не просто маркер. - Агрегат накапливает события и отдаёт их после сохранения.
export abstract class DomainEvent {
readonly eventId: string = crypto.randomUUID();
readonly occurredAt: Date = new Date();
}
export class OrderPaid extends DomainEvent {
constructor(
readonly orderId: string,
readonly amount: number,
) {
super();
}
}
export abstract class AggregateRoot {
private readonly domainEvents: DomainEvent[] = [];
protected registerEvent(event: DomainEvent): void {
this.domainEvents.push(event);
}
getDomainEvents(): ReadonlyArray<DomainEvent> {
return [...this.domainEvents];
}
clearEvents(): void {
this.domainEvents.length = 0;
}
}
Агрегат вызывает registerEvent(new OrderPaid(...)) при изменении состояния. После сохранения агрегата репозиторий забирает накопленные события и публикует их — это гарантирует, что события уходят только после успешной записи.
Внутренние события живут внутри одного bounded context, часто в одной транзакции. Внешние пересекают границы через брокер сообщений (Kafka, RabbitMQ) и требуют сериализации.
Repository — доступ к агрегатам
Как правило, когда нужно сохранить заказ, пишут SQL прямо в сервисе. Запросы выходят за пределы бизнес-логики, привязываются к конкретной БД, и поменять хранилище становится дорого.
Repository — абстракция доступа к хранилищу агрегатов. Он создаёт иллюзию коллекции объектов в памяти: найти, сохранить. Интерфейс живёт в слое домена, реализация — в инфраструктуре.
Ключевые правила:
- Один репозиторий — один агрегат.
- Загружает и сохраняет агрегат целиком.
- Методы в терминах домена:
findById,save— не SQL. - Интерфейс в домене, реализация в инфраструктуре (инверсия зависимостей).
// Интерфейс — в домене
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// Реализация — в инфраструктуре, публикует события после сохранения
export class OrderRepositoryImpl implements OrderRepository {
constructor(
private readonly dao: OrderDao,
private readonly eventPublisher: EventPublisher,
) {}
async save(order: Order): Promise<void> {
await this.dao.save(order);
for (const event of order.getDomainEvents()) {
await this.eventPublisher.publish(event);
}
order.clearEvents();
}
}
Разница с DAO: Repository работает с агрегатом целиком и думает на языке домена. DAO работает с таблицей и думает на языке хранилища.
Domain Service — логика между агрегатами
Иногда операция не принадлежит ни одному агрегату. Перевод денег между счетами: нужно снять с одного и зачислить на другой. Ни Account-от-отправителя, ни Account-получателя не должны знать друг о друге.
Domain Service — место для бизнес-логики, которая координирует несколько агрегатов или объектов домена.
Правило: сначала попробуй поместить логику в Entity или Aggregate Root. Domain Service — если логика действительно не принадлежит ни одному из них.
export class TransferService {
transfer(from: Account, to: Account, amount: Money): void {
from.withdraw(amount);
to.deposit(amount);
}
}
Domain Service живёт в слое домена и знает только про доменные объекты. Не путать с Application Service — тот живёт в слое приложения и оркестрирует: загрузить агрегат, вызвать доменную логику, сохранить.
Factory — создание агрегатов
Иногда создание агрегата — это не просто new. Нужно проверить бизнес-правила, сгенерировать ID, собрать объект из нескольких частей.
Factory инкапсулирует логику создания агрегата. Если конструктор справляется — фабрика не нужна.
export class OrderFactory {
createNew(customer: Customer): Order {
if (customer.isBlocked()) {
throw new Error('Blocked customer cannot place orders');
}
return Order.create(OrderId.generate(), customer.id);
}
}
Здесь проверяется бизнес-правило («заблокированный клиент не может оформить заказ») до создания агрегата. Сам конструктор Order не знает про Customer — это разделение обязанностей.
Коротко
- Entity — объект с уникальным ID; равенство по ID; содержит поведение и проверяет инварианты.
- Value Object — нет ID, иммутабельный, равенство по значениям; убирает «примитивную одержимость».
- Aggregate — кластер объектов с единой границей консистентности; снаружи виден только корень.
- Domain Event — иммутабельный факт в прошедшем времени; агрегат накапливает события, репозиторий публикует их после сохранения.
- Repository — абстракция хранилища агрегатов; интерфейс в домене, реализация в инфраструктуре.
- Domain Service — бизнес-логика, которая не принадлежит ни одному агрегату; крайнее средство, не первый выбор.
- Factory — создание агрегата со сложной логикой; если конструктор справляется — не нужна.
- Не нужно применять все паттерны сразу. Начни с Entity и Value Object, добавляй остальное по необходимости.
Что почитать дальше
- Что такое DDD и зачем он нужен — стратегический уровень: bounded context, ubiquitous language.
- Стратегические паттерны DDD — как делить систему на контексты и выстраивать отношения между ними.
- Интеграционные паттерны DDD — как bounded contexts общаются между собой.