Агрегаты

April 1, 2022

Введение

В предыдущем посте (Черновик поста) я рассмотрел подходы к проектированию модели информации системы и пришёл к выводу, что наилучшим подходом является подход на базе агрегатов. А в этом посте я подробно рассмотрю что такое агрегаты и как их проектировать.

Что такое агрегат? (TLDR)

Агрегат - это кластер сущностей и объектов-значений, объединённых общими инвариантами. Любое взаимодействие с агрегатом осуществляется через одну и только одну из его сущностей, называемую корнем агрегата.

Для того чтобы обеспечить соблюдение инвариантов, агрегат должен удовлетворять следующим требованиям:

  1. Выступать единицей персистанса (все сущности всегда загружаются и сохраняются вместе). "Точкой входа" персистанса (загружаемым и сохраняемым объектом) является корень агрегата
  2. Все модификации состояния агрегата должны осуществляться через корень
  3. Все сущности должны входить только в один агрегат

В объектно-ориентированном коде агрегат всегда материализуется минимум в два класса - корень агрегата и репозиторий агрегата. Внутри агрегата связи реализуются ссылками непосредственно на объекты. Между агрегатами связи реализуются через идентификаторы корней агрегатов.

Например, отчёт с непересекающимися отчётными периодами и составителем моделируется двумя агрегатами, которые на Котлине будут выглядеть так:

// Агрегат составителя отчёта

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?
}

Почему агрегата именно два, а не один или три? Ответ на этот вопрос лежит в принципах декомпозиции модели информации системы.

Принципы декомпозиции модели информации на агрегаты

При проектировании агрегатов (как и всех других элементов ПО) следует руководствоваться принципом высокой связности/низкой связанности. В случае агрегатов этот принцип выражается в соблюдении следующих ограничений:

  1. Агрегаты не должны иметь циклических связей
  2. Агрегаты должны определять область жизни всех сущностей, в них входящих. Эта область определяется областью жизни корня агрегата. Некорневые сущности не могут появляться раньше корня и продолжать существовать после его удаления.
  3. Агрегаты должны обеспечивать соблюдение инвариантов. Агрегаты предоставляют такое API, которое не позволит клиенту перевести модель в невалидное состояние.
  4. Агрегаты должны обеспечивать возможность реализовать все операции системы так, чтобы в одной транзакции менялся (или удалялся) один агрегат. Притом речь идёт именно об изменении (в том числе в виде удаления) существующих агрегатов - создавать и читать можно сколько угодно агрегатов.
  5. Агрегаты должны быть минимального необходимого размера. Имеется в виду и количество типов сущностей в агрегате, и количество экземпляров сущностей и их размер в байтах.
  6. Агрегаты должны храниться целиком в одной системе хранения данных на одном узле. Разные агрегаты одной системы могут храниться на разных узлах или в разных хранилищах.
  7. Агрегаты могут ссылаться на другие агрегаты только через идентификаторы корней. Внутри агрегата сущности могут свободно ссылаться друг на друга.

Так вот, почему агрегатов всё-таки именно два? Потому что отчёты и составители ценны сами по себе и имеют независимые жизненные циклы. А периоды не имеют смысла без отчёта и инвариант отсутствия пересечения определяется на кластере объектов отчёта и его отчётных периодов.

Методика декомпозиции модели информации на агрегаты

Я предпочитаю идти от обратного и на первом этапе считать каждую сущность отдельным агрегатом, а потом искать причины для объединения сущностей в агрегаты. Поэтому первой версией разбиения информации на агрегаты является сама ER-диаграмма.

Затем я ищу инварианты системы. Самый простой и часто встречаемый инвариант - область жизни одной сущности (А) не должна выходить за пределы области жизни другой сущности (Б). В этом случае сущности А и Б нужно объединить в агрегат с Б в качестве корня.

Но самые важные инварианты определяются конкретными людьми в конкретном контексте и для их выявления не существует универсального алгоритма на базе технических вводных. Чтобы выявить самые важные инварианты я обращаюсь к экспертам - заказчикам, пользователям, владельцам продукта, руководителям проектов, аналитикам и т.д. Зачастую эксперты самостоятельно не могут сформулировать инварианты, и им необходимо помочь, предлагая свои версии и задавая наводящие вопросы (например, могут ли пересекаться отчётные периоды). Конкретные техники и способы помощи экспертам подробно расписаны в книгах по DDD.

Действительно важные инварианты бизнес так или иначе озвучит - важно их услышать. Если не услышите в процессе разработки, то точно услышите, когда инвариант будет нарушен в промышленной эксплуатации с последствиями для бизнеса:)

