open atlas
↑ К треку
Архитектурные паттерны ARCH · 06 · 03

Aggregates и инварианты

Aggregate — граница транзакционной согласованности. Aggregate root — единственная точка входа извне. Одна транзакция изменяет один aggregate. Размер aggregate определяется инвариантом, который он защищает, а не графом данных.

ARCH Senior ◷ 24 min
Уровень
ОсновыJuniorMiddleSenior

В Sales-контексте B2B-платформы был Quote, содержащий объекты LineItem. У LineItem был NegotiatedPrice. У Quote было бизнес-правило: суммарная стоимость всех позиций не должна превышать кредитный лимит клиента в момент подачи. Правило казалось простым. Потом команда столкнулась с проблемой конкурентности. Два менеджера одновременно редактировали один Quote — один добавлял дорогостоящую позицию, другой удалял другую. Оба изменения прошли проверку кредитного лимита по отдельности, потому что каждое читало Quote до того, как запись другого завершилась. После коммита обоих транзакций суммарная стоимость превысила лимит. Инвариант — правило кредитного лимита — был нарушен, несмотря на то что оба изменения по отдельности были валидны. Первым решением команды стала оптимистичная блокировка на строку Quote. Это помогло для конфликтов на уровне Quote, но потом всплыла другая проблема: разработчик написал batch-задание, которое обновляло цены LineItem напрямую, минуя Quote. Задание не знало о правиле кредитного лимита, потому что проверяло только LineItem, а не владеющий им Quote. Две отдельные транзакции, две отдельные проверки инвариантов — одно и то же повреждение. Команда случайно разделила то, что должно было быть одной единицей согласованности, на два. Паттерн aggregate — ответ на этот класс проблем. Aggregate определяет границу транзакционной согласованности: кластер доменных объектов, рассматриваемый как одно целое для целей изменений данных. Aggregate root — единственная точка входа. Каждая мутация проходит через него. Инварианты, которые он применяет, гарантированно остаются верными после каждой транзакции.

Что aggregate применяет

Aggregate — это не группировка связанных данных. Это группировка данных вокруг инварианта согласованности — бизнес-правила, которое должно оставаться верным для нескольких объектов после каждой операции.

Aggregate Quote применяет правило: «суммарная согласованная стоимость всех позиций не должна превышать кредитный лимит клиента в момент подачи». Это правило охватывает Quote и все его объекты LineItem. Quote не может делегировать проверку в LineItem, потому что LineItem не знает о суммарной стоимости. Ни один отдельный LineItem не может нарушить правило — только коллекция в целом.

Это определяющий критерий для aggregate: какой наименьший набор объектов необходимо загрузить и заблокировать вместе, чтобы применить конкретный бизнес-инвариант? Этот набор и есть aggregate. Объект в корне этого набора, тот, что применяет инвариант, — aggregate root.

Aggregate root — единственная точка входа для внешнего кода. Ни один объект снаружи aggregate не держит прямую ссылку на внутренний LineItem. Внешний код вызывает quote.addLineItem(...) или quote.removeLineItem(...). Root Quote запускает проверку инварианта. Объекты LineItem — детали реализации: к ним нельзя обратиться, изменить или удалить, минуя root.

Одна транзакция изменяет один aggregate

Это правило — структурное следствие границы согласованности. Если одна транзакция изменяет два aggregate’а, то либо:

  1. Вы поместили неправильные объекты в один aggregate (инвариант на самом деле охватывает оба — они должны быть одним aggregate’ом), либо
  2. Вы приняли eventual consistency между двумя aggregate’ами (инвариант не должен применяться синхронно сразу для обоих — они коммуницируют через события).

Правило «одна транзакция — один aggregate» объясняет, почему batch-задание из вступительной истории породило проблему. Оно изменяло LineItem в одной транзакции, не проходя через Quote. Проверка инварианта Quote никогда не запускалась. Решение: убрать прямые записи LineItem полностью — все мутации идут через Quote.addLineItem() или Quote.updateLineItemPrice(), которые применяют инвариант после каждого изменения.

Почему это работает

Почему это правило такое строгое? Потому что «одна транзакция — один aggregate» — это то, что делает границу aggregate применимой. Если два aggregate’а можно изменить в одной транзакции, то любой инвариант, охватывающий оба, должен проверяться в каждой транзакции, затрагивающей любой из них. Нельзя определить, каким транзакциям нужна проверка, не анализируя все сразу. Граница aggregate становится бессмысленной. Как только вы принимаете одну транзакцию на aggregate, aggregate root становится единственным местом, где должна жить логика инварианта — и вы можете рассуждать о ней изолированно.

Ссылки на другие aggregate’ы по ID

Когда одному aggregate’у нужно ссылаться на другой, он делает это только через идентификатор — не через ссылку на объект. Aggregate Quote в Sales может ссылаться на Customer, которому принадлежит. Но Customer — отдельный aggregate (со своими инвариантами вокруг кредитной истории и статуса аккаунта). Aggregate Quote хранит customerId — value object, оборачивающий идентификатор клиента, — а не прямую ссылку на объект Customer.

