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

Покупатель добавил в корзину третий товар, увидел на экране 1 500, а с карты списали 900. Строку в заказ добавил один сервис, итог пересчитывает другой, а подтверждение третий проверял по старой копии. Ни в одном нет ошибки: правило «итог равен сумме строк» не принадлежит никому и держится на том, что все три вызывают в правильном порядке.

Тактические паттерны DDD дают каждому правилу хозяина. Entity и Value Object говорят, что узнают по идентификатору, а что по значению; Aggregate проводит границу, внутри которой правила обязаны выполняться после каждой транзакции; Domain Event сообщает остальным, что случилось; Repository прячет хранилище. Главный — Aggregate: остальные либо живут внутри его границы, либо обслуживают её.

внутри границы — одна транзакция, за границей — только ссылка по id Агрегат Order Order #42 DRAFT строка #1 500 строка #2 400 строка #3 600 итого 900 итого 1 500транзакция 1 — сразу инвариант: итого = сумма строк итого ещё не сошлось customerId только id Агрегат Customer #7 адрес, телефон меняется своей транзакцией Order #42 CONFIRMEDсобытиеOrderConfirmed Агрегат Reservation резерв: нет резерв: 3 позициитранзакция 2 — позже своя граница, свои правила

Новая строка и пересчитанный итог ложатся в базу одной транзакцией: либо оба изменения, либо ни одного, и после неё правило «итого = сумма строк» обязано выполняться. За границей живут другие агрегаты: на клиента заказ ссылается только идентификатором, а резерв на складе меняется своей транзакцией по событию и отстаёт на секунды.

Обязательно

Entity — объект, который узнают по идентификатору

Пользователь сменил почту, потом имя, потом телефон. Исходных полей не осталось, но история заказов, подписки и штрафы должны остаться при нём. Сравнивать такие объекты по полям нельзя: два Ивана Петрова из одного города — разные люди, а один человек до и после смены почты — тот же. Объект, который узнают по идентификатору, а не по содержимому, — Entity.

Отсюда всё остальное: идентификатор выдаётся при создании и не меняется, equals и hashCode считаются только по нему. Поля меняются не напрямую, а методом с именем из языка предметной области, который проверяет правило до того, как что-то поменять.

public class User {
    private final UUID id;
    private Email email;
    private boolean active = true;

    public User(UUID id, Email email) {
        this.id = Objects.requireNonNull(id);
        this.email = Objects.requireNonNull(email);
    }

    public void changeEmail(Email newEmail) {
        if (!active) throw new IllegalStateException("Inactive user cannot change email");
        this.email = Objects.requireNonNull(newEmail);
    }

    public void deactivate() { this.active = false; }

    @Override
    public boolean equals(Object o) {
        return o instanceof User other && id.equals(other.id);
    }

    @Override
    public int hashCode() { return id.hashCode(); }
}

changeEmail отличается от сеттера одной строкой, и именно она держит правило «деактивированный пользователь почту не меняет». Класс с публичными сеттерами и без таких строк называют анемичной моделью: правила утекают в сервисы, и через год их три версии в трёх местах — см. принципы проектирования.

Value Object — объект, который узнают по значению

Две суммы по 100 рублей неразличимы: спрашивать, «та же» это сотня или другая, бессмысленно, важны число и валюта. Адрес, интервал дат, почта устроены так же. Объект без идентификатора, равный другому по содержимому, — Value Object. Раз идентичности нет, менять его незачем: сумма плюс сумма — новая сумма.

Без такого типа деньги ходят по коду как double amount и String currency в соседних полях, и однажды 100 долларов складываются с 100 рублями, потому что проверка валюты стоит в одном сервисе из четырёх. Value Object собирает правило в одно место: сложить деньги можно только через add, а add сначала сверяет валюту.

public final class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        this.amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
        this.currency = currency;
    }

    public static Money zero(Currency currency) {
        return new Money(BigDecimal.ZERO, currency);
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("Currency mismatch: " + currency + " and " + other.currency);
        return new Money(amount.add(other.amount), currency);
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof Money m && amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
    }

    @Override
    public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); }
}

