Покупатель добавил в корзину третий товар, увидел на экране 1 500, а с карты списали 900. Строку в заказ добавил один сервис, итог пересчитывает другой, а подтверждение третий проверял по старой копии. Ни в одном нет ошибки: правило «итог равен сумме строк» не принадлежит никому и держится на том, что все три вызывают в правильном порядке.
Тактические паттерны DDD дают каждому правилу хозяина. Entity и Value Object говорят, что узнают по идентификатору, а что по значению; Aggregate проводит границу, внутри которой правила обязаны выполняться после каждой транзакции; Domain Event сообщает остальным, что случилось; Repository прячет хранилище. Главный — Aggregate: остальные либо живут внутри его границы, либо обслуживают её.
Новая строка и пересчитанный итог ложатся в базу одной транзакцией: либо оба изменения, либо ни одного, и после неё правило «итого = сумма строк» обязано выполняться. За границей живут другие агрегаты: на клиента заказ ссылается только идентификатором, а резерв на складе меняется своей транзакцией по событию и отстаёт на секунды.
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 и ссылка на неё: адрес получает идентичность, которой у него нет, и правка в профиле молча меняет адрес во всех старых заказах.
У пользователей совпало всё, кроме идентификатора, и этого достаточно: 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 с блокировкой строк и разбор дубликатов — в интеграционных паттернах.
Оплата и строка события фиксируются вместе, поэтому потерять событие между коммитом и отправкой невозможно: его либо нет вместе с оплатой, либо оно лежит в 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();
}
}
Все стрелки зависимостей смотрят внутрь домена: обработчик зовёт интерфейс, реализация в инфраструктуре его реализует. Домен не знает ни про 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 — другой слой: он загружает агрегат, вызывает доменный метод и сохраняет; правил в нём нет.
Правило перевода не принадлежит ни одному счёту и потому живёт отдельным классом в домене: стрелки идут от сервиса к обоим счетам, а между самими счетами связи нет.
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 под экран, мимо агрегата и его инвариантов.
Что почитать дальше
- Онтология и доменная модель — откуда берутся правила, задающие границу агрегата.
- Интеграционные паттерны DDD — код outbox и relay, дубликаты, идемпотентный потребитель.
- View-репозитории в jOOQ — как устроено чтение мимо агрегата.
- Транзакции и блокировки в Hibernate —
@Versionи обе блокировки на практике.