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

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 — это:

  1. Entity (имеет уникальный идентификатор)
  2. Единственная точка входа в агрегат для внешнего мира
  3. Гарант инвариантов — каждый публичный метод проверяет бизнес-правила
  4. Единица идентификации — внешние системы ссылаются на агрегат через 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 напрямую. Для доступа к конкретной задаче нужен путь: TodoListIdTodoItemId.

Правило 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
  }
) {}

Каждый мутирующий метод увеличивает версию. Это обеспечивает:

  1. Обнаружение конфликтов — два пользователя одновременно меняют один агрегат, второй получит ошибку
  2. Идемпотентность — повторная отправка команды с той же версией безопасна
  3. Аудит — можно отслеживать количество изменений

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 — это:

  1. Единственная точка входа — весь внешний мир работает только с корнем
  2. Гарант инвариантов — каждый публичный метод проверяет бизнес-правила
  3. Глобально идентифицируемый — имеет уникальный Id, по которому ссылаются другие агрегаты
  4. Единица работы Repository — загружается и сохраняется целиком
  5. Единица кеширования — кеш привязан к Id корня
  6. Единица версионированияversion для оптимистичной блокировки

В Effect-ts Aggregate Root реализуется как Schema.Class с методами, возвращающими Effect<NewState, DomainError>. Guard-методы выделяются для переиспользуемых проверок. Фабричный метод create обеспечивает корректную инициализацию.