Число знаков после запятой спрашивается у валюты, а не зашивается двойкой: у рубля их два, у иены ноль, у кувейтского динара три. Жёсткая двойка молча округлила бы иены до несуществующих сотых, а у динара потеряла бы третий знак.

Своей таблицы у Value Object нет: нет идентификатора, по которому её связать. Money в строке заказа — две колонки в order_lines, price_amount numeric(19,4) и price_currency char(3), из которых объект собирается при чтении. Адрес доставки — колонки с префиксом shipping_* в orders или, если по адресу никто не ищет, одна колонка jsonb. В JPA это @Embeddable, в jOOQ — маппер. Самая частая ошибка — таблица addresses с id и ссылка на неё: адрес получает идентичность, которой у него нет, и правка в профиле молча меняет адрес во всех старых заказах.

Entity: равенство по id User id: 7 a@mail.ru ≠ User id: 9 a@mail.ru поля совпали, а люди разные сменит почту — останется собой Value Object: равенство по значению Money 100 RUB id нет = Money 100 RUB id нет одно и то же значение «изменить» = создать новое

У пользователей совпало всё, кроме идентификатора, и этого достаточно: equals смотрит только на id. У денег идентификатора нет вовсе, поэтому две сотни рублей неотличимы, а «поменять» сумму можно только одним способом — создать новую.

Aggregate — граница, внутри которой правила держатся

Вернёмся к покупателю, который видел 1 500, а заплатил 900. Строки заказа и итог лежат в разных таблицах, и пока их можно менять по отдельности, ничто не гарантирует, что после сохранения они сойдутся. Второй сценарий той же природы: заказ подтверждён и передан на склад, а в него добавляют строку, потому что проверка статуса живёт в одном обработчике, а добавление строки — в другом.

Aggregate — группа объектов с одним входом: снаружи виден только корень (Aggregate Root), и всё внутри границы меняется через его методы. Отсюда две гарантии: правило проверяется в одном месте, а объекты внутри границы сохраняются одной транзакцией. Снаружи с агрегатом связаны только идентификатором.

Так не надо:

Order order = orders.findById(orderId).orElseThrow();
order.getLines().add(new OrderLine(productId, 2, price));
orders.save(order);

Статус не проверен, итог не пересчитан, и save честно запишет заказ со строками на 1 500 и итогом 900. Так надо — список строк закрыт, изменение идёт через корень:

public class Order {
    private final OrderId id;
    private final CustomerId customerId;
    private final List<OrderLine> lines = new ArrayList<>();
    private Money total;
    private OrderStatus status;

    public Order(OrderId id, CustomerId customerId, Currency currency) {
        this.id = Objects.requireNonNull(id);
        this.customerId = Objects.requireNonNull(customerId);
        this.total = Money.zero(currency);
        this.status = OrderStatus.DRAFT;
    }

    public void addLine(ProductId productId, int qty, Money price) {
        ensureDraft();
        if (qty <= 0) throw new IllegalArgumentException("qty must be > 0");
        OrderLine line = new OrderLine(productId, qty, price);
        Money newTotal = total.add(line.subtotal());
        lines.add(line);
        total = newTotal;
    }

    public void confirm() {
        ensureDraft();
        if (lines.isEmpty()) throw new IllegalStateException("Order has no lines");
        status = OrderStatus.CONFIRMED;
    }

    public Money total() { return total; }

    private void ensureDraft() {
        if (status != OrderStatus.DRAFT)
            throw new IllegalStateException("Order is not editable");
    }
}

Три места в листинге стоит прочитать медленно. Статус задаётся в конструкторе: иначе у нового заказа он null, и ensureDraft отклонит первую же строку.

Итог хранится в поле и пересчитывается в addLine: строки и итог меняются одним методом и ложатся в базу одной транзакцией, как на схеме в начале. Валюта задаётся при создании, и total.add откажется от строки в другой валюте; сумма считается до lines.add, поэтому отказ не оставит в заказе строку, не учтённую в итоге. Итог можно и считать по строкам; хранят его, когда заказы длинные, а сумму спрашивают чаще, чем меняют.

