Logo
Overview

Агрегат и корень агрегата в DDD: инварианты и границы транзакции

October 14, 2026
6 min read

На одном проекте мы переписывали монолит на микросервисы. Через месяц архитектура напоминала лапшу: ордера дёргали товары напрямую, изменение цены в корзине ломало складские остатки, а база заказов разрослась до полусотни таблиц, которые кто-то (не будем показывать пальцем) джойнил в одном запросе.

Корень проблемы был не в микросервисах. Мы не знали, что такое агрегат.

Агрегат (Aggregate) — кластер доменных объектов (сущностей и Value Objects), с которыми работают как с единым целым. У каждого агрегата ровно один корень (Aggregate Root) — единственная точка доступа извне.

Это не «группа объектов, которые удобно сложить рядом». Это консистентная группа, границы которой определяются бизнес-правилами. Если два объекта обязаны быть согласованными в любой момент времени — они в одном агрегате. Если хватает eventual consistency — в разных.

Зачем нужен агрегат

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

Агрегат «Корзина» фиксирует цену на момент добавления. Это не дублирование данных. Это защита инварианта: «сумма корзины предсказуема для покупателя в момент оформления».

Выбор границ агрегата — всегда компромисс. Маленький агрегат — теряете консистентность. Большой — получаете блокировки и проблемы с конкурентным доступом. Серебряной пули нет, есть понимание бизнес-правил.

Корень агрегата: единственная дверь

Корень агрегата (Aggregate Root) — сущность, через которую идёт любое взаимодействие с агрегатом. Только корень:

  • Загружается из репозитория
  • Принимает команды извне
  • Отдаёт данные наружу
  • Гарантирует выполнение инвариантов

В агрегате «Заказ» корнем будет Order. Если клиентскому коду нужно добавить строку — он не лезет в коллекцию напрямую: order.getLines().add(newLine). Он вызывает order.addLine(product, quantity). Корень сам проверяет, не превышен ли кредитный лимит, не заблокирован ли клиент, доступен ли товар.

Прямой доступ к внутренностям агрегата — дыра в бизнес-логике. Я видел код, где order.totalAmount выставляли напрямую из REST-контроллера. Сумма заказа считалась на фронтенде и прилетала в API как готовая. Спустя месяц обнаружили клиентов, оформивших заказы на 0 рублей.

Внешние объекты ссылаются на агрегат только по идентификатору. Не по объектной ссылке, не по foreign key с каскадным удалением — только по ID. Это правило часто нарушают в ORM, и начинается каскадная загрузка на 15 таблиц ради одного поля status.

Инварианты: что защищает агрегат

Инвариант — бизнес-правило, которое должно выполняться всегда. Не «почти всегда», не «в рамках одной транзакции, если никто не лезет» — всегда.

Для агрегата «Заказ» типичный набор:

  • Сумма всех строк не превышает кредитный лимит клиента
  • Нельзя изменить заказ со статусом «отправлен» или «доставлен»
  • Отмена возможна, только если ни одна строка ещё не отгружена
  • Количество товара в строке не может быть меньше 1

Агрегат существует, чтобы это защищать. Если правило требует синхронной проверки данных из нескольких объектов — эти объекты в одном агрегате.

Но не всякое бизнес-правило — инвариант агрегата. «Скидка не может превышать 30% без согласования менеджера» может жить в сервисе скидок, а не в агрегате «Заказ». Разница принципиальная, и её часто упускают — начинают пихать в агрегат вообще всю логику.

Одна транзакция — один агрегат

Правило DDD: транзакция БД затрагивает ровно один агрегат. Не два, не три — один.

Если нужно согласованно изменить два агрегата — проблема в дизайне границ. Либо они должны быть одним агрегатом, либо согласованность не нужна и выручают доменные события с eventual consistency.

Почему это жёсткое правило:

  • Два агрегата — потенциально два разных storage (таблицы, базы, сервисы). Распределённая транзакция — это боль.
  • Конкурентный доступ к разным агрегатам не должен взаимно блокироваться.
  • Транзакция на три агрегата, где один меняется долго — два других висят в блокировке.

На практике агрегат загружается целиком, меняется целиком и сохраняется целиком. Репозиторий общается только с корнем. Никаких orderRepository.saveLine(orderLine) — только orderRepository.save(order).

Структура агрегата на схеме