Это имеет три следствия:

  1. Нет каскадной загрузки: загрузка Quote не загружает автоматически граф Customer. Каждый aggregate загружается независимо.
  2. Нет каскадной мутации: aggregate root может применять инварианты только над объектами, которыми владеет. Он не может мутировать Customer через ссылку — для этого нужно обращаться к Customer через его собственный root.
  3. Явная кросс-aggregate согласованность: если операция должна изменить и Quote, и Customer — эти изменения происходят в отдельных транзакциях, координируемых через доменные события (событие QuoteSubmitted может запустить процесс, обновляющий Customer.openQuoteCount). Это eventual consistency по замыслу.

Размер aggregate’а: по инварианту, а не по графу данных

Самая распространённая ошибка в дизайне aggregate’ов — делать их слишком большими. Команды смотрят на граф объектов — Quote содержит LineItems, LineItems содержат Products, Products содержат PricingRules, PricingRules содержат Tiers — и объединяют всё в один большой aggregate, потому что «всё это связано».

Правильный вопрос размера: каков инвариант, который должен выполняться транзакционно? Если инвариант — «суммарная стоимость позиций не должна превышать кредитный лимит при подаче», то aggregate — это Quote + LineItems. Всё. Product — отдельный aggregate: его собственные правила (название, описание, статус в каталоге) никак не связаны с правилом кредитного лимита Quote. PricingRule — вероятно, тоже отдельный aggregate. Quote хранит ссылку productId и value object NegotiatedPrice — он не владеет Product.

Малые aggregate’ы имеют два критических преимущества:

Конкуренция за блокировки: большой aggregate блокируется на всё время транзакции. Если aggregate Order содержит каждую позицию, каждую отгрузку, каждый платёж и весь журнал взаимодействий с клиентом — тогда каждая операция над заказом блокирует весь заказ. В нагруженной системе это создаёт узкие места сериализации. Малые aggregate’ы блокируют только то, что нужно заблокировать для конкретного инварианта.

Концептуальная ясность: большой aggregate, объединяющий несвязанные вещи, — сигнал о том, что инварианты не были чётко определены. «Всё про заказ» — не инвариант. «Согласованная стоимость Quote не должна превышать кредитный лимит клиента» — это инвариант. Aggregate’ы, размер которых определяется инвариантом, делают бизнес-правила видимыми в коде.

Умолчание для размера aggregate’а: настолько мал, насколько позволяет инвариант. Начните с наименьшего aggregate’а, применяющего инвариант. Расширяйте только когда реальный инвариант этого требует — не когда граф объектов это предполагает.

Викторина

Разработчик предлагает: «Aggregate Order должен содержать OrderLineItems, Shipments и Payments, потому что все они — часть заказа». Опытный в DDD ревьюер возражает. В чём его основной аргумент?

Викторина

Aggregate Quote применяет правило: «суммарная стоимость позиций не должна превышать кредитный лимит клиента при подаче». Команда хочет обновлять кредитный лимит клиента при подаче Quote (резервировать кредит). Они предлагают: внутри `quote.submit()` вызвать `customer.reserveCredit(quote.total())` напрямую. Что не так с этим дизайном?

Викторина

У команды aggregate `Order`, содержащий каждый `LineItem`, и они испытывают конкуренцию за блокировки: обработка заказов медленная, потому что две операции, затрагивающие разные позиции одного заказа, не могут выполняться параллельно. Разработчик предлагает: «Сделать каждый LineItem собственным aggregate'ом». Какой trade-off команда должна оценить перед этим?

Вспомните перед уходом
  1. 01
    Что такое aggregate root и почему он — единственная внешняя точка входа?
  2. 02
    Почему «одна транзакция изменяет один aggregate» следует из границы aggregate'а?
  3. 03
    Каков правильный критерий размера aggregate'а и почему «размер по графу данных» провалится?
Итог

Баг конкурентности из вступительной истории произошёл из-за разделения одной единицы согласованности — Quote с его инвариантом кредитного лимита — на два отдельно изменяемых объекта. Решением была не новая блокировка; это было признание того, что Quote и его LineItems образуют один aggregate, с Quote как root’ом, применяющим правило кредитного лимита по всем позициям в каждой транзакции.

Aggregate — граница транзакционной согласованности. Root — единственная точка входа. Все мутации проходят через него, чтобы проверка инварианта всегда запускалась. Одна транзакция изменяет один aggregate. Другие aggregate’ы ссылаются по ID — нет прямых объектных ссылок через границы aggregate’ов.

Размер — критически важный навык. Вопрос не «что связано?», а «какой инвариант должен выполняться транзакционно?». Начните с наименьшего aggregate’а, применяющего инвариант. Противостойте соблазну включить всё связанное — большие aggregate’ы сериализуют несвязанные операции и загружают ненужные данные. Кросс-aggregate согласованность разрешается через доменные события и eventual consistency — что почти всегда приемлемо, потому что очень мало бизнес-правил требуют одновременных атомарных изменений нескольких доменных объектов.

Следующий урок рассматривает, как множество bounded contexts соотносятся друг с другом — стратегические паттерны интеграции, позволяющие независимым контекстам коммуницировать без утечки их моделей друг в друга.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.