← назад к разделу

В большинстве проектов бизнес-логика постепенно размазывается по сервисам, контроллерам и вспомогательным классам. Через год становится непонятно, где живут правила, кто за что отвечает и почему изменение в одном месте ломает другое.

Тактические паттерны 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, добавляй остальное по необходимости.

Что почитать дальше