Получив список инвариантов, я выбираю те, что затрагивают несколько типов или экземпляров сущностей. Сущности, которые участвуют в обеспечении одного инварианта, объединяю в агрегаты. Если речь идёт о разных типах, то в агрегат я объеднияю сами эти сущности. Если речь идёт о разных экземплярах одной сущности, то я присоединяю их списком к одной из существующих или специально созданной для этого сущности.

Затем я проверяю получившиеся агрегаты на соответствие принципам.

Принцип акцикличных агрегатов я сейчас нарушаю крайне редко, а нарушения сразу же видны на ER-диаграмме. При разбиении циклов я пользуюсь принципом стабильных зависимостей и удаляю ссылку из более "стабильного" агрегата. Стабильность определяется по значимости для бизнеса, вероятности изменений в будущем и количеству входящих связей. Значимость для бизнеса и вероятность изменений определяются посредством гадания на кофейной гуще.

Чтобы проверить принцип изменения одного агрегата в одной транзакции, я строю диаграмму эффектов. Диаграмма помогает мне увидеть операции, которые меняют несколько агрегатов. С такими агрегатами можно поступить по-разному:

  1. Если агрегаты всегда меняются вместе и размер позволяет - объединить их в один
  2. Если в одной операции смешались разные ответственности и есть возможность - разбить операцию на две
  3. Если в одной операции смешались разные ответственности, но разбиение операции невозможно или ухудшает дизайн - разбить изменения агрегатов на разные транзакции
    1. В первую очередь стоит посмотреть на вариант с использованием шины событий. В этом случае в первой транзакции остаётся изменение первого агрегата и генерация события, а в изменения остальных агрегатов уходят в транзакции обработчиков события.
    2. Если разбиение через события приводит к появлению каскада событий, то можно просто разбить операцию на несколько транзакций
  4. Если я уверен, что операция имеет высокую связность, а конкуренция за агрегат низкая (он меняется редко или только одним пользователем) - оставить всё как есть.

Если выполнять декомпозицию по описанной выше методики, то агрегаты с большим количеством видов сущностей у меня ни разу не появлялись. Поэтому для проверки принципа малых агрегатов остаётся удостоверится в отсутствии "больших" атрибутов и связей "один к действительно многому".

"Большие" тексты и массивы байт (картинки) я всегда выношу в отдельные агрегаты, даже когда это приводит к нарушениям принципов общей области жизни и изменения одного агрегата в одной транзакции. "Большой" - понятие относительное, и я выделяю атрибуты, если математическое ожидание их размера превышает ~4 килобайта.

"Действительно многие" связи я также всегда выношу в отдельные агрегаты вопреки остальным принципам. "Действительно многие" - тоже понятие относительное, и я выношу связи, когда математическое ожидание количества связанных объектов превышает ~20 штук.

Для проверки всех остальных принципов у меня нет устоявшихся инструментария и эвристик и их нарушение я ищу "методом вдумчивого взгляда".

Процесс "проверить-подрихтовать-обновить диаграммы" я повторяю до тех пор, пока не получу результат, проходящий проверку.

Частые ошибки проектирования агрегатов

Моделирование лишних связей

Самой распространённой ошибкой является добавление лишних ссылок между объектами. Предельный случай этой ошибки - модель связного графа объектов.

Но и в контексте проектирования агрегатов можно внести в модель лишние связи. Чаще всего причинами внесения лишних связей являются:

  1. удобство навигации - связь добавляется, чтобы была возможность добраться до объекта А, имея на руках объект Б
  2. отражение реальности - связь добавляется потому, что "в реальности" сущности связаны
  3. отражение модели данных - связь добавляется потому, что в логической схеме реляционной БД есть соответствующий атрибут и внешний ключ
  4. отражение пользовательского интерфейса - связь добавляется потому, что в UI в форме ввода или вывода данных, участвуют данные разных сущностей

Но напомню, что единственной причиной добавления ссылки на объект является вхождение объекта в агрегат, а единственной причиной включения объекта в агрегат является его участие в обеспечении инварианта. Поэтому если связь не требуется для обеспечения инварианта, то её включение необходимо дважды обдумать. Потому что, как я уже говорил, лишние связи ведут к повышению …​кхм…​ связанности дизайна и как следствие усложнению системы и деградации производительности.

Анемичная доменная модель

