Проектирование агрегата: размер, границы, связи
Золотое правило маленьких агрегатов. Пять эвристик определения границ: истинный инвариант, транзакционная необходимость, жизненный цикл, конкурентный доступ, размер коллекции. Пошаговый процесс проектирования, матрица принятия решений, связи между агрегатами только по 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?
- Независимая загрузка — агрегат
TodoListможно загрузить без загрузкиUser - Независимое хранение — агрегаты могут храниться в разных таблицах, БД, сервисах
- Независимые транзакции — изменение
TodoListне блокируетUser - Чистота границ — невозможно случайно изменить
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.
Наш выбор для курса
Мы выбираем Вариант A — TodoList как агрегат с вложенными TodoItem. Причины:
- Учебный пример — важнее продемонстрировать концепцию агрегата в чистом виде
- Лимит 50 задач — коллекция ограничена, производительность не проблема
- Инварианты (уникальность, лимит) — естественно живут внутри агрегата
- 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 дублирует логику координации
Резюме
Проектирование агрегатов — это искусство баланса:
- Начинайте с маленьких агрегатов — одна Entity + Value Objects
- Используйте инварианты как главный критерий — если есть общее бизнес-правило, объекты в одном агрегате
- Применяйте эвристики — жизненный цикл, конкурентность, размер коллекции
- Связывайте агрегаты только по ID — никаких объектных ссылок
- Между агрегатами — eventual consistency — через Domain Events
- Готовьтесь к рефакторингу — границы могут меняться по мере понимания домена
В Effect-ts границы агрегатов выражаются через R-канал: если метод агрегата не требует внешних зависимостей (R = never), он работает только с внутренними данными. Если Application Service координирует несколько агрегатов, это видно в типе: Effect<Result, Error, RepoA | RepoB>.