Aggregate Root: единственная точка входа
Роль Aggregate Root как привратника агрегата, глобальная vs локальная идентичность, четыре правила (все операции через корень, внешние ссылки только на корень, Repository работает с корнем). Паттерны Guard Methods, Factory Method, With-pattern. Версионирование и оптимистичная блокировка.
Зачем нужен Aggregate Root
В предыдущей статье мы определили Aggregate как кластер объектов с единой границей. Но кластер — это набор объектов. Как внешний мир взаимодействует с этим набором? Через каждый объект по отдельности? Нет — это разрушило бы инварианты.
Представьте агрегат TodoList с задачами. Если кто-то мог бы напрямую добавить Todo в массив, обойдя проверку лимита — инвариант «не более 50 задач» был бы нарушен. Если кто-то мог бы напрямую изменить Todo.completed, обойдя проверку бизнес-правил — инвариант «нельзя завершить архивированную задачу» был бы нарушен.
Aggregate Root решает эту проблему: он является единственной Entity внутри агрегата, к которой разрешён доступ извне. Все операции над дочерними объектами проходят только через корень.
Определение Aggregate Root
Aggregate Root — это:
- Entity (имеет уникальный идентификатор)
- Единственная точка входа в агрегат для внешнего мира
- Гарант инвариантов — каждый публичный метод проверяет бизнес-правила
- Единица идентификации — внешние системы ссылаются на агрегат через Id корня
ВНЕШНИЙ МИР
│
│ Единственная точка входа
▼
┌────────────────────────────────────────┐
│ AGGREGATE ROOT │
│ ┌─────────────────────┐ │
│ │ TodoList │ │
│ │ ───────────── │ │
│ │ id: TodoListId │ │
│ │ name: string │ │
│ │ version: number │ │
│ │ │ │
│ │ addTodo() ────┤──── ✅ Проверяет лимит
│ │ completeTodo()────┤──── ✅ Проверяет статус
│ │ removeTodo() ────┤──── ✅ Проверяет наличие
│ │ renameTodo() ────┤──── ✅ Проверяет уникальность
│ └─────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ Todo #1 │ │ Todo #2 │ │ Todo#3 │ │
│ │ ✖ прямой│ │ ✖ прямой│ │ ✖ прямой│ │
│ │ доступ │ │ доступ │ │ доступ │ │
│ └──────────┘ └──────────┘ └────────┘ │
└────────────────────────────────────────┘
Правила Aggregate Root
Правило 1: Глобальная идентичность
Aggregate Root имеет глобально уникальный идентификатор. Дочерние Entity имеют локально уникальный идентификатор (уникальный только внутри агрегата).
import { Schema } from "effect"
// ─── Глобальная идентичность корня ───────────
class TodoListId extends Schema.TaggedClass<TodoListId>()("TodoListId", {
value: Schema.String.pipe(Schema.brand("TodoListId"))
}) {}
// ─── Локальная идентичность дочерней Entity ──
class TodoItemId extends Schema.TaggedClass<TodoItemId>()("TodoItemId", {
value: Schema.String.pipe(Schema.brand("TodoItemId"))
}) {}
// TodoItemId уникален ТОЛЬКО внутри конкретного TodoList.
// Два разных TodoList могут содержать TodoItem с одинаковым Id —
// это не конфликт, потому что контекст разный.
Внешние системы никогда не ссылаются на TodoItemId напрямую. Для доступа к конкретной задаче нужен путь: TodoListId → TodoItemId.
Правило 2: Все операции через корень
Дочерние Entity не имеют публичных мутирующих методов, доступных извне. Все изменения идут через Aggregate Root:
import { Effect } from "effect"
// ❌ НЕПРАВИЛЬНО: прямое изменение дочерней Entity
const todo = todoList.todos[0]
const updatedTodo = new Todo({ ...todo, completed: true })
// Кто проверит инварианты? Никто!
// ✅ ПРАВИЛЬНО: изменение через Aggregate Root
const updatedList = yield* todoList.completeTodo(todoId)
// TodoList.completeTodo проверяет:
// 1. Существует ли задача
// 2. Не завершена ли она уже
// 3. Не архивирован ли список
// 4. Обновляет версию
Правило 3: Внешние ссылки только на корень
Другие агрегаты и сервисы могут ссылаться только на Aggregate Root по его Id:
// ❌ НЕПРАВИЛЬНО: ссылка на дочернюю Entity
class Comment extends Schema.Class<Comment>("Comment")({
todoItem: TodoItem // Прямая ссылка на дочерний объект!
}) {}
// ❌ НЕПРАВИЛЬНО: ссылка на Id дочерней Entity
class Comment extends Schema.Class<Comment>("Comment")({
todoItemId: TodoItemId // Вроде Id, но дочерней Entity!
}) {}
// ✅ ПРАВИЛЬНО: ссылка на корень
class Comment extends Schema.Class<Comment>("Comment")({
todoListId: TodoListId, // Ссылка на агрегат
todoItemId: TodoItemId // Локальный Id для навигации внутри
}) {}
Правило 4: Repository работает с корнем
Repository оперирует Aggregate Root, а не дочерними Entity:
import { Context, Effect } from "effect"
// ❌ НЕПРАВИЛЬНО: Repository для дочерней Entity
class TodoItemRepository extends Context.Tag("TodoItemRepository")<
TodoItemRepository,
{
readonly findById: (id: TodoItemId) => Effect.Effect<TodoItem, TodoItemNotFound>
readonly save: (item: TodoItem) => Effect.Effect<void>
}
>() {}
// ✅ ПРАВИЛЬНО: Repository для Aggregate Root
class TodoListRepository extends Context.Tag("TodoListRepository")<
TodoListRepository,
{
readonly findById: (id: TodoListId) => Effect.Effect<TodoList, TodoListNotFound>
readonly save: (list: TodoList) => Effect.Effect<void>
readonly delete: (id: TodoListId) => Effect.Effect<void, TodoListNotFound>
}
>() {}
Реализация Aggregate Root в Effect-ts
Рассмотрим полноценную реализацию Aggregate Root для TodoList:
import { Effect, Schema, Option, pipe, ReadonlyArray } from "effect"
// ═══════════════════════════════════════════════
// Value Objects
// ═══════════════════════════════════════════════
class TodoListId extends Schema.TaggedClass<TodoListId>()("TodoListId", {
value: Schema.String.pipe(Schema.brand("TodoListId"))
}) {}
class TodoItemId extends Schema.TaggedClass<TodoItemId>()("TodoItemId", {
value: Schema.String.pipe(Schema.brand("TodoItemId"))
}) {}
class TodoTitle extends Schema.TaggedClass<TodoTitle>()("TodoTitle", {
value: Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(200),
Schema.brand("TodoTitle")
)
}) {}
class ListName extends Schema.TaggedClass<ListName>()("ListName", {
value: Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(100),
Schema.brand("ListName")
)
}) {}
// ═══════════════════════════════════════════════
// 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 TodoNotInList extends Schema.TaggedError<TodoNotInList>()(
"TodoNotInList",
{ todoItemId: Schema.String, todoListId: Schema.String }
) {}
class TodoAlreadyCompleted extends Schema.TaggedError<TodoAlreadyCompleted>()(
"TodoAlreadyCompleted",
{ todoItemId: Schema.String }
) {}
class ListArchived extends Schema.TaggedError<ListArchived>()(
"ListArchived",
{ todoListId: Schema.String }
) {}
// ═══════════════════════════════════════════════
// Status (Union type)
// ═══════════════════════════════════════════════
const TodoStatus = Schema.Literal("active", "completed")
type TodoStatus = typeof TodoStatus.Type
const ListStatus = Schema.Literal("active", "archived")
type ListStatus = typeof ListStatus.Type
// ═══════════════════════════════════════════════
// Child Entity: TodoItem (локальная идентичность)
// ═══════════════════════════════════════════════
class TodoItem extends Schema.Class<TodoItem>("TodoItem")({
id: TodoItemId,
title: TodoTitle,
status: TodoStatus,
createdAt: Schema.DateFromSelf,
completedAt: Schema.OptionFromSelf(Schema.DateFromSelf)
}) {}
// ═══════════════════════════════════════════════
// Aggregate Root: TodoList
// ═══════════════════════════════════════════════
class TodoList extends Schema.Class<TodoList>("TodoList")({
id: TodoListId,
name: ListName,
status: ListStatus,
items: Schema.Array(TodoItem),
maxItems: Schema.Number.pipe(Schema.int(), Schema.positive()),
createdAt: Schema.DateFromSelf,
version: Schema.Number.pipe(Schema.int(), Schema.nonNegative())
}) {
// ─── Гвардия: проверка общих предусловий ───
private ensureActive(): Effect.Effect<void, ListArchived> {
return this.status === "archived"
? new ListArchived({ todoListId: this.id.value })
: Effect.void
}
private findItem(
itemId: TodoItemId
): Effect.Effect<{ readonly item: TodoItem; readonly index: number }, TodoNotInList> {
const index = this.items.findIndex(
(item) => item.id.value === itemId.value
)
return index === -1
? new TodoNotInList({
todoItemId: itemId.value,
todoListId: this.id.value
})
: Effect.succeed({ item: this.items[index]!, index } as const)
}
// ─── Команды (мутации через новые копии) ───
addItem(
itemId: TodoItemId,
title: TodoTitle,
now: Date
): Effect.Effect<TodoList, TodoListFull | DuplicateTitle | ListArchived> {
return Effect.gen(this, function* () {
// Предусловие: список не архивирован
yield* this.ensureActive()
// Инвариант 1: лимит элементов
if (this.items.length >= this.maxItems) {
return yield* new TodoListFull({
maxSize: this.maxItems,
currentSize: this.items.length
})
}
// Инвариант 2: уникальность заголовков
const hasDuplicate = this.items.some(
(item) => item.title.value === title.value
)
if (hasDuplicate) {
return yield* new DuplicateTitle({ title: title.value })
}
// Создаём дочернюю Entity
const newItem = new TodoItem({
id: itemId,
title,
status: "active",
createdAt: now,
completedAt: Option.none()
})
// Возвращаем НОВЫЙ агрегат
return new TodoList({
...this,
items: [...this.items, newItem],
version: this.version + 1
})
})
}
completeItem(
itemId: TodoItemId,
now: Date
): Effect.Effect<TodoList, TodoNotInList | TodoAlreadyCompleted | ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
const { item, index } = yield* this.findItem(itemId)
if (item.status === "completed") {
return yield* new TodoAlreadyCompleted({
todoItemId: itemId.value
})
}
const completedItem = new TodoItem({
...item,
status: "completed",
completedAt: Option.some(now)
})
const updatedItems = ReadonlyArray.modify(this.items, index, () => completedItem)
return new TodoList({
...this,
items: updatedItems,
version: this.version + 1
})
})
}
removeItem(
itemId: TodoItemId
): Effect.Effect<TodoList, TodoNotInList | ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
yield* this.findItem(itemId) // проверяем существование
return new TodoList({
...this,
items: this.items.filter((item) => item.id.value !== itemId.value),
version: this.version + 1
})
})
}
archive(): Effect.Effect<TodoList, ListArchived> {
return Effect.gen(this, function* () {
if (this.status === "archived") {
return yield* new ListArchived({ todoListId: this.id.value })
}
return new TodoList({
...this,
status: "archived",
version: this.version + 1
})
})
}
// ─── Запросы (чистые функции, без Effect) ───
get activeItemCount(): number {
return this.items.filter((item) => item.status === "active").length
}
get completedItemCount(): number {
return this.items.filter((item) => item.status === "completed").length
}
get isEmpty(): boolean {
return this.items.length === 0
}
get isFull(): boolean {
return this.items.length >= this.maxItems
}
get completionPercentage(): number {
return this.items.length === 0
? 0
: Math.round((this.completedItemCount / this.items.length) * 100)
}
findItemByTitle(title: TodoTitle): Option.Option<TodoItem> {
return Option.fromNullable(
this.items.find((item) => item.title.value === title.value)
)
}
}
Паттерны реализации Aggregate Root
Паттерн 1: Guard Methods (Методы-гвардии)
Выделяйте общие проверки предусловий в приватные методы:
class TodoList extends Schema.Class<TodoList>("TodoList")({
/* ... */
}) {
// Гвардия: повторяется во многих методах
private ensureActive(): Effect.Effect<void, ListArchived> {
return this.status === "archived"
? new ListArchived({ todoListId: this.id.value })
: Effect.void
}
private ensureNotFull(): Effect.Effect<void, TodoListFull> {
return this.items.length >= this.maxItems
? new TodoListFull({
maxSize: this.maxItems,
currentSize: this.items.length
})
: Effect.void
}
addItem(/* ... */) {
return Effect.gen(this, function* () {
yield* this.ensureActive() // Переиспользуемая проверка
yield* this.ensureNotFull() // Переиспользуемая проверка
// ... остальная логика
})
}
}
Паттерн 2: Фабричный метод (Factory Method)
Создание агрегата — тоже операция с инвариантами. Используйте статический фабричный метод:
class TodoList extends Schema.Class<TodoList>("TodoList")({
/* ... */
}) {
static create(params: {
readonly id: TodoListId
readonly name: ListName
readonly maxItems?: number
readonly now: Date
}): Effect.Effect<TodoList, never> {
return Effect.succeed(
new TodoList({
id: params.id,
name: params.name,
status: "active",
items: [],
maxItems: params.maxItems ?? 50,
createdAt: params.now,
version: 0
})
)
}
}
// Использование:
const list = yield* TodoList.create({
id: new TodoListId({ value: "list-1" }),
name: new ListName({ value: "Shopping" }),
now: new Date()
})
Паттерн 3: With-pattern (Обновление через копию)
Для простых обновлений Value Object можно использовать with-паттерн:
class TodoList extends Schema.Class<TodoList>("TodoList")({
/* ... */
}) {
rename(newName: ListName): Effect.Effect<TodoList, ListArchived> {
return Effect.gen(this, function* () {
yield* this.ensureActive()
return new TodoList({
...this,
name: newName,
version: this.version + 1
})
})
}
}
Aggregate Root и версионирование
Поле version в Aggregate Root — это не случайность, а необходимость. Оно используется для оптимистичной блокировки:
// Repository при сохранении проверяет версию:
//
// UPDATE todo_lists
// SET ..., version = :newVersion
// WHERE id = :id AND version = :expectedVersion
//
// Если WHERE не нашёл строку → кто-то другой изменил
// агрегат между загрузкой и сохранением → ConflictError
class OptimisticLockError extends Schema.TaggedError<OptimisticLockError>()(
"OptimisticLockError",
{
aggregateId: Schema.String,
expectedVersion: Schema.Number,
actualVersion: Schema.Number
}
) {}
Каждый мутирующий метод увеличивает версию. Это обеспечивает:
- Обнаружение конфликтов — два пользователя одновременно меняют один агрегат, второй получит ошибку
- Идемпотентность — повторная отправка команды с той же версией безопасна
- Аудит — можно отслеживать количество изменений
Aggregate Root как единица кеширования
Поскольку внешний мир ссылается на агрегат через Id корня, кеш тоже работает по Id корня:
import { Context, Effect } from "effect"
class TodoListCache extends Context.Tag("TodoListCache")<
TodoListCache,
{
readonly get: (id: TodoListId) => Effect.Effect<Option.Option<TodoList>>
readonly set: (list: TodoList) => Effect.Effect<void>
readonly invalidate: (id: TodoListId) => Effect.Effect<void>
}
>() {}
При изменении любой части агрегата (даже одного TodoItem) кеш всего агрегата инвалидируется. Это ещё одна причина проектировать маленькие агрегаты — крупный агрегат инвалидируется чаще.
Антипаттерны Aggregate Root
Антипаттерн 1: «Прозрачный» корень
Aggregate Root, который просто пробрасывает операции без проверки инвариантов:
// ❌ Корень — просто прокси
class TodoList {
getTodos(): ReadonlyArray<TodoItem> {
return this.items // Утечка внутренних объектов!
}
updateTodo(index: number, item: TodoItem): TodoList {
// Никаких проверок! Инвариант обходится!
const items = [...this.items]
items[index] = item
return new TodoList({ ...this, items })
}
}
Проблема: внешний код может изменить TodoItem как угодно, обойдя все инварианты. Корень должен проверять каждую операцию.
Антипаттерн 2: Возвращение мутабельных ссылок
// ❌ Утечка мутабельного массива
class TodoList {
getItems(): TodoItem[] {
return this.items // Массив можно мутировать!
}
}
// Внешний код:
const items = list.getItems()
items.push(new TodoItem({ /* ... */ })) // Обход инвариантов!
В Effect-ts с Schema.Array и ReadonlyArray это менее вероятно, но важно осознавать риск. Всегда возвращайте readonly структуры.
Антипаттерн 3: Множественные корни
// ❌ Два «корня» для одного агрегата
class TodoListRepository { /* ... */ }
class TodoItemRepository { /* ... */ }
// Можно загрузить TodoItem без его TodoList,
// изменить и сохранить, обойдя инварианты TodoList!
Резюме
Aggregate Root — это:
- Единственная точка входа — весь внешний мир работает только с корнем
- Гарант инвариантов — каждый публичный метод проверяет бизнес-правила
- Глобально идентифицируемый — имеет уникальный Id, по которому ссылаются другие агрегаты
- Единица работы Repository — загружается и сохраняется целиком
- Единица кеширования — кеш привязан к Id корня
- Единица версионирования —
versionдля оптимистичной блокировки
В Effect-ts Aggregate Root реализуется как Schema.Class с методами, возвращающими Effect<NewState, DomainError>. Guard-методы выделяются для переиспользуемых проверок. Фабричный метод create обеспечивает корректную инициализацию.