Агрегаты
April 1, 2022
Введение
В предыдущем посте (Черновик поста) я рассмотрел подходы к проектированию модели информации системы и пришёл к выводу, что наилучшим подходом является подход на базе агрегатов. А в этом посте я подробно рассмотрю что такое агрегаты и как их проектировать.
Что такое агрегат? (TLDR)
Агрегат - это кластер сущностей и объектов-значений, объединённых общими инвариантами. Любое взаимодействие с агрегатом осуществляется через одну и только одну из его сущностей, называемую корнем агрегата.
Для того чтобы обеспечить соблюдение инвариантов, агрегат должен удовлетворять следующим требованиям:
- Выступать единицей персистанса (все сущности всегда загружаются и сохраняются вместе). "Точкой входа" персистанса (загружаемым и сохраняемым объектом) является корень агрегата
- Все модификации состояния агрегата должны осуществляться через корень
- Все сущности должны входить только в один агрегат
В объектно-ориентированном коде агрегат всегда материализуется минимум в два класса - корень агрегата и репозиторий агрегата. Внутри агрегата связи реализуются ссылками непосредственно на объекты. Между агрегатами связи реализуются через идентификаторы корней агрегатов.
Например, отчёт с непересекающимися отчётными периодами и составителем моделируется двумя агрегатами, которые на Котлине будут выглядеть так:
// Агрегат составителя отчёта
data class Author(
val id: Long,
val name: String
)
interface AuthorsRepo {
fun save(user: Author): Author
fun findById(id: Long): Author?
}
// Агрегат отчёта
data class ReportingPeriod(
val from: LocalDate,
val to: LocalDate
) {
init {
require(from <= to) { "$from > $to" }
}
}
data class Report(
val id: Long,
val reportingPeriods: List<ReportingPeriod>,
val authorId: Long
) {
init {
reportingPeriods.sortedBy { it.from }
.windowed(2, partialWindows = false)
.find { it[0].to >= it[1].from }
?.let { throw IllegalArgumentException("Report cannot have intersecting intervals: ${it[0]} and ${it[1]}") }
}
}
interface ReportsRepo {
fun save(user: Report): Report
fun findById(id: Long): Report?
}
Почему агрегата именно два, а не один или три? Ответ на этот вопрос лежит в принципах декомпозиции модели информации системы.
Принципы декомпозиции модели информации на агрегаты
При проектировании агрегатов (как и всех других элементов ПО) следует руководствоваться принципом высокой связности/низкой связанности. В случае агрегатов этот принцип выражается в соблюдении следующих ограничений:
- Агрегаты не должны иметь циклических связей
- Агрегаты должны определять область жизни всех сущностей, в них входящих. Эта область определяется областью жизни корня агрегата. Некорневые сущности не могут появляться раньше корня и продолжать существовать после его удаления.
- Агрегаты должны обеспечивать соблюдение инвариантов. Агрегаты предоставляют такое API, которое не позволит клиенту перевести модель в невалидное состояние.
- Агрегаты должны обеспечивать возможность реализовать все операции системы так, чтобы в одной транзакции менялся (или удалялся) один агрегат. Притом речь идёт именно об изменении (в том числе в виде удаления) существующих агрегатов - создавать и читать можно сколько угодно агрегатов.
- Агрегаты должны быть минимального необходимого размера. Имеется в виду и количество типов сущностей в агрегате, и количество экземпляров сущностей и их размер в байтах.
- Агрегаты должны храниться целиком в одной системе хранения данных на одном узле. Разные агрегаты одной системы могут храниться на разных узлах или в разных хранилищах.
- Агрегаты могут ссылаться на другие агрегаты только через идентификаторы корней. Внутри агрегата сущности могут свободно ссылаться друг на друга.
Так вот, почему агрегатов всё-таки именно два? Потому что отчёты и составители ценны сами по себе и имеют независимые жизненные циклы. А периоды не имеют смысла без отчёта и инвариант отсутствия пересечения определяется на кластере объектов отчёта и его отчётных периодов.
Методика декомпозиции модели информации на агрегаты
Я предпочитаю идти от обратного и на первом этапе считать каждую сущность отдельным агрегатом, а потом искать причины для объединения сущностей в агрегаты. Поэтому первой версией разбиения информации на агрегаты является сама ER-диаграмма.
Затем я ищу инварианты системы. Самый простой и часто встречаемый инвариант - область жизни одной сущности (А) не должна выходить за пределы области жизни другой сущности (Б). В этом случае сущности А и Б нужно объединить в агрегат с Б в качестве корня.
Но самые важные инварианты определяются конкретными людьми в конкретном контексте и для их выявления не существует универсального алгоритма на базе технических вводных. Чтобы выявить самые важные инварианты я обращаюсь к экспертам - заказчикам, пользователям, владельцам продукта, руководителям проектов, аналитикам и т.д. Зачастую эксперты самостоятельно не могут сформулировать инварианты, и им необходимо помочь, предлагая свои версии и задавая наводящие вопросы (например, могут ли пересекаться отчётные периоды). Конкретные техники и способы помощи экспертам подробно расписаны в книгах по DDD.
Действительно важные инварианты бизнес так или иначе озвучит - важно их услышать. Если не услышите в процессе разработки, то точно услышите, когда инвариант будет нарушен в промышленной эксплуатации с последствиями для бизнеса:)
Получив список инвариантов, я выбираю те, что затрагивают несколько типов или экземпляров сущностей. Сущности, которые участвуют в обеспечении одного инварианта, объединяю в агрегаты. Если речь идёт о разных типах, то в агрегат я объеднияю сами эти сущности. Если речь идёт о разных экземплярах одной сущности, то я присоединяю их списком к одной из существующих или специально созданной для этого сущности.
Затем я проверяю получившиеся агрегаты на соответствие принципам.
Принцип акцикличных агрегатов я сейчас нарушаю крайне редко, а нарушения сразу же видны на ER-диаграмме. При разбиении циклов я пользуюсь принципом стабильных зависимостей и удаляю ссылку из более "стабильного" агрегата. Стабильность определяется по значимости для бизнеса, вероятности изменений в будущем и количеству входящих связей. Значимость для бизнеса и вероятность изменений определяются посредством гадания на кофейной гуще.
Чтобы проверить принцип изменения одного агрегата в одной транзакции, я строю диаграмму эффектов. Диаграмма помогает мне увидеть операции, которые меняют несколько агрегатов. С такими агрегатами можно поступить по-разному:
- Если агрегаты всегда меняются вместе и размер позволяет - объединить их в один
- Если в одной операции смешались разные ответственности и есть возможность - разбить операцию на две
- Если в одной операции смешались разные ответственности, но разбиение операции невозможно или ухудшает дизайн - разбить изменения агрегатов на разные транзакции
- В первую очередь стоит посмотреть на вариант с использованием шины событий. В этом случае в первой транзакции остаётся изменение первого агрегата и генерация события, а в изменения остальных агрегатов уходят в транзакции обработчиков события.
- Если разбиение через события приводит к появлению каскада событий, то можно просто разбить операцию на несколько транзакций
- Если я уверен, что операция имеет высокую связность, а конкуренция за агрегат низкая (он меняется редко или только одним пользователем) - оставить всё как есть.
Если выполнять декомпозицию по описанной выше методики, то агрегаты с большим количеством видов сущностей у меня ни разу не появлялись. Поэтому для проверки принципа малых агрегатов остаётся удостоверится в отсутствии "больших" атрибутов и связей "один к действительно многому".
"Большие" тексты и массивы байт (картинки) я всегда выношу в отдельные агрегаты, даже когда это приводит к нарушениям принципов общей области жизни и изменения одного агрегата в одной транзакции. "Большой" - понятие относительное, и я выделяю атрибуты, если математическое ожидание их размера превышает ~4 килобайта.
"Действительно многие" связи я также всегда выношу в отдельные агрегаты вопреки остальным принципам. "Действительно многие" - тоже понятие относительное, и я выношу связи, когда математическое ожидание количества связанных объектов превышает ~20 штук.
Для проверки всех остальных принципов у меня нет устоявшихся инструментария и эвристик и их нарушение я ищу "методом вдумчивого взгляда".
Процесс "проверить-подрихтовать-обновить диаграммы" я повторяю до тех пор, пока не получу результат, проходящий проверку.
Частые ошибки проектирования агрегатов
Моделирование лишних связей
Самой распространённой ошибкой является добавление лишних ссылок между объектами. Предельный случай этой ошибки - модель связного графа объектов.
Но и в контексте проектирования агрегатов можно внести в модель лишние связи. Чаще всего причинами внесения лишних связей являются:
- удобство навигации - связь добавляется, чтобы была возможность добраться до объекта А, имея на руках объект Б
- отражение реальности - связь добавляется потому, что "в реальности" сущности связаны
- отражение модели данных - связь добавляется потому, что в логической схеме реляционной БД есть соответствующий атрибут и внешний ключ
- отражение пользовательского интерфейса - связь добавляется потому, что в UI в форме ввода или вывода данных, участвуют данные разных сущностей
Но напомню, что единственной причиной добавления ссылки на объект является вхождение объекта в агрегат, а единственной причиной включения объекта в агрегат является его участие в обеспечении инварианта. Поэтому если связь не требуется для обеспечения инварианта, то её включение необходимо дважды обдумать. Потому что, как я уже говорил, лишние связи ведут к повышению …кхм… связанности дизайна и как следствие усложнению системы и деградации производительности.
Анемичная доменная модель
Ещё одной распространённой ошибкой является анемичная доменная модель. Анемичная доменная модель характеризуется в первую очередь сущностями, у которых все свойства доступны для чтения и записи через геттеры и сеттеры. При этом всё поведение сущности ограничивается геттерами и сеттерами. Эта ошибка ведёт к утери возможности обеспечить соблюдение инвариантов.
Кроме того, последствием анемичной модели становится погребение существенных для агрегата трансформаций в методах сервисов приложения. Что влечёт за собой жёсткую сцепку трансформаций и ввода-вывода. Из-за чего:
- Усложняется задача тестирования трансформаций
- Снижается переиспользуемость трансформаций
- Усложняется задача понимания кода из-за смешения разных уровней абстракции в сервисе приложения
Давайте сравним решения одной и той же задачи с помощью анемичной и "полнокровной" доменных моделей.
В качестве задачи возьмём систему хранения информации о торговле на бирже крипто-валют. В центре этой системы находятся "торги по символу" - торги между парой крипто-валют.
Требования к системе следующие:
- Каждый пользователь по каждой паре может вести торги с использованием "грида" - по сути, набора значений параметров алгоритма торговли.
- В каждый момент времени для каждого символа пользователя может быть активен только один из гридов символа.
- Гриды уникально идентифицируются своим именем.
- Для каждого грида хранится статистика по торгам с его участием (в примере - только доход).
- Статистика может меняться только у активного грида.
- Каждый пользователь может вести торги одновременно по нулю и более символов.
Так же есть ограничение на API системы: обновление информации осуществляется посредством отправки клиентом списка активных в данный момент пар и их гридов.
Реализация этой задачи с анемичной доменной моделью будет выглядеть примерно так:
data class Grid(
var name: String,
var profit: BigDecimal
)
data class SymbolTrading(
var symbol: String,
var grids: MutableList<Grid>,
var activeGrid: Grid?
)
data class CustomerTradings(
var customerId: Long,
var tradings: MutableList<SymbolTrading>
)
data class ActiveSymbol(
var symbol: String,
var gridName: String
)
fun fetchCustomerSymbols(id: Long): CustomerTradings = TODO()
fun saveCustomerSymbols(customerSymbols: CustomerTradings): Unit = TODO()
fun updateCustomerSymbols(customerId: Long, activeSymbols: List<ActiveSymbol>) {
val customerSymbols = fetchCustomerSymbols(customerId) // (1)
activeSymbols.map { activeSymbol ->
val trading = customerSymbols.tradings.find { it.symbol == activeSymbol.symbol }
if (trading != null) { // (2)
trading.activeGrid = trading.grids.find { it.name == activeSymbol.gridName } ?: Grid(activeSymbol.gridName, BigDecimal(0))
} else {
val activeGrid = Grid(activeSymbol.gridName, BigDecimal(0))
customerSymbols.tradings.add(
SymbolTrading(activeSymbol.symbol, mutableListOf(activeGrid), activeGrid)
)
}
}
saveCustomerSymbols(customerSymbols) // (1)
}
Такую реализацию будет относительно сложно протестировать - надо будет либо сетапить и проверять состояние БД, либо использовать моки и делать тесты хрупким и зависящим от деталей реализации.
Также здесь в одном методе смешаны и работа с БД (1) и бизнес-правила (2).
Эти две проблемы можно решить посредством вынесения бизнес-правил в утилитарный метод.
Однако это не решит основную проблему - с таким подходом невозможно защитить инварианты.
Ничего не остановит клиентский код от удаления активного грида из trading.grids
.
Как и от изменения статистики по неактивному гриду.
Для того чтобы защитить инварианты, необходимо большую часть логики перенести в доменную модель. Также необходимо исключить возможность неконтролируемых операций записи.
Если оставаться в парадигме изменяемой модели данных, то это можно сделать путём сокращения области видимости сеттеров до внутренней в случае Котлина. Но тогда придётся выделять агрегаты в разные модули, что очень не удобно.
В том числе (но не только) по этому, я рекомендую пойти простым путём: сделать сущности неизменяемыми, с закрытым конструктором и опубликованным фабричным методом вместо него, который будет гарантировать соблюдение инвариантов.
typealias Symbol = String
typealias GridName = String
data class Grid(
val name: GridName,
val profit: BigDecimal = BigDecimal(0)
)
data class SymbolTrading private constructor(
val symbol: Symbol,
val grids: Map<GridName, Grid>,
val activeGrid: GridName
) {
init {
require(activeGrid in grids) { "Active grid ($activeGrid) should be within symbol's grids ($grids)" }
}
companion object {
fun new(symbol: Symbol, gridName: GridName) =
SymbolTrading(symbol, mapOf(gridName to Grid(gridName)), gridName)
}
fun activateGrid(gridName: String): SymbolTrading =
if (gridName in grids) SymbolTrading(symbol, grids, gridName)
else SymbolTrading(symbol, grids + (gridName to Grid(gridName)), gridName)
}
data class CustomerSymbols(
val customerId: Long,
val tradings: Map<Symbol, SymbolTrading>
) {
fun activateSymbols(activeSymbols: List<ActiveSymbol>): CustomerSymbols {
val updatedTradings = activeSymbols.map {
tradings[it.symbol]?.activateGrid(it.gridName)
?: SymbolTrading.new(it.symbol, it.gridName)
}
return CustomerSymbols(customerId, tradings + updatedTradings.associateBy { it.symbol })
}
}
data class ActiveSymbol(
val symbol: String,
val gridName: String
)
fun fetchCustomerSymbols(id: Long): CustomerSymbols = TODO()
fun saveCustomerSymbols(customerSymbols: CustomerSymbols): Unit = TODO()
fun updateCustomerSymbols(customerId: Long, activeSymbols: List<ActiveSymbol>) {
val customerSymbols = fetchCustomerSymbols(customerId)
val updatedCustomerSymbols = customerSymbols.activateSymbols(activeSymbols)
saveCustomerSymbols(updatedCustomerSymbols)
}
Такая реализация гарантирует, что любые модификации в данных должны будут пройти через CustomerSymbols
.
А так как CustomerSymbols
является единицей работы с БД, это гарантирует, что в БД не попадут никакие данные в обход кода контроля инвариантов в модели.
"Полнокровная" модель явно очерчивает список доступных операций и повышает их видимость - все операции над агрегатом находится рядом с агрегатом, а не разбросаны по сервисам и утилитарным методам.
Наконец, вся бизнес логика, которую надо покрыть полноценным набором тестов, ушла в чистую доменную модель которую очень легко тестировать.
А код с эффектами - updateCustomerSymbols
- стал тривиальным и его достаточно протестировать одним интеграционным, е2е или сценарным тестом.
Всё вместе - гарантия соблюдения инвариантов, упрощение анализа операций записи и упрощение тестирования - позволяет существенно уменьшить количество ошибок и регрессий и, как следствие, сократить стоимость разработки в длительной перспективе.
FAQ
Как программировать связи?
Связи внутри агрегата программируются свойствами со ссылками на объекты (a), а между агрегатами - свойствами с идентификаторами корней агрегатов (b):
data class Report(
val reportingPeriods: List<ReportingPeriod>, // (a)
val authorId: Long // (b)
)
Как защитить инварианты?
Для того чтобы гарантировать сохранность своих инвариантов, агрегат должен не позволять внешним клиентам менять состояние напрямую. Для достижения этого необходимо следовать принципу "Tell Don’t Ask". В случае агрегатов это означает предоставление корнем агрегата API внесения изменений вместо API получения изменяемых объектов внутренних сущностей.
При этом для получения информации об агрегате есть несколько подходов:
- Использовать неизменяемые классы для моделирования сущностей агрегатов.
Объекты таких классов можно безопасно передавать клиентам, поэтому агрегат может предоставить прямой доступ к своим частям.
- Плюсы: минимум дополнительного кода, хорошо масштабируется по количеству методов запроса информации
- Минусы: повышает связанность между клиентами и агрегатом.
- Предоставлять API в том числе для получения информации только на уровне корня агрегата.
В этом случае внутренние сущности вообще не попадают в публичное API агрегата.
- Плюсы: полностью скрывает устройство агрегата и минимизирует связанность между клиентами и агрегатом
- Минусы: плохо масштабируется по количеству методов запроса информации
- Использовать копии изменяемых объектов.
Этот подход похож на первый, тем что даёт клиентам доступ к частям агрегата, но клиентам выдаются не сами объекты частей, а их копии
- Плюсы: может быть использован в случае, когда нет возможности сделать объекты неизменяемыми
- Минусы: те же, что и у первого подхода, и необходимость в дополнительном коде копирования объектов в каждом геттере и, как следствие, большей нагрузки на сборщика мусора
- Использовать "read-only" представления.
Похож на третий подход, но вместо копий предполагается возвращать "read-only" представления изменяемых сущностей.
- Плюсы: нет необходимости в коде копирования объектов и снижение нагрузки на сборщика мусора
- Минусы: требует описания дополнительных интерфейсов для представлений и не очень надёжен - никто не запретит клиенту привести объект к изменяемому типу или поменять его через механизм рефлексии.
Я сам использую преимущественно первый подход, подключая второй в случаях, когда вижу необходимость в сокрытии структуры агрегата.
Как реализовать выборку данных для UI?
Существует несколько походов, и у каждого из них свои плюсы и минусы.
- Сборка DTO из агрегатов.
Заключается в том, чтобы вытащить нужные агрегаты из репозиториев и собрать из них DTO.
- Плюсы - минимальная сцепленность модулей, минимум дополнительного кода
- Минусы - потенциальные проблемы с производительностью из-за нескольких запросов в БД и больше ручной работы по добавлению зависимостей на репозитории и чтению данных из них.
- Сборка DPO из агрегатов.
По сути то же, что и первый вариант, только клиенту выдаётся Data Payload Object (DPO), вместо DTO.
DPO - это набор агрегатов, из которого клиент сам строит нужные ему структуры.
- Плюсы - минимальная сцепленность модулей, не нужен код для маппинга агрегатов в клиентские структуры.
- Минусы - клиенту будут возвращаться лишние данные, что может плохо сказаться на эффективности и безопасности системы.
- Отдельные модели для записи и чтения.
В дополнение к модели для записи (агрегаты), создаётся дополнительная денормализованная модель для чтения.
- Плюсы - эффективная работа с БД и создание DTO средствами ORM.
- Минусы - неявная сцепка модуля генерации DTO с деталями реализации всех модулей агрегатов, в два раза больше кода для описания модели данных.
- Сборка DTO в СУБД.
Современные СУБД (PostgreSQL, в частности) имеют встроенные средства для формирования JSON и позволяют собрать финальную DTO непосредственно SQL-запросом.
- Плюсы - самая эффективная работа с БД.
- Минусы - завязка на диалект определённой СУБД, менее удобный инструментарий для работы с SQL-запросами (чем с кодом на Kotlin, например), примитивные средства переиспользования кода и создания абстракций в самом SQL.
Варианты 1-3 подробно рассмотрены в книгах по DDD, вариант 4 хорошо описан в посте Лукаса Едера Stop Mapping Stuff in Your Middleware. Use SQL’s XML or JSON Operators Instead
Я сейчас в качестве варианта по умолчанию использую первый, а третий или четвёртый задействую в "горячем" коде. Второй вариант я пока что ни разу не использовал.
Зачем объединять сущности в агрегаты?
Для того чтобы обеспечить выполнение инварианта, затрагивающего несколько сущностей. Частым примером такого инварианта являются слабые сущности - сущности область жизни которых ограничена областью жизни другой сущности.
Почему агрегаты должны быть маленькими?
Из соображений производительности. Так как агрегаты являются единицей персистанса, большие агрегаты приведут к передаче больших объёмов данных по сети. И так как агрегаты являются единицей согласованности, большие агрегаты приведут к "большим" транзакциям (по количеству затронутых объектов и длительности), что повлечёт за собой большое количество конфликтующих транзакций. Это, в свою очередь, станет причиной либо ошибкам согласованности, либо большим накладным расходам на синхронизацию транзакций.
Когда не стоит объединять сущности в агрегаты?
Тогда, когда это приведёт к большим агрегатам. Например, пользователя, его фото и его комментарии лучше разделить по разным агрегатам, не смотря на то, что фото и комментарии являются слабыми сущностями. Фото - просто в силу большого размера. Комментарии - в силу их неограниченного роста.
Когда можно включать в агрегат много видов сущностей?
Агрегат может включать много видов сущностей, при соблюдении двух условий:
- Агрегат преимущественно изменяется одним пользователем - исключает проблемы с синхронизацией
- Агрегат остаётся ограниченным по размеру в байтах - исключает проблемы с производительностью
Почему в транзакции можно менять только один агрегат?
Во-первых - по определению. Агрегат определяет границы согласованности.
Во-вторых, потому что много маленьких агрегатов - это де-факто один большой агрегат со всеми вытекающими проблемами с синхронизацией и производительностью.
В-третьих, агрегаты могут храниться на разных машинах. А по определению агрегата это значит, что придётся иметь дело с распределёнными транзакциями. С которыми я бы предпочёл иметь дело в последнюю очередь.
Как обеспечить выполнение принципа "модификация одного агрегата в одной транзакции"?
В первую очередь, необходимо понять действительно ли эти модификации должны быть строго согласованы, или можно обойтись согласованностью в конечном итоге. Для этого автор одной из основных книг по ДДД предлагает следующий алгоритм:
- если обеспечение согласованности изменений является ответственностью пользователя, инициировавшего выполнение операции - то модификации должны быть строго согласованы
- иначе - можно обойтись согласованностью в конечном итоге
Если получилось что, модификации должны быть строго согласованы, то это значит, что вы "открыли" новый инвариант, и новый агрегат для его обеспечения. Если при этом агрегат становится большим - надо взвешивать плюсы и минусы и либо оставлять большой агрегат, либо возвращаться на этап проектирования агрегатов и операций системы и искать новое решение. Возможно несколько потенциальных решений:
- "Закрыть" этот неудобный инвариант и перейти к согласованности в конечном итоге
- Убрать из агрегата "лишние" сущности, которые были включены в него по причинам отличным от обеспечения инварианта
- Разбить большой агрегат, новым способом, который обеспечит соблюдение всех инвариантов. Возможно для этого придётся отказаться от некоторых инвариантов
Если же модификации могут быть согласованными в конечном итоге, то операцию необходимо разбить на две. Для этого надо разбить код на два транзакционных метода в слое сервисов приложения. Затем либо оба этих метода публикуются для клиентов, либо они связываются через публикацию доменного события первым методом и его обработку вторым.
Заключение
Агрегаты - действительно сложная тема:
Clustering Entities (5) and Value Objects (6) into an Aggregate with a carefully crafted consistency boundary may at first seem like quick work, but among all DDD tactical guidance, this pattern is one of the least well understood.
и её невозможно полностью понять, прочитав один пост.
Но я постарался собрать в этом посте необходимый минимум информации для того, чтобы спроектировать первый агрегат.
Дальнейшее чтение по теме
- An In-Depth Understanding of Aggregation in Domain-Driven Design [idddd]
- Domain-Driven Design: Tackling Complexity in the Heart of Software [ddd]
- Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# [dddmf]
- Domain-Driven Design Distilled [dddd]
- Patterns, Principles, and Practises of Domain-Driven Design [pppofddd]
- Implementing Domain-Driven Design [iddd]