Опирается на правила:
R-AGG-1…R-AGG-5иR-AGG-X1…R-AGG-X4из DDD Tactical Style Guide → раздел 3. Aggregate Root.
Важно знать
- Aggregate — это кластер сущностей и VO, согласованных по инвариантам. У него один корень (
AggregateRoot), через который происходит весь внешний доступ.- Транзакционная граница = граница агрегата. Один use-case изменяет один агрегат. Между агрегатами — eventual consistency через события.
- Внутренние Entity скрыты. Снаружи — только методы корня. Если нужны коллекции наружу — через
unmodifiableList/Collections.unmodifiableXxx.- Доменные события регистрируются только в самом корне, через
registerEvent(...). Сервисы, репозитории, контроллеры события не публикуют.- Ссылки на другие агрегаты — по ID (
CustomerId,ProductId), не объекты.- Корень выделен по бизнес-инварианту, который должен быть согласован транзакционно. Не «всё, что связано с заказом», а «то, что нельзя обновить отдельно».
- God aggregate (десятки несвязанных Entity внутри) — главный антипаттерн. Делим по инвариантам, не по UI или по структуре БД.
Aggregate Root — это объект, который отвечает за согласованность кластера данных. Если бизнес-правило требует «сумма позиций заказа всегда равна total», то Order — агрегат, OrderItem — внутренняя сущность, и любая операция над OrderItem идёт через метод Order (например, order.addItem(...) пересчитает total). Если бы OrderItem был отдельным агрегатом, инвариант пришлось бы поддерживать «снаружи» — через двухфазные коммиты или eventual consistency, и это сразу видно как сложность. Раскрытие раздела 3 гайда.
Наследование AggregateRoot
R-AGG-1: корень агрегата наследует AggregateRoot<ID> из ddd-building-blocks. Это надстройка над Entity<ID>, которая добавляет список зарегистрированных событий и метод registerEvent(...).
public final class Order extends AggregateRoot<OrderId> {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
private Money total;
public Order(OrderId id, CustomerId customerId, List<OrderItem> items) {
this.id = Objects.requireNonNull(id);
this.customerId = Objects.requireNonNull(customerId);
if (items.isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
this.items = new ArrayList<>(items);
this.status = OrderStatus.NEW;
this.total = recalcTotal();
registerEvent(new OrderCreated(this.id, this.customerId, this.total));
}
@Override public OrderId getId() { return id; }
}
OrderItem — обычный Entity<OrderItemId> (не AggregateRoot), Money — VO. Список items держим как mutable internal field (ArrayList), наружу выдаём через unmodifiableList (см. ниже).
Все внешние операции — через методы корня
R-AGG-2: внутренние Entity недоступны снаружи без обёртки. Любая модификация — через метод корня.
public final class Order extends AggregateRoot<OrderId> {
// ...
public void addItem(ProductId productId, int quantity, Money unitPrice) {
if (this.status != OrderStatus.NEW) {
throw new OrderAlreadyConfirmedException(this.id);
}
OrderItemId itemId = OrderItemId.generate();
OrderItem item = new OrderItem(itemId, productId, quantity, unitPrice);
this.items.add(item);
this.total = recalcTotal();
registerEvent(new OrderItemAdded(this.id, itemId, quantity));
}
public void confirm() {
if (this.status != OrderStatus.NEW) {
throw new OrderAlreadyConfirmedException(this.id);
}
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmed(this.id, this.total, Instant.now()));
}
public List<OrderItem> items() {
return Collections.unmodifiableList(items);
}
}
Что неправильно:
// ПЛОХО — клиент мутирует внутренний список
order.items().add(new OrderItem(...)); // напрямую
// или
order.getInternalItems().clear(); // через утечку
Зачем закрывать:
- Инвариант
total = sum(items)ломается, если items меняются помимо методаaddItem. Корень не может пересчитатьtotal, не знает об изменении. - События не регистрируются. Прямая мутация items не вызывает
registerEvent(new OrderItemAdded(...))— изменение «теряется» для подписчиков.
R-AGG-X2 дословно запрещает «возвращать наружу мутабельные коллекции». Только через unmodifiableList / List.copyOf.
События регистрируются только в корне
R-AGG-3, R-AGG-X4: registerEvent(...) — внутри методов агрегата, в момент изменения состояния. Не в сервисе, не в репозитории, не в контроллере.
public void cancel(String reason) {
if (this.status == OrderStatus.SHIPPED) {
throw new OrderAlreadyShippedException(this.id);
}
if (this.status == OrderStatus.CANCELLED) {
return; // idempotent
}
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelled(this.id, reason, Instant.now()));
}
Почему в корне, а не где-то ещё:
- Атомарность. Состояние и событие меняются в одном месте. Невозможно «изменить состояние и забыть зарегистрировать событие» — оба действия в одном методе.
- Корректность. Событие отражает факт изменения агрегата. Только сам агрегат знает, действительно ли он перешёл в новое состояние (см. early-return на
CANCELLEDвыше — события не дублируются). - Граница ответственности. Сервис координирует, агрегат принимает решения. Если бы сервис регистрировал события, его пришлось бы постоянно держать в курсе доменных правил.
Подробно про публикацию событий после save() — в Domain Event.
Транзакционная граница — один агрегат
R-AGG-4: один use-case изменяет один агрегат. Другие — только через события (eventual consistency).
// ХОРОШО — handler изменяет один Order
@Component
@RequiredArgsConstructor
class ConfirmOrderHandler implements UseCaseHandler<ConfirmOrder, Void> {
private final OrderRepository orderRepository;
@Override
@Transactional
public Void handle(ConfirmOrder command) {
Order order = orderRepository.findById(command.orderId(), SelectMode.FOR_UPDATE)
.orElseThrow(() -> new OrderNotFoundException(command.orderId()));
order.confirm(); // ← меняет один агрегат
orderRepository.save(order); // ← публикация события OrderConfirmed
return null;
}
}
OrderConfirmed потом обрабатывается отдельным процессом — например, InventoryHandler уменьшает остатки по Product, BillingHandler создаёт Invoice. Эти операции — другие транзакции, в которых меняется их агрегат.
Что нельзя:
// ПЛОХО — handler меняет два агрегата в одной транзакции
@Transactional
public Void handle(ConfirmOrder command) {
Order order = orderRepository.findById(...);
Customer customer = customerRepository.findById(order.customerId());
customer.incrementOrderCount(); // ← мутация чужого агрегата
order.confirm();
customerRepository.save(customer);
orderRepository.save(order);
}
Почему плохо (R-AGG-X3):
- Двойная блокировка.
FOR UPDATEберётся на двух разных таблицах. Deadlock-prone при противоположном порядке в другом use-case. - Сложнее раскидать по сервисам. Если завтра
Customerуезжает в другой сервис, такой handler не работает. - Инвариант не локальный.
customer.orderCountнарушает локальную согласованность — кореньCustomerзависит от изменений вOrder, но корни не должны знать друг про друга прямо.
Правильно: после order.confirm() корень регистрирует OrderConfirmed, подписчик в Customer-агрегате обрабатывает событие в отдельной транзакции и обновляет orderCount.
Ссылки между агрегатами — только по ID
R-AGG-5: внутри Order не хранится объект Customer. Только CustomerId.
// ПЛОХО
public final class Order extends AggregateRoot<OrderId> {
private Customer customer; // ← объект другого агрегата
}
// ХОРОШО
public final class Order extends AggregateRoot<OrderId> {
private final CustomerId customerId;
}
Это про то же, что выше — невозможность мутировать чужой агрегат + локальность инвариантов + независимая загрузка из persistence. Когда нужен «Order + имя клиента» на read-side, это read-model (см. CQRS Style Guide), а не объект Customer внутри Order.
Корень выделен по инварианту, не по UI
R-AGG-X1: God aggregate — самый частый антипаттерн. Признак — внутри одного агрегата десятки несвязанных Entity:
// ПЛОХО — God aggregate
public final class Customer extends AggregateRoot<CustomerId> {
private List<Order> orders; // ← каждый Order — свой агрегат
private List<Invoice> invoices; // ← Invoice тоже агрегат
private List<Subscription> subs; // ← и Subscription
private List<PaymentMethod> methods;
// ...
}
Что не так: «load Customer» поднимает мегабайты, любая операция требует FOR UPDATE на всём, транзакции конфликтуют, всё блокируется. Корень должен быть выделен по общему бизнес-инварианту, который надо поддерживать атомарно.
Правильное деление:
Customer— id, имя, email, статус. Один агрегат.Order— id, items, total, status. Другой агрегат, держитcustomerIdпо ID.Invoice— id, suma, дата, статус оплаты. Третий, тоже поcustomerId.Subscription,PaymentMethod— отдельные агрегаты.
Read-сценарий «показать клиента с его заказами и инвойсами» — это query на read-model, не загрузка God aggregate. Подробно — в CQRS Style Guide и ViewRepository.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| God aggregate (десятки несвязанных Entity) | R-AGG-X1 | Делим по локальному инварианту, ссылки между агрегатами — по ID |
| Возврат наружу мутабельных коллекций | R-AGG-X2 | Collections.unmodifiableList(items) или List.copyOf |
| Изменение чужого агрегата напрямую | R-AGG-X3 | registerEvent + подписчик в отдельной транзакции |
| Регистрация события вне корня (в сервисе, репозитории) | R-AGG-X4 | registerEvent(...) внутри метода агрегата |
Хранение объекта другого агрегата (Customer customer) | R-AGG-5 | CustomerId customerId |
Куда дальше
- DDD Tactical → раздел 3. Aggregate Root — нормативные формулировки
R-AGG-*. - Entity — внутренние Entity агрегата (не корни).
- Domain Event — что такое
registerEventи как события публикуются после save. - Repository — как агрегат целиком сохраняется и поднимается.
- Distributed Patterns Style Guide — saga, когда eventual consistency между агрегатами недостаточно.
- CQRS Style Guide — read-model для сценариев «склей несколько агрегатов».