OrderLine — внутренний объект агрегата: снаружи его не создают и не меняют, а его идентификатор нужен только чтобы найти строку в списке.

Снимок вместо ссылки: цена и адрес в момент оформления

Заказ ссылается на клиента по customerId, но адрес доставки и цену позиции не запрашивает у профиля и каталога, а копирует к себе при оформлении. Это выглядит как дублирование, и его «исправляют»: убирают колонки из заказа и ходят за адресом в профиль. Через месяц клиент меняет адрес, и заказ недельной давности едет по новому, а вчерашняя переоценка каталога меняет сумму уже оплаченных заказов.

Снимок — это факт: заказ оформили по этой цене на этот адрес. Факт принадлежит заказу и меняется только вместе с ним; ссылка нужна для другого — найти клиента, чтобы прислать письмо или начислить бонусы. Правило: что после оформления должно остаться как было — копируется внутрь; что должно следовать за источником — ссылка.

Где провести границу агрегата

Правило «агрегат должен быть небольшим» не помогает, пока не сказано, что такое «небольшой». Помогают три вопроса.

Какое правило обязано выполняться после каждой транзакции? Всё, что в нём участвует, лежит внутри. «Итог равен сумме строк» держит строки и итог вместе, «подтверждённый заказ не редактируют» держит там же статус. У остатка на складе общего правила с заказом нет: резерв может появиться через секунду после подтверждения, и покупатель этого не заметит. Значит, остаток снаружи.

Кто и когда меняет эти данные? Профиль правит сам клиент в любое время, заказ — покупатель при покупке и потом сотрудник склада. Положить заказы внутрь клиента — значит заставить правку профиля конфликтовать с оформлением заказа: две операции, которым нечего делить, будут ждать друг друга и получать отказы. То, что меняют разные люди по разным поводам, — разные агрегаты.

Сколько это весит при загрузке? Агрегат читается и сохраняется целиком. Заказ на тридцать строк — тридцать одна строка из базы на одно изменение, терпимо. История операций по счёту за год — десятки тысяч строк, и грузить их ради одного перевода нельзя. Коллекция без верхней границы — сигнал, что её элементы — отдельный агрегат со ссылкой на родителя. Тот же сигнал — своя жизнь у «строки»: у позиции появилась отправка со своим статусом и трекингом, значит, она стала агрегатом Shipment.

Откуда берутся правила, из которых вырастает граница, разобрано в статье про онтологию и доменную модель, а как добыть их у бизнеса за одну встречу — в Event Storming.

Двое меняют один заказ одновременно

Менеджер и покупатель в одну секунду добавляют в заказ по строке. Оба прочитали заказ с итогом 900, каждый прибавил свою строку и записал свой итог. Кто записал последним, тот и победил: одна строка в базе есть, а итог её не учитывает. Граница агрегата от этого не защищает, защищает номер версии.

У корня агрегата есть колонка version. Прочитали заказ с version = 7, изменили, и репозиторий пишет: UPDATE orders SET ..., version = 8 WHERE id = ? AND version = 7. Первому это удаётся, второй получает «обновлено 0 строк»: копия устарела, и репозиторий бросает исключение об оптимистичной блокировке. Дальше два выхода: сказать пользователю «заказ изменился, обновите страницу» или заново загрузить агрегат и повторить команду. Автоматический повтор уместен, когда команда имеет смысл на новом состоянии: добавить строку — да, а «подтвердить» отменённый за это время заказ отклонит сам агрегат.

Версия одна на весь агрегат, и это цена границы: двое, добавляющие разные строки, всё равно конфликтуют, хотя не трогают ничего общего. Пока конфликты редки, повтор их прячет; когда постоянны — это второй признак слишком большого агрегата, и лечится он границей, а не хитрыми блокировками. Если же правки одного объекта идут потоком по природе задачи, как списания с расчётного счёта, берут пессимистичную блокировку SELECT ... FOR UPDATE: второй ждёт первого, а не переделывает работу. В Hibernate — транзакции и блокировки, в jOOQ — lock-режимы.

Domain Event — факт, о котором должны узнать другие