100%
graph TD
  A["Заказ (Order)
Корень агрегата"] --> B["Строка заказа
(OrderLine) — Сущность"]
  A --> C["Строка заказа
(OrderLine) — Сущность"]
  A --> D["Адрес доставки
(Address) — VO"]
  A --> E["Статус
(OrderStatus) — VO"]
  B --> F["Цена (Money) — VO"]
  B --> G["Количество (Quantity) — VO"]
  H["Инвариант:
сумма строк ≤ кредитный лимит"] -.->|"защищает"| A
  H2["Инвариант:
нельзя изменить
отправленный заказ"] -.->|"защищает"| A
  I["Внешний код"] -->|"только через корень"| A
  J["OrderRepository"] -->|"сохраняет / загружает
целиком"| A

  style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style B fill:#7b68ee,stroke:#5a4db2,color:#fff
  style C fill:#7b68ee,stroke:#5a4db2,color:#fff
  style D fill:#50c878,stroke:#3a9a5c,color:#fff
  style E fill:#50c878,stroke:#3a9a5c,color:#fff
  style F fill:#e0e0e0,stroke:#999,color:#000
  style G fill:#e0e0e0,stroke:#999,color:#000
  style H fill:#f0a500,stroke:#c88400,color:#fff
  style H2 fill:#f0a500,stroke:#c88400,color:#fff
  style I fill:#e0e0e0,stroke:#999,color:#000
  style J fill:#e0e0e0,stroke:#999,color:#000

Синим выделен корень — единственная точка входа. Фиолетовым — сущности внутри агрегата (OrderLine). Зелёным — Value Objects (Address, OrderStatus). Серым — вспомогательные VO и внешние связи. Жёлтым — инварианты, которые корень обязан поддерживать.

Любой внешний код (контроллер, сервис, другой агрегат) общается только с корнем. Репозиторий загружает и сохраняет весь агрегат разом.

Типичные ошибки

Агрегат-монолит. Заказ, включающий клиента, каталог товаров, склад и историю доставки. Грузится три секунды, блокирует полбазы. Если сущность не участвует в защите инвариантов — её в агрегате быть не должно.

Ссылки на другие агрегаты по объекту. Поле Customer customer вместо CustomerId customerId. Hibernate радостно подгружает клиента, его адреса, его заказы, его любимые товары… Каскадный кошмар.

Агрегаты-одиночки. Каждая сущность — отдельный агрегат. Вроде всё разбито красиво, но инварианты на стыке сущностей разваливаются. Order и OrderLine как отдельные агрегаты — как тогда гарантировать, что сумма строк не превышает кредитный лимит?

Как определить границы

На собеседованиях это один из частых вопросов. Универсального ответа нет, но есть эвристики, которые я применяю:

  1. Выпишите инварианты. Все бизнес-правила, требующие синхронной согласованности. Объекты, связанные такими правилами, — кандидаты в один агрегат.

  2. Ищите кластеры по жизненному циклу. Создаются, меняются и удаляются вместе — один агрегат. Order и OrderLine: строки не живут отдельно от заказа.

  3. Думайте о конкурентном доступе. Два пользователя регулярно меняют разные части одного агрегата — вероятно, это два разных агрегата.

  4. Правило маленьких агрегатов. Вон Вернон советует проектировать агрегаты минимального размера. Сомневаетесь — делайте меньше. Увеличить проще, чем раздробить.

Границы агрегатов определяются бизнесом, а не структурой таблиц. Одна таблица в БД не означает один агрегат.

Связь с другими тактическими паттернами

Агрегат не висит в вакууме. Он строится из DTO, VO и Entity — понимание разницы между этими слоями помогает не тащить в агрегат лишнее. Корень агрегата — всегда Entity, а вот его внутренности часто Value Objects (цена, адрес, статус).

В более широкой картине DDD агрегат — тактический инструмент внутри Bounded Context. Каждый контекст содержит свои агрегаты со своими инвариантами. Одна и та же сущность («Пользователь») в контексте аутентификации и в контексте рекомендаций — два разных агрегата с разными полями и разными правилами. Норма.

Чек-лист для аналитика

  • У каждого агрегата определён ровно один корень
  • Внешний доступ — только через корень, по ID
  • Инварианты агрегата выписаны и защищены корнем
  • Одна транзакция БД затрагивает только один агрегат
  • Границы агрегата продиктованы бизнес-правилами, а не схемой БД
  • Репозиторий работает только с корнем: загружает и сохраняет агрегат целиком
  • На другие агрегаты — ссылки только по идентификатору, не по объекту