На одном проекте мы переписывали монолит на микросервисы. Через месяц архитектура напоминала лапшу: ордера дёргали товары напрямую, изменение цены в корзине ломало складские остатки, а база заказов разрослась до полусотни таблиц, которые кто-то (не будем показывать пальцем) джойнил в одном запросе.
Корень проблемы был не в микросервисах. Мы не знали, что такое агрегат.
Агрегат (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).
Структура агрегата на схеме
Синим выделен корень — единственная точка входа. Фиолетовым — сущности внутри агрегата (OrderLine). Зелёным — Value Objects (Address, OrderStatus). Серым — вспомогательные VO и внешние связи. Жёлтым — инварианты, которые корень обязан поддерживать.
Любой внешний код (контроллер, сервис, другой агрегат) общается только с корнем. Репозиторий загружает и сохраняет весь агрегат разом.
Типичные ошибки
Агрегат-монолит. Заказ, включающий клиента, каталог товаров, склад и историю доставки. Грузится три секунды, блокирует полбазы. Если сущность не участвует в защите инвариантов — её в агрегате быть не должно.
Ссылки на другие агрегаты по объекту. Поле Customer customer вместо CustomerId customerId. Hibernate радостно подгружает клиента, его адреса, его заказы, его любимые товары… Каскадный кошмар.
Агрегаты-одиночки. Каждая сущность — отдельный агрегат. Вроде всё разбито красиво, но инварианты на стыке сущностей разваливаются. Order и OrderLine как отдельные агрегаты — как тогда гарантировать, что сумма строк не превышает кредитный лимит?
Как определить границы
На собеседованиях это один из частых вопросов. Универсального ответа нет, но есть эвристики, которые я применяю:
-
Выпишите инварианты. Все бизнес-правила, требующие синхронной согласованности. Объекты, связанные такими правилами, — кандидаты в один агрегат.
-
Ищите кластеры по жизненному циклу. Создаются, меняются и удаляются вместе — один агрегат.
OrderиOrderLine: строки не живут отдельно от заказа. -
Думайте о конкурентном доступе. Два пользователя регулярно меняют разные части одного агрегата — вероятно, это два разных агрегата.
-
Правило маленьких агрегатов. Вон Вернон советует проектировать агрегаты минимального размера. Сомневаетесь — делайте меньше. Увеличить проще, чем раздробить.
Границы агрегатов определяются бизнесом, а не структурой таблиц. Одна таблица в БД не означает один агрегат.
Связь с другими тактическими паттернами
Агрегат не висит в вакууме. Он строится из DTO, VO и Entity — понимание разницы между этими слоями помогает не тащить в агрегат лишнее. Корень агрегата — всегда Entity, а вот его внутренности часто Value Objects (цена, адрес, статус).
В более широкой картине DDD агрегат — тактический инструмент внутри Bounded Context. Каждый контекст содержит свои агрегаты со своими инвариантами. Одна и та же сущность («Пользователь») в контексте аутентификации и в контексте рекомендаций — два разных агрегата с разными полями и разными правилами. Норма.
Чек-лист для аналитика
- У каждого агрегата определён ровно один корень
- Внешний доступ — только через корень, по ID
- Инварианты агрегата выписаны и защищены корнем
- Одна транзакция БД затрагивает только один агрегат
- Границы агрегата продиктованы бизнес-правилами, а не схемой БД
- Репозиторий работает только с корнем: загружает и сохраняет агрегат целиком
- На другие агрегаты — ссылки только по идентификатору, не по объекту