Ещё одной распространённой ошибкой является анемичная доменная модель. Анемичная доменная модель характеризуется в первую очередь сущностями, у которых все свойства доступны для чтения и записи через геттеры и сеттеры. При этом всё поведение сущности ограничивается геттерами и сеттерами. Эта ошибка ведёт к утери возможности обеспечить соблюдение инвариантов.

Кроме того, последствием анемичной модели становится погребение существенных для агрегата трансформаций в методах сервисов приложения. Что влечёт за собой жёсткую сцепку трансформаций и ввода-вывода. Из-за чего:

  1. Усложняется задача тестирования трансформаций
  2. Снижается переиспользуемость трансформаций
  3. Усложняется задача понимания кода из-за смешения разных уровней абстракции в сервисе приложения

Давайте сравним решения одной и той же задачи с помощью анемичной и "полнокровной" доменных моделей.

В качестве задачи возьмём систему хранения информации о торговле на бирже крипто-валют. В центре этой системы находятся "торги по символу" - торги между парой крипто-валют.

Требования к системе следующие:

  1. Каждый пользователь по каждой паре может вести торги с использованием "грида" - по сути, набора значений параметров алгоритма торговли.
  2. В каждый момент времени для каждого символа пользователя может быть активен только один из гридов символа.
  3. Гриды уникально идентифицируются своим именем.
  4. Для каждого грида хранится статистика по торгам с его участием (в примере - только доход).
  5. Статистика может меняться только у активного грида.
  6. Каждый пользователь может вести торги одновременно по нулю и более символов.

Так же есть ограничение на 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 получения изменяемых объектов внутренних сущностей.

При этом для получения информации об агрегате есть несколько подходов:

  1. Использовать неизменяемые классы для моделирования сущностей агрегатов. Объекты таких классов можно безопасно передавать клиентам, поэтому агрегат может предоставить прямой доступ к своим частям.
    1. Плюсы: минимум дополнительного кода, хорошо масштабируется по количеству методов запроса информации
    2. Минусы: повышает связанность между клиентами и агрегатом.
  2. Предоставлять API в том числе для получения информации только на уровне корня агрегата. В этом случае внутренние сущности вообще не попадают в публичное API агрегата.
    1. Плюсы: полностью скрывает устройство агрегата и минимизирует связанность между клиентами и агрегатом
    2. Минусы: плохо масштабируется по количеству методов запроса информации
  3. Использовать копии изменяемых объектов. Этот подход похож на первый, тем что даёт клиентам доступ к частям агрегата, но клиентам выдаются не сами объекты частей, а их копии
    1. Плюсы: может быть использован в случае, когда нет возможности сделать объекты неизменяемыми
    2. Минусы: те же, что и у первого подхода, и необходимость в дополнительном коде копирования объектов в каждом геттере и, как следствие, большей нагрузки на сборщика мусора
  4. Использовать "read-only" представления. Похож на третий подход, но вместо копий предполагается возвращать "read-only" представления изменяемых сущностей.
    1. Плюсы: нет необходимости в коде копирования объектов и снижение нагрузки на сборщика мусора
    2. Минусы: требует описания дополнительных интерфейсов для представлений и не очень надёжен - никто не запретит клиенту привести объект к изменяемому типу или поменять его через механизм рефлексии.

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

Как реализовать выборку данных для UI?

Существует несколько походов, и у каждого из них свои плюсы и минусы.

  1. Сборка DTO из агрегатов. Заключается в том, чтобы вытащить нужные агрегаты из репозиториев и собрать из них DTO.
    1. Плюсы - минимальная сцепленность модулей, минимум дополнительного кода
    2. Минусы - потенциальные проблемы с производительностью из-за нескольких запросов в БД и больше ручной работы по добавлению зависимостей на репозитории и чтению данных из них.
  2. Сборка DPO из агрегатов. По сути то же, что и первый вариант, только клиенту выдаётся Data Payload Object (DPO), вместо DTO. DPO - это набор агрегатов, из которого клиент сам строит нужные ему структуры.
    1. Плюсы - минимальная сцепленность модулей, не нужен код для маппинга агрегатов в клиентские структуры.
    2. Минусы - клиенту будут возвращаться лишние данные, что может плохо сказаться на эффективности и безопасности системы.
  3. Отдельные модели для записи и чтения. В дополнение к модели для записи (агрегаты), создаётся дополнительная денормализованная модель для чтения.
    1. Плюсы - эффективная работа с БД и создание DTO средствами ORM.
    2. Минусы - неявная сцепка модуля генерации DTO с деталями реализации всех модулей агрегатов, в два раза больше кода для описания модели данных.
  4. Сборка DTO в СУБД. Современные СУБД (PostgreSQL, в частности) имеют встроенные средства для формирования JSON и позволяют собрать финальную DTO непосредственно SQL-запросом.
    1. Плюсы - самая эффективная работа с БД.
    2. Минусы - завязка на диалект определённой СУБД, менее удобный инструментарий для работы с SQL-запросами (чем с кодом на Kotlin, например), примитивные средства переиспользования кода и создания абстракций в самом SQL.

