Aggregate: кластер объектов с единой границей
Что такое Aggregate в DDD, зачем он нужен, граница транзакционной согласованности, анатомия агрегата (Root, Child Entity, Value Object), пять правил Вона Вернона, жизненный цикл и связь с Repository. Первый взгляд на реализацию через Effect-ts.
Проблема: хаос без границ
Представьте доменную модель интернет-магазина. У вас есть Order, OrderItem, Product, Customer, Address, Payment, Shipment. Все эти объекты как-то связаны друг с другом. Теперь вопрос: когда вы сохраняете заказ, что именно вы сохраняете?
- Только
Order? Orderвместе сOrderItem[]?Order+OrderItem[]+Payment?- Всё дерево целиком, включая
CustomerиProduct?
А если два пользователя одновременно редактируют один заказ — один меняет адрес доставки, другой добавляет товар — как разрешить конфликт? Какие данные нужно блокировать? Весь заказ? Только строку товара?
Без чётких границ вы получаете:
- Проблему согласованности — данные сохраняются частично, система оказывается в невалидном состоянии
- Проблему конкурентного доступа — невозможно определить, что блокировать
- Проблему производительности — приходится загружать огромные графы объектов ради изменения одного поля
- Проблему тестирования — невозможно протестировать логику одного объекта без создания всего окружения
Именно для решения этих проблем Эрик Эванс в книге «Domain-Driven Design: Tackling Complexity in the Heart of Software» (2003) ввёл концепцию Aggregate.
Что такое Aggregate
Aggregate (Агрегат) — это кластер доменных объектов (Entity и Value Object), которые рассматриваются как единое целое с точки зрения изменений данных.
Ключевое слово здесь — «единое целое с точки зрения изменений». Aggregate определяет:
- Границу согласованности (consistency boundary) — все инварианты внутри агрегата гарантированно соблюдаются после каждой операции
- Границу транзакции (transactional boundary) — все изменения внутри агрегата атомарны: либо все применяются, либо ни одно
- Границу загрузки (loading boundary) — агрегат загружается и сохраняется как целая единица
- Границу конкурентного доступа (concurrency boundary) — оптимистичная или пессимистичная блокировка применяется к агрегату целиком
Определение Эрика Эванса
«An Aggregate is a cluster of associated objects that we treat as a unit for the purpose of data changes. Each Aggregate has a root and a boundary. The boundary defines what is inside the Aggregate. The root is a single, specific Entity contained in the Aggregate.» — Eric Evans, Domain-Driven Design
Определение Вона Вернона
«An Aggregate is composed of either a single Entity or a cluster of Entities and Value Objects that must be transactionally consistent.» — Vaughn Vernon, Implementing Domain-Driven Design
Анатомия Aggregate
Aggregate состоит из трёх компонентов:
┌──────────────────────────────────────────────┐
│ AGGREGATE BOUNDARY │
│ │
│ ┌───────────────────────────────────┐ │
│ │ AGGREGATE ROOT (Entity) │ │
│ │ ┌────────────┐ ┌──────────────┐ │ │
│ │ │ Identity │ │ Behavior │ │ │
│ │ │ (TodoId) │ │ (methods) │ │ │
│ │ └────────────┘ └──────────────┘ │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────┐ ┌───────────────────┐ │
│ │ Child Entity │ │ Value Object │ │
│ │ (TodoItem) │ │ (Priority) │ │
│ └───────────────┘ └───────────────────┘ │
│ │
│ ┌───────────────┐ ┌───────────────────┐ │
│ │ Child Entity │ │ Value Object │ │
│ │ (Comment) │ │ (DueDate) │ │
│ └───────────────┘ └───────────────────┘ │
│ │
│ ИНВАРИАНТЫ: │
│ • Не более 50 задач в списке │
│ • Уникальные заголовки внутри списка │
│ • Хотя бы одна задача при создании │
└──────────────────────────────────────────────┘
1. Aggregate Root (Корень агрегата)
Aggregate Root — это единственная Entity внутри агрегата, через которую внешний мир взаимодействует со всем агрегатом. Это «входная дверь» — единственная точка входа.
2. Внутренние Entity
Дочерние Entity имеют свою идентичность, но она локальна — имеет смысл только в контексте родительского агрегата. Например, OrderItem #3 — это третий элемент конкретного заказа. Без заказа он не имеет смысла.
3. Value Objects
Value Objects внутри агрегата — это значения без идентичности: Money, Priority, DueDate. Они полностью принадлежат агрегату и не существуют самостоятельно.
Граница транзакционной согласованности
Самый важный аспект Aggregate — это его роль как границы транзакционной согласованности. Что это значит на практике?
Правило: одна транзакция = один агрегат
В рамках одной бизнес-операции вы можете изменять только один агрегат. Это самое жёсткое и самое важное правило DDD:
✅ Одна транзакция → один Aggregate
Сохранить TodoList с изменёнными задачами
✅ Одна транзакция → один Aggregate
Создать новый Order с OrderItems
❌ Одна транзакция → два Aggregate
Перевести деньги со счёта A на счёт B
(нужен Saga / Process Manager)
❌ Одна транзакция → два Aggregate
Создать Todo и обновить статистику User
(используй Domain Event)
Почему только один агрегат?
- Масштабируемость — если транзакция захватывает несколько агрегатов, они не могут храниться на разных серверах
- Конкурентный доступ — чем больше объектов в транзакции, тем выше вероятность конфликта
- Простота — один агрегат = одна блокировка = предсказуемое поведение
- Производительность — маленькие транзакции быстрее больших
Согласованность между агрегатами
Если бизнес-правило затрагивает несколько агрегатов, используется eventual consistency (согласованность в конечном итоге) через Domain Events:
TodoList UserStatistics
│ │
│ 1. addTodo("Buy milk") │
│ 2. validate invariants │
│ 3. save (одна транзакция) │
│ 4. emit TodoAdded event ──────────▶│
│ │ 5. handle event
│ │ 6. increment todoCount
│ │ 7. save (другая транзакция)
Между шагами 4 и 7 данные временно несогласованны. UserStatistics.todoCount ещё не обновлён, хотя задача уже создана. Это нормально для DDD — система в конечном итоге придёт в согласованное состояние.
Aggregate в контексте Effect-ts
В функциональном подходе с Effect-ts Aggregate — это не класс с мутабельным состоянием, а иммутабельная структура данных, методы которой возвращают новую копию агрегата:
import { Effect, Data, Option } from "effect"
import { Schema } from "effect"
// ─── Value Objects ───────────────────────────────────
class TodoId extends Schema.TaggedClass<TodoId>()("TodoId", {
value: Schema.String.pipe(Schema.brand("TodoId"))
}) {}
class TodoTitle extends Schema.TaggedClass<TodoTitle>()("TodoTitle", {
value: Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(200),
Schema.brand("TodoTitle")
)
}) {}
class TodoListId extends Schema.TaggedClass<TodoListId>()("TodoListId", {
value: Schema.String.pipe(Schema.brand("TodoListId"))
}) {}
// ─── Child Entity ────────────────────────────────────
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: TodoTitle,
completed: Schema.Boolean,
createdAt: Schema.DateFromSelf
}) {}
// ─── Domain Errors ───────────────────────────────────
class TodoListFull extends Schema.TaggedError<TodoListFull>()(
"TodoListFull",
{ maxSize: Schema.Number, currentSize: Schema.Number }
) {}
class DuplicateTitle extends Schema.TaggedError<DuplicateTitle>()(
"DuplicateTitle",
{ title: Schema.String }
) {}
class TodoNotFound extends Schema.TaggedError<TodoNotFound>()(
"TodoNotFound",
{ todoId: Schema.String }
) {}
// ─── Aggregate Root ──────────────────────────────────
class TodoList extends Schema.Class<TodoList>("TodoList")({
id: TodoListId,
name: Schema.String,
todos: Schema.Array(Todo),
maxSize: Schema.Number.pipe(Schema.int()),
version: Schema.Number.pipe(Schema.int())
}) {
// ─── Поведение: все методы возвращают Effect ──────
addTodo(todo: Todo): Effect.Effect<TodoList, TodoListFull | DuplicateTitle> {
return Effect.gen(this, function* () {
// Инвариант 1: проверка лимита
if (this.todos.length >= this.maxSize) {
return yield* new TodoListFull({
maxSize: this.maxSize,
currentSize: this.todos.length
})
}
// Инвариант 2: уникальность заголовков
const titleExists = this.todos.some(
(t) => t.title.value === todo.title.value
)
if (titleExists) {
return yield* new DuplicateTitle({ title: todo.title.value })
}
// Создаём НОВЫЙ агрегат (иммутабельность!)
return new TodoList({
...this,
todos: [...this.todos, todo],
version: this.version + 1
})
})
}
completeTodo(todoId: TodoId): Effect.Effect<TodoList, TodoNotFound> {
return Effect.gen(this, function* () {
const index = this.todos.findIndex(
(t) => t.id.value === todoId.value
)
if (index === -1) {
return yield* new TodoNotFound({ todoId: todoId.value })
}
const updatedTodos = this.todos.map((t, i) =>
i === index ? new Todo({ ...t, completed: true }) : t
)
return new TodoList({
...this,
todos: updatedTodos,
version: this.version + 1
})
})
}
get activeTodoCount(): number {
return this.todos.filter((t) => !t.completed).length
}
get completedTodoCount(): number {
return this.todos.filter((t) => t.completed).length
}
}
Обратите внимание на ключевые принципы:
- Иммутабельность —
addTodoиcompleteTodoне мутируютthis, а возвращают новыйTodoList - Инварианты в Effect — нарушение инварианта возвращает типизированную ошибку через
Effect.fail, а не бросает исключение - Версионирование —
versionувеличивается при каждом изменении (для оптимистичной блокировки) - Вычисляемые свойства —
activeTodoCount— чистая функция от состояния
Aggregate vs Entity: в чём разница?
Начинающие часто путают Aggregate и Entity. Разберём различия:
| Характеристика | Entity | Aggregate |
|---|---|---|
| Идентичность | Имеет уникальный Id | Имеет уникальный Id (через Root) |
| Содержимое | Только свои поля и VO | Entity + Child Entities + Value Objects |
| Инварианты | Только свои | Все инварианты кластера |
| Граница транзакции | Не определяет | Определяет |
| Загрузка | Может загружаться частично | Всегда загружается целиком |
| Ссылки извне | Может ссылаться на другие Entity | Только через Id корня |
Entity — это один объект с идентичностью.
Aggregate — это один или несколько объектов, объединённых общими инвариантами и границей транзакции.
Иногда Aggregate состоит из одной Entity — и это нормально. Todo может быть агрегатом из одного объекта, если у него нет дочерних сущностей и все инварианты локальны.
Пять правил Вона Вернона для Aggregates
Вон Вернон в книге «Implementing Domain-Driven Design» сформулировал пять правил проектирования агрегатов:
Правило 1: Защищайте инварианты внутри границ агрегата
Все бизнес-правила, которые должны быть согласованны, помещаются внутрь одного агрегата. Если правило «в заказе не может быть более 100 позиций» затрагивает Order и OrderItem, они должны быть в одном агрегате.
Правило 2: Проектируйте маленькие агрегаты
Чем меньше агрегат, тем лучше:
- Меньше конфликтов при конкурентном доступе
- Быстрее загрузка и сохранение
- Проще тестирование
- Меньше данных блокируется
Правило 3: Ссылайтесь на другие агрегаты только по идентификатору
Агрегат никогда не содержит ссылку на объект другого агрегата. Только на его Id:
// ❌ НЕПРАВИЛЬНО: прямая ссылка на объект другого агрегата
class Order extends Schema.Class<Order>("Order")({
customer: Customer // Customer — другой агрегат!
}) {}
// ✅ ПРАВИЛЬНО: ссылка по идентификатору
class Order extends Schema.Class<Order>("Order")({
customerId: CustomerId // Только Id!
}) {}
Правило 4: Используйте eventual consistency за пределами агрегата
Согласованность между агрегатами — в конечном итоге, через Domain Events.
Правило 5: Обновляйте один агрегат в одной транзакции
Одна бизнес-операция → одна транзакция → один агрегат.
Жизненный цикл Aggregate
Aggregate проходит через определённые стадии:
┌─────────┐ create() ┌──────────┐
│ Не │ ──────────────▶ │ Живой │
│ создан │ │ (Active) │
└─────────┘ └────┬─────┘
│
┌─────────────┼─────────────┐
│ │ │
update() complete() archive()
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Живой │ │Завершён │ │Архивирован│
│(Updated) │ │(Done) │ │(Archived) │
└──────────┘ └──────────┘ └──────────┘
В Effect-ts жизненный цикл выражается через фабричные методы (для создания) и методы экземпляра (для переходов):
// Фабрика: создание нового агрегата
const createTodoList = (
name: string,
maxSize: number = 50
): Effect.Effect<TodoList, InvalidName> =>
Effect.gen(function* () {
if (name.trim().length === 0) {
return yield* new InvalidName({ name })
}
return new TodoList({
id: new TodoListId({ value: generateId() }),
name: name.trim(),
todos: [],
maxSize,
version: 0
})
})
// Переход: архивация (метод агрегата)
// TodoList.archive(): Effect<ArchivedTodoList, CannotArchiveActive>
Aggregate и Repository
Aggregate является единицей работы для Repository. Repository оперирует целыми агрегатами, а не отдельными Entity:
// ❌ НЕПРАВИЛЬНО: Repository для дочерней Entity
interface TodoItemRepository {
findById(id: TodoItemId): Effect.Effect<TodoItem, TodoItemNotFound>
save(item: TodoItem): Effect.Effect<void>
}
// ✅ ПРАВИЛЬНО: Repository для Aggregate Root
interface TodoListRepository {
findById(id: TodoListId): Effect.Effect<TodoList, TodoListNotFound>
save(list: TodoList): Effect.Effect<void>
}
Repository загружает агрегат целиком (со всеми дочерними Entity и Value Objects), и сохраняет его целиком. Это гарантирует, что инварианты агрегата всегда проверяются при каждой операции.
Когда нужен Aggregate, а когда достаточно Entity?
Используйте Aggregate, когда:
- Есть бизнес-правила, затрагивающие несколько связанных объектов
- Нужна транзакционная согласованность группы объектов
- Объекты не имеют смысла друг без друга (TodoList ↔ Todo)
- Требуется конкурентный контроль над группой объектов
Достаточно одиночной Entity, когда:
- Объект полностью самодостаточен
- Все инварианты локальны (затрагивают только свои поля)
- Нет дочерних Entity (только Value Objects)
- Объект — сам себе агрегат (агрегат из одного элемента)
Типичные ошибки при проектировании Aggregates
Ошибка 1: «Бог-агрегат»
Агрегат, содержащий слишком много сущностей. Например, User содержит Orders[], Payments[], Addresses[], Notifications[]. В итоге загрузка одного пользователя тянет тысячи объектов.
Решение: разбить на мелкие агрегаты — UserProfile, OrderHistory, NotificationPreferences — и связать их по Id.
Ошибка 2: Анемичный агрегат
Агрегат без поведения — просто контейнер данных. Вся логика живёт в «сервисах»:
// ❌ Анемичный агрегат
class TodoList extends Schema.Class<TodoList>("TodoList")({
id: TodoListId,
todos: Schema.Array(Todo)
}) {}
// Логика «сбоку»
const addTodoToList = (list: TodoList, todo: Todo): TodoList =>
new TodoList({ ...list, todos: [...list.todos, todo] })
// Где инварианты?! Кто проверит лимит? Уникальность?
Решение: поведение и инварианты — внутри агрегата.
Ошибка 3: Агрегат ссылается на другие агрегаты по объектной ссылке
// ❌ Прямая ссылка
class Order extends Schema.Class<Order>("Order")({
customer: Customer // Объект!
}) {}
// ✅ Ссылка по Id
class Order extends Schema.Class<Order>("Order")({
customerId: CustomerId // Только идентификатор!
}) {}
Резюме
Aggregate — это ключевой строительный блок DDD, определяющий:
- Что рассматривается как единое целое (кластер объектов)
- Как гарантируется согласованность (транзакционная граница)
- Кто является точкой входа (Aggregate Root)
- Где проверяются бизнес-правила (инварианты внутри границы)
В Effect-ts Aggregate реализуется как иммутабельная структура (Schema.Class), методы которой возвращают Effect — либо новый Aggregate (успех), либо типизированную ошибку (нарушение инварианта). Это даёт compile-time гарантии и делает невозможным «забыть» обработать ошибку бизнес-правила.
В следующей статье мы углубимся в Aggregate Root — единственную точку входа в агрегат — и разберём, почему это критически важно для целостности системы.