Опирается на правила: R-AGG-1R-AGG-5 и R-AGG-X1R-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-X2Collections.unmodifiableList(items) или List.copyOf
Изменение чужого агрегата напрямуюR-AGG-X3registerEvent + подписчик в отдельной транзакции
Регистрация события вне корня (в сервисе, репозитории)R-AGG-X4registerEvent(...) внутри метода агрегата
Хранение объекта другого агрегата (Customer customer)R-AGG-5CustomerId 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 для сценариев «склей несколько агрегатов».