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

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?

А если два пользователя одновременно редактируют один заказ — один меняет адрес доставки, другой добавляет товар — как разрешить конфликт? Какие данные нужно блокировать? Весь заказ? Только строку товара?

Без чётких границ вы получаете:

  1. Проблему согласованности — данные сохраняются частично, система оказывается в невалидном состоянии
  2. Проблему конкурентного доступа — невозможно определить, что блокировать
  3. Проблему производительности — приходится загружать огромные графы объектов ради изменения одного поля
  4. Проблему тестирования — невозможно протестировать логику одного объекта без создания всего окружения

Именно для решения этих проблем Эрик Эванс в книге «Domain-Driven Design: Tackling Complexity in the Heart of Software» (2003) ввёл концепцию Aggregate.


Что такое Aggregate

Aggregate (Агрегат) — это кластер доменных объектов (Entity и Value Object), которые рассматриваются как единое целое с точки зрения изменений данных.

Ключевое слово здесь — «единое целое с точки зрения изменений». Aggregate определяет:

  1. Границу согласованности (consistency boundary) — все инварианты внутри агрегата гарантированно соблюдаются после каждой операции
  2. Границу транзакции (transactional boundary) — все изменения внутри агрегата атомарны: либо все применяются, либо ни одно
  3. Границу загрузки (loading boundary) — агрегат загружается и сохраняется как целая единица
  4. Границу конкурентного доступа (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)

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

  1. Масштабируемость — если транзакция захватывает несколько агрегатов, они не могут храниться на разных серверах
  2. Конкурентный доступ — чем больше объектов в транзакции, тем выше вероятность конфликта
  3. Простота — один агрегат = одна блокировка = предсказуемое поведение
  4. Производительность — маленькие транзакции быстрее больших

Согласованность между агрегатами

Если бизнес-правило затрагивает несколько агрегатов, используется 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
  }
}

Обратите внимание на ключевые принципы:

  1. ИммутабельностьaddTodo и completeTodo не мутируют this, а возвращают новый TodoList
  2. Инварианты в Effect — нарушение инварианта возвращает типизированную ошибку через Effect.fail, а не бросает исключение
  3. Версионированиеversion увеличивается при каждом изменении (для оптимистичной блокировки)
  4. Вычисляемые свойстваactiveTodoCount — чистая функция от состояния

Aggregate vs Entity: в чём разница?

Начинающие часто путают Aggregate и Entity. Разберём различия:

ХарактеристикаEntityAggregate
ИдентичностьИмеет уникальный IdИмеет уникальный Id (через Root)
СодержимоеТолько свои поля и VOEntity + 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, определяющий:

  1. Что рассматривается как единое целое (кластер объектов)
  2. Как гарантируется согласованность (транзакционная граница)
  3. Кто является точкой входа (Aggregate Root)
  4. Где проверяются бизнес-правила (инварианты внутри границы)

В Effect-ts Aggregate реализуется как иммутабельная структура (Schema.Class), методы которой возвращают Effect — либо новый Aggregate (успех), либо типизированную ошибку (нарушение инварианта). Это даёт compile-time гарантии и делает невозможным «забыть» обработать ошибку бизнес-правила.

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