Варианты 1-3 подробно рассмотрены в книгах по DDD, вариант 4 хорошо описан в посте Лукаса Едера Stop Mapping Stuff in Your Middleware. Use SQL’s XML or JSON Operators Instead

Я сейчас в качестве варианта по умолчанию использую первый, а третий или четвёртый задействую в "горячем" коде. Второй вариант я пока что ни разу не использовал.

Зачем объединять сущности в агрегаты?

Для того чтобы обеспечить выполнение инварианта, затрагивающего несколько сущностей. Частым примером такого инварианта являются слабые сущности - сущности область жизни которых ограничена областью жизни другой сущности.

Почему агрегаты должны быть маленькими?

Из соображений производительности. Так как агрегаты являются единицей персистанса, большие агрегаты приведут к передаче больших объёмов данных по сети. И так как агрегаты являются единицей согласованности, большие агрегаты приведут к "большим" транзакциям (по количеству затронутых объектов и длительности), что повлечёт за собой большое количество конфликтующих транзакций. Это, в свою очередь, станет причиной либо ошибкам согласованности, либо большим накладным расходам на синхронизацию транзакций.

Когда не стоит объединять сущности в агрегаты?

Тогда, когда это приведёт к большим агрегатам. Например, пользователя, его фото и его комментарии лучше разделить по разным агрегатам, не смотря на то, что фото и комментарии являются слабыми сущностями. Фото - просто в силу большого размера. Комментарии - в силу их неограниченного роста.

Когда можно включать в агрегат много видов сущностей?

Агрегат может включать много видов сущностей, при соблюдении двух условий:

  1. Агрегат преимущественно изменяется одним пользователем - исключает проблемы с синхронизацией
  2. Агрегат остаётся ограниченным по размеру в байтах - исключает проблемы с производительностью

Почему в транзакции можно менять только один агрегат?

Во-первых - по определению. Агрегат определяет границы согласованности.

Во-вторых, потому что много маленьких агрегатов - это де-факто один большой агрегат со всеми вытекающими проблемами с синхронизацией и производительностью.

В-третьих, агрегаты могут храниться на разных машинах. А по определению агрегата это значит, что придётся иметь дело с распределёнными транзакциями. С которыми я бы предпочёл иметь дело в последнюю очередь.

Как обеспечить выполнение принципа "модификация одного агрегата в одной транзакции"?

В первую очередь, необходимо понять действительно ли эти модификации должны быть строго согласованы, или можно обойтись согласованностью в конечном итоге. Для этого автор одной из основных книг по ДДД предлагает следующий алгоритм:

  1. если обеспечение согласованности изменений является ответственностью пользователя, инициировавшего выполнение операции - то модификации должны быть строго согласованы
  2. иначе - можно обойтись согласованностью в конечном итоге

Если получилось что, модификации должны быть строго согласованы, то это значит, что вы "открыли" новый инвариант, и новый агрегат для его обеспечения. Если при этом агрегат становится большим - надо взвешивать плюсы и минусы и либо оставлять большой агрегат, либо возвращаться на этап проектирования агрегатов и операций системы и искать новое решение. Возможно несколько потенциальных решений:

  1. "Закрыть" этот неудобный инвариант и перейти к согласованности в конечном итоге
  2. Убрать из агрегата "лишние" сущности, которые были включены в него по причинам отличным от обеспечения инварианта
  3. Разбить большой агрегат, новым способом, который обеспечит соблюдение всех инвариантов. Возможно для этого придётся отказаться от некоторых инвариантов

Если же модификации могут быть согласованными в конечном итоге, то операцию необходимо разбить на две. Для этого надо разбить код на два транзакционных метода в слое сервисов приложения. Затем либо оба этих метода публикуются для клиентов, либо они связываются через публикацию доменного события первым методом и его обработку вторым.

Заключение

Агрегаты - действительно сложная тема:

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.

Vaughn Vernon, Implementing Domain-Driven Design

и её невозможно полностью понять, прочитав один пост.

Но я постарался собрать в этом посте необходимый минимум информации для того, чтобы спроектировать первый агрегат.

Дальнейшее чтение по теме