Типобезопасный домен: Гексагональная архитектура на базе Effect Проектирование агрегата: размер, границы, связи
Глава

Проектирование агрегата: размер, границы, связи

Золотое правило маленьких агрегатов. Пять эвристик определения границ: истинный инвариант, транзакционная необходимость, жизненный цикл, конкурентный доступ, размер коллекции. Пошаговый процесс проектирования, матрица принятия решений, связи между агрегатами только по ID, рефакторинг границ.

Почему размер агрегата — критическое решение

Размер агрегата — это, пожалуй, самое важное решение при доменном моделировании. От него зависят:

  • Производительность — крупный агрегат означает загрузку большого объёма данных при каждой операции
  • Конкурентный доступ — чем больше агрегат, тем чаще возникают конфликты при параллельном редактировании
  • Масштабируемость — маленькие агрегаты проще распределить между серверами
  • Согласованность — маленький агрегат не может гарантировать инварианты между далёкими объектами
  • Простота — агрегат из 2–3 объектов понятнее, чем из 20

Это всегда компромисс между согласованностью (больше — лучше) и производительностью (меньше — лучше).


Золотое правило: начинай с маленького агрегата

Вон Вернон формулирует это как Rule of Thumb #2:

«Design small Aggregates.»

В идеале агрегат состоит из:

  • Aggregate Root (одна Entity)
  • Несколько Value Objects (внутри корня)
  • Минимум дочерних Entity (0–3, редко больше)

Почему? Потому что увеличить агрегат позже легко, а разбить большой агрегат на маленькие — больно:

Легко:                           Сложно:
Todo (агрегат)                   BigOrder (агрегат)
  + добавить Priority VO            ├─ Customer
  + добавить DueDate VO             ├─ OrderItem[] (тысячи!)
  + добавить Tags VO                ├─ Payment[]
  = всё ещё маленький агрегат       ├─ Shipment[]
                                    ├─ Invoice[]
                                    └─ AuditLog[]
                                    = нужно разбивать

Эвристики определения границ агрегата

Эвристика 1: Правило истинного инварианта

Включай в агрегат только то, что необходимо для поддержания инвариантов.

Задайте вопрос: «Если я изменю объект A, нужно ли проверить условие, затрагивающее объект B?»

  • Да → A и B в одном агрегате
  • Нет → A и B в разных агрегатах
Пример: Заказ и Позиции

Инвариант: "Общая сумма заказа = сумма позиций"
Добавление позиции → нужно пересчитать сумму → Order и OrderItem в одном агрегате ✅

Пример: Заказ и Клиент

Инвариант: ???
Изменение заказа → нужно проверить что-то у клиента? Нет!
Order и Customer → РАЗНЫЕ агрегаты ✅

Эвристика 2: Правило транзакционной необходимости

Включай в агрегат только то, что должно быть согласовано в рамках ОДНОЙ транзакции.

Спросите: «Если я добавляю задачу в список, должно ли прямо сейчас, в этой же транзакции обновиться что-то ещё?»

  • Лимит списка → да, в той же транзакции → в одном агрегате
  • Статистика пользователя → нет, может обновиться позже → другой агрегат
// ✅ В одной транзакции (один агрегат):
// TodoList.addItem проверяет лимит и уникальность заголовков

// ❌ НЕ в одной транзакции (разные агрегаты):
// Обновление UserStatistics.totalTodos
// → через Domain Event → eventual consistency

Эвристика 3: Правило жизненного цикла

Объекты с одинаковым жизненным циклом — кандидаты для одного агрегата.

  • Order и OrderItem — создаются и удаляются вместе → один агрегат
  • Order и Customer — разные жизненные циклы → разные агрегаты
  • TodoList и TodoItem — TodoItem не существует без TodoList → один агрегат

Эвристика 4: Правило конкурентного доступа

Если два пользователя часто одновременно работают с разными частями данных — разделите на агрегаты.

Сценарий: Wiki с разделами

Плохо: Article (агрегат)
  ├─ Section 1  ← Пользователь A редактирует
  ├─ Section 2  ← Пользователь B редактирует
  └─ Section 3
  Конфликт! Оба меняют один агрегат → один проиграет

Лучше: Article (агрегат) ← метаданные
        Section (агрегат) ← каждый раздел отдельно
  Пользователи A и B работают с разными агрегатами → нет конфликта

Эвристика 5: Правило размера коллекции

Если коллекция дочерних Entity может расти неограниченно — это сигнал к разделению.

Плохо: User (агрегат)
  └─ Orders[] (тысячи заказов!)
  Загрузка пользователя = загрузка всех заказов