Заказ оплачен, и нужно отправить письмо, зарезервировать товар и начислить бонусы. Проще всего вызвать всё это из pay(), и тогда Order знает о почте, складе и программе лояльности, а каждый новый потребитель — правка агрегата. Вместо этого агрегат фиксирует факт, а кто и как на него реагирует, решают снаружи. Такой факт — Domain Event: неизменяемый объект в прошедшем времени (OrderPaid, не PayOrder), который несёт всё нужное потребителю — идентификатор заказа, сумму, время.

public interface DomainEvent {
    UUID eventId();
    Instant occurredAt();
}

public record OrderPaid(UUID eventId, Instant occurredAt, UUID orderId, BigDecimal amount)
        implements DomainEvent { }

public abstract class AggregateRoot {
    private final List<DomainEvent> domainEvents = new ArrayList<>();

    protected void registerEvent(DomainEvent event) { domainEvents.add(event); }
    public List<DomainEvent> domainEvents() { return Collections.unmodifiableList(domainEvents); }
    public void clearEvents() { domainEvents.clear(); }
}

DomainEvent — интерфейс, а не базовый класс, потому что события удобнее писать как record, а record умеет только реализовывать интерфейсы. Время приходит снаружи через Clock, а не из Instant.now(): иначе в тесте его не с чем сравнить. Order extends AggregateRoot в pay() зовёт registerEvent(new OrderPaid(UUID.randomUUID(), clock.instant(), id.value(), total.amount())), и события копятся в списке до сохранения.

Самое важное происходит после сохранения. Между агрегатами события дают итоговую согласованность: заказ оплачен в своей транзакции, резерв на складе появится через секунды в своей, и в промежутке система рассогласована. Промежуток допустим, потеря события — нет: не узнает склад об оплате — заказ повиснет без резерва.

Терять событие проще всего при отправке. save() — ещё не запись: изменения уходят в открытую транзакцию, а фиксируются на коммите. Отправить события из save() значит сообщить складу об оплате, которая может ещё откатиться. Отправить после коммита из того же процесса — потерять событие, если процесс упадёт между коммитом и отправкой: заказ оплачен, письма нет, и следов нет.

Поэтому событие записывают в таблицу outbox той же транзакцией, что и заказ: в базе либо есть и оплата, и событие, либо нет обоих. Отдельный процесс, relay, читает таблицу, отправляет события в брокер и помечает отправленные. Упадёт он между отправкой и пометкой — отправит событие второй раз, и это свойство схемы, а не ошибка: потребитель обязан узнавать повтор по eventId и молча пропускать знакомое. Код relay с блокировкой строк и разбор дубликатов — в интеграционных паттернах.

одна транзакция Order outbox строка события relay отдельный процесс брокер склад помнит eventId relay упал между отправкой и пометкой — событие придёт дважды, потребитель повтор пропустит

Оплата и строка события фиксируются вместе, поэтому потерять событие между коммитом и отправкой невозможно: его либо нет вместе с оплатой, либо оно лежит в outbox и дождётся relay. Зато повтор возможен всегда, и защита от него живёт на стороне потребителя.

Repository — коллекция агрегатов, а не доступ к таблицам

Когда SQL для сохранения заказа пишут прямо в обработчике, любое изменение схемы расходится по всем местам, где заказ сохраняют. Repository прячет это за двумя операциями в словах предметной области: найти агрегат по идентификатору и сохранить целиком. Интерфейс объявлен в домене рядом с Order, реализация лежит в инфраструктуре и зависит от домена, а не наоборот.

public interface OrderRepository {
    Optional<Order> findById(OrderId id);
    void save(Order order);
}

public class JooqOrderRepository implements OrderRepository {
    private final OutboxWriter outbox;

    @Override
    public void save(Order order) {
        writeOrderAndLines(order);
        order.domainEvents().forEach(outbox::append);
        order.clearEvents();
    }
}
приложение ConfirmOrderHandler домен Order OrderRepository интерфейс инфраструктура JooqOrderRepository SQL, таблицы orders и order_lines order.confirm() findById, save реализует: зависимость смотрит вверх, в домен

