Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Эта статья является конспектом материала Effective Aggregate Design Part I: Modeling a Single Aggregate.
Объединение сущностей (entities) и объектов значений (value objects) в агрегат с тщательно продуманными границами согласованности может показаться простым, но из всех тактических DDD шаблонов, агрегат является одним из самых сложных.
Для начала будет полезно рассмотреть некоторые общие вопросы. Является ли агрегат просто способом объединения тесно связанных объектов с общим корнем (Aggregate Root)? Если да, то есть ли какое-то ограничение на количество объектов, которые могут находиться в графе? Поскольку один агрегат может ссылаться на другой, можно ли перемещаться по агрегатам с помощью этих связей и менять данные объектов, входящих в определенный агрегат? И чем является инвариант и граница согласованности? Ответ на последний вопрос в значительной степени влияет на остальные ответы.
Есть множество способов смоделировать агрегат неправильно. Мы можем спроектировать слишком большой агрегат. С другой стороны, мы можем разделить все агрегаты так, что в результате нарушатся истинные инварианты. Как мы увидим, крайне важно избегать подобных крайностей и вместо этого обращать внимание на бизнес-правила.
Разработка приложения ProjectOvation
Давайте рассмотрим агрегаты на примере. Наша фиктивная компания разрабатывает приложение для поддержки проектов, основанных на методологии Scrum. Приложение следует традиционной модели управления проектами по методологии Scrum, то есть имеются продукт (product), владелец продукта (product owner), команды (team), элементы бэклога (backlog items), запланированные релизы (planned releases), спринты (sprints). Терминология Scrum формирует стартовую точку единого языка (ubiquitous language). Каждая организация, которая покупает подписку, регистрируется как арендатор (tenant), это еще один термин для нашего единого языка.
Компания собрала группу талантливых разработчиков. Однако, их опыт с DDD несколько ограничен. Это означает, что команда будет допускать ошибки, связанные с DDD по ходу разработки. Они будут расти, и мы вместе с ними. Их трудности помогут нам распознать и устранить подобные неблагоприятные ситуации, которые мы создали в нашем собственном программном обеспечении.
Как необходимо команде выбирать набор объектов для объединения в кластер? Паттерн агрегат рассматривает композицию и указывает на сокрытие информации. Он также рассматривает границы согласованности и транзакции, но команда не была обеспокоена этим. Вот что вышло. Команда рассмотрела следующие утверждения единого языка:
Продукты имеют элементы бэклога, релизы и спринты.
Можно добавлять новые элементы бэклога.
Можно добавлять новые релизы.
Можно добавлять новые спринты.
Запланированный элемент бэклога можно привязать к релизу.
Запланированный элемент бэклога можно привязать к спринту.
На основе этих утверждений команда спроектировала первый вариант модели. Давайте посмотрим, что у них вышло.
Первая попытка: большой агрегат
Команда придала большое значение фразе «Продукты имеют» в первом утверждении из списка выше. Для некоторых это звучало как композиция, поэтому объекты должны быть взаимосвязаны, как граф объектов. Разработчики добавили в спецификацию следующие правила согласованности:
Если элемент бэклога привязан к спринту, мы не должны позволить удалить его из системы.
Если спринт имеет элементы бэклога, то мы не должны позволить удалить его из системы.
Если релиз имеет запланированные элементы бэклога, то мы не должны позволить удалить его из системы.
Если элемент бэклога привязан к релизу, то мы не должны позволить удалить его из системы.
В результате Product был смоделирован как очень большой агрегат. Корневой объект, Product, содержит все BacklogItem, все Release, все Sprint экземпляры, связанные с ним. Такой интерфейс защищал все детали от случайного удаления клиента. Эта конструкция показана в следующем коде и в виде UML-диаграммы ниже.
public class Product extends ConcurrencySafeEntity {
private Set<BacklogItem> backlogItems;
private String description;
private String name;
private ProductId productId;
private Set<Release> releases;
private Set<Sprint> sprints;
private TenantId tenantId;
...
}
Большой агрегат выглядел привлекательно, но он не был по-настоящему практичен. Как только приложение стало работать в предполагаемой многопользовательской среде, начинали происходить регулярные сбои транзакций. Наши экземпляры агрегата используют оптимистическую блокировку для защиты объектов от одновременной модификации несколькими клиентами, что позволяет избежать использования блокировок БД. Объекты содержат номер версии, который увеличивается во время внесения изменений и проверяется перед тем, как эти изменения будут сохранены в БД. Если версия сохраняемого объекта больше версии (копии) клиента, то версия клиента считается устаревшей и обновления отклоняются.
Рассмотрим общий многопользовательский сценарий:
Два пользователя, Билл и Джо, смотрят одинаковый Product c версией 1 и начинают работать с ним.
Билл планирует новый BacklogItem и сохраняет. Версия становится 2.
Джо планирует новый Release и пытается сохранить, но он получает ошибку, так как версия его копии Product устарела и равнялась 1.
Такой механизм персистентности используется для борьбы с конкурентным доступом. Этот подход действительно важен для защиты инвариантов агрегата от одновременных изменений.
Эти проблемы согласованности возникли только у двух пользователей. Добавьте больше пользователей, и это станет намного большей проблемой. Несколько пользователей часто делают такие параллельные изменения во время совещания по планированию спринта и во время выполнения спринта. Неудачное выполнение всех запросов кроме одного на постоянной основе неприемлемо.
Планирования нового элемента бэклог не должно логически мешать планированию нового релиза. Почему Джо не может сохранить свои изменения? Большой агрегат был спроектирован с учетом ложных инвариантов, а не реальных бизнес-правил. Эти ложные инварианты являются искусственными ограничениями, налагаемыми разработчиками. Помимо проблем с транзакциями, также имеются недостатки производительности и масштабируемости.
Вторая попытка: несколько агрегатов
Теперь рассмотрим альтернативную модель, которая показана на рисунке 2. У нас есть четыре агрегата. Каждая зависимость использует ProductId, который является идентификатором Product-а.
Разбиение большого агрегата на четыре изменит контракт метода для Product. С большим агрегатом сигнатуры методов выглядели следующим образом:
public class Product ... {
...
public void planBacklogItem(
String aSummary, String aCategory,
BacklogItemType aType, StoryPoints aStoryPoints) {
...
}
...
public void scheduleRelease(
String aName, String aDescription,
Date aBegins, Date anEnds) {
...
}
public void scheduleSprint(
String aName, String aGoals,
Date aBegins, Date anEnds) {
...
}
...
}
Все эти методы являются командами. То есть они модифицируют состояние Product, добавляя новый элемент в коллекцию, поэтому их возвращаемый тип – void. Но с отдельными агрегатами мы имеем:
public class Product ... {
...
public BacklogItem planBacklogItem(
String aSummary, String aCategory,
BacklogItemType aType, StoryPoints aStoryPoints) {
...
}
public Release scheduleRelease(
String aName, String aDescription,
Date aBegins, Date anEnds) {
...
}
public Sprint scheduleSprint(
String aName, String aGoals,
Date aBegins, Date anEnds) {
...
}
...
}
Эти измененные методы теперь имеют контракт запроса и действуют как фабрики. То есть каждый из них создает новый экземпляр агрегата и возвращает ссылку на него. Теперь, когда клиент хочет запланировать элемент бэклога, сервис приложения должен выглядеть следующим образом:
public class ProductBacklogItemService ... {
...
@Transactional
public void planProductBacklogItem(
String aTenantId, String aProductId,
String aSummary, String aCategory,
String aBacklogItemType, String aStoryPoints) {
Product product =
productRepository.productOfId(
new TenantId(aTenantId),
new ProductId(aProductId));
BacklogItem plannedBacklogItem =
product.planBacklogItem(
aSummary,
aCategory,
BacklogItemType.valueOf(aBacklogItemType),
StoryPoints.valueOf(aStoryPoints));
backlogItemRepository.add(plannedBacklogItem);
}
...
}
Таким образом, мы решили проблему сбоя транзакции. Теперь любое количество экземпляров BacklogItem, Release и Sprint можно безопасно создавать с помощью одновременных запросов.
Однако даже при таких преимуществах четыре агрегата менее удобны с точки зрения использования клиентом. Возможно, мы могли бы вернуть большой агрегат, но устранив проблемы параллелизма. Однако даже если так сделать, то остается проблема, которая связана с тем, что большой агрегат может выйти из-под контроля. Прежде чем понять причину, давайте рассмотрим самый важный совет по моделированию, который нужен команде.
Моделируйте истинные инварианты в контексте согласованности
Пытаясь сформировать агрегаты в ограниченном контексте, мы должны понимать истинные инварианты модели. Только с этим знанием мы можем определить, какие объекты должны быть сгруппированы в определенный агрегат.
Инвариант — это бизнес-правило, которое всегда должно быть согласованным. Существуют различные виды согласованности. Одна из них это транзакционная, которая считается мгновенной и атомарной. Есть также конечная согласованность. При обсуждении инвариантов мы имеем в виду транзакционную согласованность. Мы можем иметь следующий инвариант:
Поэтому, когда, а = 2 и b = 3, с должно равняться 5. Согласно этому правилу, если с не равняется 5, то нарушается инвариант. Чтобы убедиться, что значение с согласовано, мы моделируем границу вокруг этих атрибутов модели.
AggregateType1 {
int a; int b; int c;
operations...
}
Граница согласованности логически утверждает, что все, что находится внутри, должно придерживаться определенных бизнес-инвариантных правил независимо от того, какие операции выполняются. Согласованность всего, что находится за пределами этой границы, не имеет отношения к агрегату. Таким образом, агрегат является синонимом границы транзакционной согласованности.
Во время использования типичного механизма персистентности мы используем одиночную транзакцию для управления согласованностью. Когда транзакция фиксируется, все, что находится внутри границы должно быть согласованным. Правильно спроектированный агрегат – это тот, что может быть изменен любым способом, требуемым бизнесом, с его инвариантами, полностью согласованными в рамках одной транзакции. И правильно спроектированный ограниченный контекст изменяет только один экземпляр агрегата в рамках одной транзакции во всех случаях.
Такое ограничение (модификация одного агрегата в рамках одной транзакции) может показаться чрезмерно строгим. Однако это эмпирическое правило и должно быть целью в большинстве случаев.
Поскольку агрегаты должны быть разработаны с акцентом на согласованность, это означает, что пользовательский интерфейс должен концентрировать каждый запрос на выполнение одной команды, которая затрагивает только один экземпляр агрегата. Если пользовательские запросы пытаются выполнить слишком много, это приведет к изменению нескольких экземпляров агрегата одновременно. Поэтому агрегаты в основном связаны с границами согласованности. Некоторые инварианты реального мира будут более сложными. Тем не менее, типичные инварианты будут менее требовательны к моделированию, что позволит проектировать небольшие агрегаты.
Проектируйте небольшие агрегаты
Теперь давайте подробно ответим на вопрос: какие дополнительные затраты будут связаны с сохранением большого агрегата? Даже если мы гарантируем, что каждая транзакция будет успешной, у нас все равно будут ограничения по производительности и масштабируемости. Увеличение пользователей и, следовательно, увеличение добавляемых ими данных приведет к огромному количеству продуктов, элементов бэклога, релизов, спринтов. Производительность и масштабируемость - это нефункциональные требования, которые нельзя игнорировать.
Что произойдет, когда пользователь захочет добавить элемент бэклога в продукт, которому уже много лет и у которого уже тысячи таких элементов бэклога? Предположим, что в механизме персистентности доступна ленивая загрузка (lazy loading). Мы почти никогда не загружаем все элементы бэклога, релизы и спринты сразу. Тем не менее, тысячи элементов бэклога будут загружены в память, чтобы добавить еще один новый элемент в коллекцию. Хуже, если механизм персистентности не поддерживает ленивую загрузку. Иногда нам приходится загружать несколько коллекций, например, во время добавления элемента бэклога в релиз или в спринт. Все элементы бэклога, а также все релизы или все спринты будут загружены.
Чтобы увидеть это более наглядно, посмотрим на диаграмму на рисунке 3. Не позволяйте 0..* обмануть вас. Число ассоциаций почти никогда не будет равным нулю и будет постоянно расти с течением времени. Скорее всего, нам придется загружать тысячи и тысячи объектов в память одновременно для выполнения относительно простых операций. И это только для одного члена команды одного арендатора. Мы должны иметь в виду, что это подобная ситуация может произойти одновременно с сотнями и тысячами арендаторов, каждый из которых имеет несколько команд и множество продуктов. И со временем ситуация будет только ухудшаться.
Этот большой агрегат никогда не будет иметь хорошую производительность или масштабируемость. Это была изначально плохая идея, потому что ложные инварианты и стремление к удобству композиции привели к ухудшению успешного выполнения транзакций, производительности и масштабируемости.
Если мы собираемся проектировать небольшие агрегаты, то нам необходимо выяснить, что значит «небольшой». Крайним случаем будет агрегат с его глобальным идентификатором и одним дополнительным атрибутом, что не рекомендуется делать, если только это действительно не то, что требуется одному конкретному агрегату. Лучше будет, если ограничим агрегат только корневой сущностью (root entity), минимальным количеством атрибутов и/или объектов значений (object value).
Однако, какие именно данные (атрибуты, объекты значения) необходимы? Ответ прост: те, что должны иметь согласованность друг с другом. Например, Product имеет атрибуты name и description. Мы не можем представить эти атрибуты несогласованными, смоделированными в отдельных агрегатах. Если вы изменяете только один из этих атрибутов, то вероятно, потому что вы исправляете ошибку. Даже если эксперты предметной области не будут думать об этом как о явном бизнес-правиле, это неявное правило.
Перед тем как смоделировать определенные данные как сущность, сначала спросите, должны ли эти данные изменяться с течением времени или их можно полностью заменить, когда это необходимо. Если экземпляры сущностей могут быть полностью заменены, то это указывает на необходимость использования объекта значения, а не сущность. Если мы проведем это упражнение для каждого конкретного случая, многие понятия, смоделированные как сущности, могут быть преобразованы в объекты значения. Предпочтение объектов значений как части агрегата не означает, что агрегат неизменяемый поскольку сама корневая сущность мутирует при замене одного из ее объектов значений. Объекты значений меньше и безопаснее в использовании. Из-за неизменяемости их проще тестировать, чтобы проверить их работоспособность.
Однако иногда использования нескольких сущностей имеет смысл. Например, сумма заказа не должна превышать максимально допустимое значение. Из этого следует, что и сумма всех элементов заказа не должна превышать допустимое значение. Если заказ и элементы заказа будут находиться в разных агрегатах, то одновременное добавление элемента заказа несколькими пользователями может превысить этот лимит. В этом случае лучше объединить сущности Order и OrderItem в один агрегат. Но следует подчеркнуть, что в большинстве случаев инвариантами бизнес-моделей управлять проще, чем в этом примере. Признавая это, помогает нам моделировать агрегаты с как можно меньшим количеством свойств.
Небольшие агрегаты не только более производительные и масштабируемые, они также имеют меньше конфликтов во время выполнения транзакций, которые препятствуют фиксации данных в хранилище. Это делает систему более удобной. Если инварианты требуют еще несколько сущностей или коллекцию, то необходимо добавить их, но продолжайте стремиться к тому, чтобы общий размер был как можно меньше.
Не доверяйте каждому сценарию использования
Нам необходимо согласовывать каждый сценарий использования с нашей текущей моделью, включая наши решения по агрегатам. Распространенной проблемой является, когда какой-то конкретный сценарий использования требует модификацию нескольких агрегатов. В таком случае нам необходимо понять, распространяется ли данный пользовательский сценарий на несколько транзакций или же он происходит только в одной. Если это второй случай, то стоит быть скептиком. Независимо от того, насколько хорошо данный сценарий использования расписан, он может неточно отражать истинные агрегаты нашей модели.
Предположим, что ваши границы агрегатов совпадают с реальными бизнес-ограничениями, тогда возникает проблема в случае, если бизнес-аналитики указывают на то, что можно увидеть на рис. 4. Обдумывая различные ситуации, вы увидите, что есть случаи, когда два из трех запросов потерпят неудачу. Ответ на этот вопрос может привести к более глубокому пониманию предметной области. Попытка сохранить согласованность нескольких агрегатов может означать, что ваша команда упустила инвариант. Вы можете, в конечном итоге, объединить несколько агрегатов в одну новую концепцию с новым названием, чтобы удовлетворить бизнес-правило.
Таким образом, новый сценарий использования может заставить нас пересмотреть модель агрегатов, но и здесь будьте скептичны. Формирование одного агрегата из нескольких может привести вас к проектированию большого агрегата и, соответственно, к его проблемам. Какой другой подход может помочь?
Просто потому, что дан сценарий использования, который требует поддержания согласованности в одной транзакции, не означает, что вы должны это делать. Часто в таких случаях бизнес-цель может быть достигнута с помощью конечной согласованности (eventual consistency) между агрегатами. Команда должна критически изучить сценарии использования и оспорить их предположения, особенно когда следование им в том виде, в каком они написаны, приведет к громоздким проектам. Команде, возможно, придется переписать сценарий использования. Новый вариант сценария использования будет указывать на конечную согласованность и приемлемую задержку обновления. Это один из вопросов, который будет рассматриваться во второй части.