Лучше: User (агрегат) ← профиль, настройки
        Order (агрегат) ← со ссылкой userId
  Каждый заказ — отдельный агрегат

Процесс проектирования агрегатов

Шаг 1: Идентифицируйте инварианты

Начните с бизнес-правил. Для каждого правила определите, какие объекты оно затрагивает:

Бизнес-правила Todo-приложения:

BR-1: "В списке задач не более 50 элементов"
  → Затрагивает: TodoList, TodoItem[]

BR-2: "Заголовки задач уникальны внутри списка"
  → Затрагивает: TodoList, TodoItem[]

BR-3: "Завершённую задачу нельзя снова активировать"
  → Затрагивает: TodoItem (локально!)

BR-4: "У каждого пользователя не более 10 списков"
  → Затрагивает: User, TodoList[]

BR-5: "Архивированный список нельзя изменять"
  → Затрагивает: TodoList

Шаг 2: Группируйте по транзакционной согласованности

Какие из этих правил должны проверяться в одной транзакции?

Группа A: BR-1, BR-2, BR-3, BR-5
  → TodoList + TodoItem[] = один агрегат (TodoList)
  → BR-3 локален для TodoItem, но TodoItem внутри TodoList

Группа B: BR-4
  → User + TodoList[]
  → НО: это потенциально большая коллекция
  → РЕШЕНИЕ: User — отдельный агрегат
  → BR-4 проверяется через Application Service (запрос к Repository)

Шаг 3: Примените эвристику размера

Агрегат TodoList с TodoItem[] — допустимо ли это?

  • Максимум 50 элементов → да, допустимый размер
  • Если бы было «неограниченное количество» → нет, нужно разбивать
  • TodoItem не имеет смысла без TodoList → да, один жизненный цикл
  • Два пользователя редактируют один список одновременно → маловероятно для Todo

Вердикт: TodoList с TodoItem[] — хороший агрегат.

Шаг 4: Определите связи между агрегатами

TodoList ←──(userId: UserId)──→ User

Связь по ID, не по объектной ссылке.
Каждый агрегат загружается независимо.

Связи между агрегатами: только по ID

Это одно из важнейших правил DDD — агрегаты ссылаются друг на друга только по идентификатору:

import { Schema } from "effect"

// ═══════════════════════════════════════════════
// ✅ ПРАВИЛЬНО: связь по ID
// ═══════════════════════════════════════════════

class TodoList extends Schema.Class<TodoList>("TodoList")({
  id: TodoListId,
  ownerId: UserId,        // Ссылка на агрегат User по ID
  // ...
}) {}

class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  todoListId: TodoListId, // Ссылка на агрегат TodoList по ID
  // ...
}) {}

// ═══════════════════════════════════════════════
// ❌ НЕПРАВИЛЬНО: прямая объектная ссылка
// ═══════════════════════════════════════════════

class BadTodoList extends Schema.Class<BadTodoList>("BadTodoList")({
  id: TodoListId,
  owner: User,            // Объект! Нарушает границу агрегата!
  // ...
}) {}

Почему только по ID?

  1. Независимая загрузка — агрегат TodoList можно загрузить без загрузки User
  2. Независимое хранение — агрегаты могут храниться в разных таблицах, БД, сервисах
  3. Независимые транзакции — изменение TodoList не блокирует User
  4. Чистота границ — невозможно случайно изменить User из TodoList

Когда нужны данные другого агрегата?

Если бизнес-логика TodoList требует проверить что-то у User, это делается через Application Service (не через прямую ссылку):

import { Effect, Context } from "effect"

// Application Service оркестрирует несколько агрегатов
const createTodoList = (params: {
  readonly userId: UserId
  readonly name: ListName
  readonly now: Date
}): Effect.Effect<
  TodoList,
  UserNotFound | TooManyLists,
  TodoListRepository | UserRepository
> =>
  Effect.gen(function* () {
    const userRepo = yield* UserRepository
    const listRepo = yield* TodoListRepository

    // Загружаем User для проверки BR-4
    const user = yield* userRepo.findById(params.userId)

    // Проверяем BR-4: "не более 10 списков"
    const existingLists = yield* listRepo.countByOwner(params.userId)
    if (existingLists >= 10) {
      return yield* new TooManyLists({
        userId: params.userId.value,
        limit: 10,
        current: existingLists
      })
    }

    // Создаём агрегат
    return yield* TodoList.create({
      id: new TodoListId({ value: generateId() }),
      name: params.name,
      ownerId: params.userId,
      now: params.now
    })
  })

Альтернативные границы: два варианта для Todo