Все стрелки зависимостей смотрят внутрь домена: обработчик зовёт интерфейс, реализация в инфраструктуре его реализует. Домен не знает ни про jOOQ, ни про таблицы, поэтому хранилище можно заменить, не трогая Order. Эту стрелку чаще всего переворачивают, объявляя интерфейс рядом с реализацией.

Ограничение, о котором репозиторий не предупреждает: он поднимает агрегат целиком, поэтому списков, фильтров и отчётов в нём нет. Запрос «заказы за месяц со статусом и итогом» превратился бы в загрузку тысячи агрегатов ради трёх колонок каждого. Так не надо:

List<Order> findAllByStatusAndCreatedBetween(OrderStatus status, Instant from, Instant to);

Чтение идёт мимо агрегата: отдельный запрос отдаёт ровно те колонки, которые нужны экрану, в плоский DTO без правил и версии — в jOOQ это view-репозитории, сам подход называется CQRS. Репозиторий остаётся у команд: загрузить, изменить через корень, сохранить.

Вторая половина темы — как агрегат ложится в таблицы. Order живёт в orders вместе с итогом, статусом, снимком адреса и колонкой version; строки — в order_lines с внешним ключом order_id. Загрузка — два запроса или один с вложенной выборкой, и маппер собирает объект в обход конструктора: проверки уже пройдены, событий регистрировать не надо. При сохранении легко ошибиться со строками: вставить «все строки заказа» — получить дубли на второй правке; обновлять по идентификатору — оставить в базе удалённые из заказа строки. Рабочих варианта два: удалить строки заказа и вставить заново, что дёшево при десятках строк, или сравнить по идентификаторам и выполнить вставки, обновления и удаления по разнице — так делает JPA с orphanRemoval = true. Подробнее — в статье о сущностях в Hibernate.

Domain Service — операция на несколько агрегатов

Перевод денег снимает сумму с одного счёта и зачисляет на другой. Ни один из двух Account не должен знать о втором, а положить операцию в обработчик запроса значит вернуть правило в слой приложения. Domain Service — класс в домене для правила, которое не принадлежит ни одному агрегату; сначала пробуют разместить логику в агрегате, сервис появляется, когда она не помещается.

class TransferService {
    void transfer(Account from, Account to, Money amount) {
        from.withdraw(amount);
        to.deposit(amount);
    }
}

Здесь одной операцией меняются два агрегата — осознанное исключение из правила «одна транзакция — один агрегат»: деньги не могут на секунду пропасть между счетами, поэтому списание и зачисление держат вместе. Плата известна: оба счёта обязаны лежать в одной базе и обслуживаться одним сервисом. Когда их приходится развести, перевод разбивают на два шага с событием между ними и компенсацией, если второй шаг не прошёл.

Application Service — другой слой: он загружает агрегат, вызывает доменный метод и сохраняет; правил в нём нет.

Обработчик TransferService загрузил оба счёта from.withdraw минус 100 ₽ to.deposit плюс 100 ₽

Правило перевода не принадлежит ни одному счёту и потому живёт отдельным классом в домене: стрелки идут от сервиса к обоим счетам, а между самими счетами связи нет.

Factory — когда конструктор не справляется

У Order три способа появиться на свет. Конструктор годится, когда все данные на руках и проверить нужно только их: идентификатор не пустой, валюта задана. Статический метод Order.place(customerId, currency) вызывает тот же конструктор, но добавляет намерение: генерирует идентификатор, ставит DRAFT, регистрирует событие OrderPlaced. Он по-прежнему знает только про заказ.

Фабрика нужна, когда для создания надо заглянуть в другой агрегат или наружу: спросить у клиента, не заблокирован ли он, взять номер из последовательности. Класть это в Order.place значит заставить заказ зависеть от Customer и от репозитория, чего агрегат делать не должен.

class OrderFactory {
    Order placeFor(Customer customer, Currency currency) {
        if (customer.isBlocked())
            throw new IllegalStateException("Blocked customer cannot place orders");
        return Order.place(customer.id(), currency);
    }
}

Проверка «заблокированный не оформляет заказ» проходит до создания, Order о Customer не знает, а точка создания одна: обработчик не соберёт заказ в обход проверки. Маппер репозитория к этой точке не относится: он собирает объект из базы напрямую, иначе каждая загрузка регистрировала бы OrderPlaced заново.

Когда всё это не нужно

Справочник валют, настройки уведомлений, список тегов — у таких данных нет правил, которые обязаны сойтись после транзакции, и нет переходов состояний. Агрегат с версией, репозиторий с маппером, события с outbox дадут здесь только код там, где хватило бы строки в таблице и DTO с валидацией. Если вся логика контекста помещается в пару if в обработчике, тактические паттерны ей не нужны — первая статья цикла говорит об этом прямо.

Переходят к ним по признакам: правило про статусы или суммы появилось в третьем сервисе; одно поле меняют из разных мест, и оно расходится; сверка рассогласований стала регулярной задачей. Начинают с Value Object и Entity — они дешевле всего; агрегат, версия и события приходят, когда появляется правило, за которое отвечать некому.

Дополнительно: при первом чтении можно пропустить

Глубже: чтение мимо агрегата: списки, фильтры и отчётырасширенное

OrderRepository из раздела выше умеет две вещи: найти агрегат по идентификатору и сохранить целиком. Первая же задача «показать список заказов клиента с фильтром по статусу и суммой за месяц» в этот интерфейс не помещается, и самая частая поломка после внедрения DDD именно здесь: в репозиторий добавляют findByCustomerAndStatus, потом findWithTotalGreaterThan, потом findForReport, и через полгода это снова таблица с методами, а агрегат грузится сотнями ради одной колонки.

Правило: репозиторий агрегата обслуживает изменения, а чтение для экранов и отчётов идёт мимо него. Список заказов это не сто агрегатов Order, а плоские строки с нужными полями: номер, дата, статус, сумма, имя клиента из другой таблицы. Их читает отдельный запрос (через jOOQ прямо в DTO под экран) в отдельном классе, который называют по-разному: сервис запросов, view-репозиторий, read-модель. У него нет инвариантов, ему можно соединять таблицы разных агрегатов и считать суммы в SQL, и он никогда не возвращает агрегат.

Что из этого следует. У агрегата остаются методы, которые нужны командам: найти по идентификатору, найти по естественному ключу для проверки уникальности, сохранить. Всё с «список», «отчёт», «фильтр», «страница» уходит в чтение. Модель чтения не обязана повторять структуру агрегата: экран «заказы клиента» показывает поля из четырёх таблиц одной строкой, и это нормально. И граница проходит по операции: команда «отменить заказ» читает агрегат через репозиторий, экран «мои заказы» читает через запрос, даже если это одна таблица.

Так выглядит первая ступень разделения команд и запросов, о которой статья про CQRS; для неё не нужны ни события, ни вторая база, только два разных пути к одной таблице. Когда отчёты становятся тяжёлыми, чтение уводят в отдельное хранилище, но интерфейс со стороны кода не меняется: команды по-прежнему через репозиторий, экраны через запросы.

Коротко

  • Entity узнают по идентификатору, Value Object — по значению; своей таблицы у Value Object нет, он лежит колонками внутри владельца.
  • Граница агрегата — правило, которое обязано выполняться после каждой транзакции, и то, что меняет один человек по одному поводу. Коллекция без предела и постоянные конфликты версий — признаки слишком широкой границы.
  • Что должно остаться как было — копируется снимком; что должно следовать за источником — ссылка по идентификатору.
  • Одновременную правку ловит version на корне; при отказе — повтор на свежей копии или сообщение пользователю.
  • Событие пишется в outbox той же транзакцией, что и агрегат; доставляет его отдельный процесс, и прийти оно может дважды.
  • Репозиторий грузит и сохраняет агрегат целиком; списки и отчёты — отдельным запросом в плоский DTO.
  • Фабрика нужна, когда создание требует чужих данных; восстановление из базы — не создание.
  • В контексте без правил тактические паттерны — лишний код; начинают с Value Object и Entity.
  • Репозиторий агрегата обслуживает команды: найти по идентификатору и сохранить; списки, фильтры и отчёты читают отдельным запросом в плоские DTO под экран, мимо агрегата и его инвариантов.

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