Рассмотрим два варианта проектирования для Todo-приложения и сравним их:

Вариант A: TodoList как агрегат с вложенными TodoItem

TodoList (Aggregate Root)
  ├─ TodoItem #1 (Child Entity)
  ├─ TodoItem #2 (Child Entity)
  └─ TodoItem #3 (Child Entity)

Плюсы:

  • Все инварианты (лимит, уникальность) проверяются внутри агрегата
  • Транзакционная согласованность гарантирована
  • Простая модель

Минусы:

  • При изменении одного TodoItem загружается весь список
  • При 50 задачах это 50 объектов в памяти
  • Конфликт версий при параллельных изменениях разных задач

Когда выбирать: до ~100 дочерних элементов, инварианты между элементами, низкий параллелизм.

Вариант B: Todo как отдельный агрегат

TodoList (Aggregate Root)
  └─ metadata only (name, maxItems, itemCount)

Todo (Aggregate Root)
  └─ todoListId: TodoListId (ссылка по ID)

Плюсы:

  • Каждая задача загружается/сохраняется независимо
  • Нет конфликтов версий при параллельном редактировании разных задач
  • Масштабируется до миллионов задач

Минусы:

  • Инвариант «уникальность заголовков» нужно проверять через Repository (запрос к БД)
  • Инвариант «не более 50 задач» проверяется через Application Service
  • Больше сложности, больше движущихся частей

Когда выбирать: неограниченное количество дочерних элементов, высокий параллелизм, инварианты можно вынести в eventual consistency.

Наш выбор для курса

Мы выбираем Вариант ATodoList как агрегат с вложенными TodoItem. Причины:

  1. Учебный пример — важнее продемонстрировать концепцию агрегата в чистом виде
  2. Лимит 50 задач — коллекция ограничена, производительность не проблема
  3. Инварианты (уникальность, лимит) — естественно живут внутри агрегата
  4. Todo-приложение — низкий параллелизм

В production-системе с миллионами задач мы бы выбрали Вариант B.


Матрица принятия решения

Используйте эту матрицу при проектировании:

ФакторВ один агрегатВ разные агрегаты
ИнвариантЕсть общий инвариантИнварианты независимы
ТранзакцияНужна одна транзакцияДопустима eventual consistency
Жизненный циклСоздаются/удаляются вместеНезависимые жизненные циклы
Размер коллекцииДо ~100 элементовНеограниченный рост
КонкурентностьРедко редактируется параллельноЧасто параллельные изменения
Частота измененийЧасти меняются вместеЧасти меняются независимо
Смысловая связь«Часть целого»«Связан с»

Если большинство ответов в левой колонке → один агрегат.
Если большинство в правой → разные агрегаты.


Рефакторинг агрегатов

Границы агрегатов — не вечны. По мере развития системы может потребоваться рефакторинг:

Разделение: большой → два маленьких

До:                          После:
Order (агрегат)              Order (агрегат)
  ├─ OrderItem[]               ├─ OrderItem[]
  ├─ Payment                   └─ totalAmount
  └─ Shipment               
                             Payment (агрегат)
                               └─ orderId: OrderId

                             Shipment (агрегат)
                               └─ orderId: OrderId

Слияние: два маленьких → один

До:                          После:
TodoList (агрегат)           TodoList (агрегат)
  └─ name, maxItems            ├─ name, maxItems
                               └─ TodoItem[]
TodoItem (агрегат)
  └─ todoListId

Сигналы к разделению:

  • Растущие конфликты конкурентного доступа
  • Медленная загрузка из-за большого объёма данных
  • Появление инвариантов, не связанных друг с другом

Сигналы к слиянию:

  • Транзакции постоянно охватывают несколько агрегатов
  • Появление инвариантов между объектами разных агрегатов
  • Код в Application Service дублирует логику координации

Резюме

Проектирование агрегатов — это искусство баланса:

  1. Начинайте с маленьких агрегатов — одна Entity + Value Objects
  2. Используйте инварианты как главный критерий — если есть общее бизнес-правило, объекты в одном агрегате
  3. Применяйте эвристики — жизненный цикл, конкурентность, размер коллекции
  4. Связывайте агрегаты только по ID — никаких объектных ссылок
  5. Между агрегатами — eventual consistency — через Domain Events
  6. Готовьтесь к рефакторингу — границы могут меняться по мере понимания домена

В Effect-ts границы агрегатов выражаются через R-канал: если метод агрегата не требует внешних зависимостей (R = never), он работает только с внутренними данными. Если Application Service координирует несколько агрегатов, это видно в типе: Effect<Result, Error, RepoA | RepoB>.