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

Защита инвариантов: правила внутри агрегата

Классификация инвариантов (атрибутные, межатрибутные, коллекционные, состояния, агрегатные). Пять стратегий защиты: Make Illegal States Unrepresentable, Smart Constructors, Validate on Mutation, Guard Methods, Assertions. Каталог инвариантов для Todo, связь инвариантов с Domain Events и тестированием.

Что такое инвариант

Инвариант (invariant) — это условие, которое всегда должно быть истинным для данных внутри агрегата. Слово «всегда» здесь ключевое: инвариант не может быть нарушен ни на мгновение. После каждой операции над агрегатом все его инварианты должны выполняться.

Инварианты — это не просто валидация входных данных. Это бизнес-правила, которые определяют допустимые состояния агрегата:

Примеры инвариантов:

✅ "В списке задач не более 50 элементов"
   → Ограничение на количество дочерних Entity

✅ "Заголовки задач внутри одного списка уникальны"
   → Ограничение на уникальность в коллекции

✅ "Завершённую задачу нельзя вернуть в активное состояние"
   → Ограничение на переходы состояний

✅ "Общая сумма заказа равна сумме всех позиций"
   → Вычисляемый инвариант на согласованность

✅ "Архивированный список не может быть изменён"
   → Ограничение на допустимые операции в состоянии

Классификация инвариантов

1. Инварианты одного атрибута (Attribute Invariants)

Самый простой вид — ограничение на значение одного поля. Обычно реализуется через Value Object:

import { Schema } from "effect"

// Инвариант: заголовок не пустой и не длиннее 200 символов
class TodoTitle extends Schema.TaggedClass<TodoTitle>()("TodoTitle", {
  value: Schema.String.pipe(
    Schema.trimmed(),
    Schema.minLength(1),
    Schema.maxLength(200),
    Schema.brand("TodoTitle")
  )
}) {}

// Инвариант: приоритет — одно из допустимых значений
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type

// Инвариант: максимальный размер — положительное целое
const MaxItems = Schema.Number.pipe(
  Schema.int(),
  Schema.greaterThan(0),
  Schema.lessThanOrEqualTo(1000)
)

Эти инварианты гарантируются типовой системой: невозможно создать TodoTitle с пустой строкой — Schema.decode вернёт ошибку.

2. Инварианты между атрибутами (Cross-Attribute Invariants)

Условие, связывающее два или более атрибута одного объекта:

import { Effect, Schema } from "effect"

// Инвариант: если статус "completed", то completedAt обязателен
// Инвариант: если статус "active", то completedAt отсутствует

class TodoItem extends Schema.Class<TodoItem>("TodoItem")({
  id: TodoItemId,
  title: TodoTitle,
  status: TodoStatus,
  createdAt: Schema.DateFromSelf,
  completedAt: Schema.OptionFromSelf(Schema.DateFromSelf)
}) {
  // Проверка инварианта при переходе в "completed"
  static complete(
    item: TodoItem,
    now: Date
  ): Effect.Effect<TodoItem, TodoAlreadyCompleted> {
    if (item.status === "completed") {
      return new TodoAlreadyCompleted({ todoItemId: item.id.value })
    }
    return Effect.succeed(
      new TodoItem({
        ...item,
        status: "completed",
        completedAt: Option.some(now)  // Гарантируем наличие даты
      })
    )
  }
}

3. Инварианты коллекции (Collection Invariants)

Условие, затрагивающее коллекцию дочерних объектов внутри агрегата:

// Инвариант: не более maxItems задач
// Инвариант: уникальные заголовки внутри списка
// Инвариант: хотя бы одна активная задача (если требуется бизнесом)

class TodoList extends Schema.Class<TodoList>("TodoList")({
  /* ... */
}) {
  addItem(
    itemId: TodoItemId,
    title: TodoTitle,
    now: Date
  ): Effect.Effect<TodoList, TodoListFull | DuplicateTitle | ListArchived> {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()

      // Collection invariant #1: size limit
      if (this.items.length >= this.maxItems) {
        return yield* new TodoListFull({
          maxSize: this.maxItems,
          currentSize: this.items.length
        })
      }

      // Collection invariant #2: unique titles
      const hasDuplicate = this.items.some(
        (item) => item.title.value === title.value
      )
      if (hasDuplicate) {
        return yield* new DuplicateTitle({ title: title.value })
      }

      // Все инварианты проверены — безопасно добавляем
      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
      })
    })
  }
}

4. Инварианты состояния (State Invariants)

Условие, определяющее, какие операции допустимы в зависимости от текущего состояния агрегата:

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
  }

  // Можно архивировать только активный список
  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
      })
    })
  }
}

5. Агрегатные инварианты (Aggregate-Wide Invariants)

Условие, связывающее корень и дочерние Entity:

class Order extends Schema.Class<Order>("Order")({
  id: OrderId,
  items: Schema.Array(OrderItem),
  totalAmount: Money,
  status: OrderStatus,
  version: Schema.Number
}) {
  // Инвариант: totalAmount === сумма всех items
  // Проверяется при каждой мутации

  addItem(
    item: OrderItem
  ): Effect.Effect<Order, OrderLimitExceeded> {
    return Effect.gen(this, function* () {
      const newItems = [...this.items, item]
      
      // Пересчитываем total (агрегатный инвариант)
      const newTotal = newItems.reduce(
        (sum, i) => Money.add(sum, i.subtotal),
        Money.zero("USD")
      )

      if (Money.greaterThan(newTotal, Money.of(10000, "USD"))) {
        return yield* new OrderLimitExceeded({
          limit: 10000,
          attempted: newTotal.amount
        })
      }

      return new Order({
        ...this,
        items: newItems,
        totalAmount: newTotal,  // Инвариант поддерживается
        version: this.version + 1
      })
    })
  }
}

Стратегии защиты инвариантов

Стратегия 1: Make Illegal States Unrepresentable (Предотвращение через типы)

Лучший инвариант — тот, который невозможно нарушить на уровне типов:

import { Schema, Data } from "effect"

// ❌ Плохо: статус и дата завершения — независимые поля
// Можно создать состояние {status: "active", completedAt: some(date)} — невалидно!
class BadTodo extends Schema.Class<BadTodo>("BadTodo")({
  status: Schema.Literal("active", "completed"),
  completedAt: Schema.OptionFromSelf(Schema.DateFromSelf)
}) {}

// ✅ Хорошо: discriminated union — невалидное состояние невозможно
const ActiveTodo = Schema.Struct({
  _tag: Schema.Literal("ActiveTodo"),
  id: TodoItemId,
  title: TodoTitle,
  createdAt: Schema.DateFromSelf
})

const CompletedTodo = Schema.Struct({
  _tag: Schema.Literal("CompletedTodo"),
  id: TodoItemId,
  title: TodoTitle,
  createdAt: Schema.DateFromSelf,
  completedAt: Schema.DateFromSelf  // Обязательное!
})

const TodoItem = Schema.Union(ActiveTodo, CompletedTodo)
type TodoItem = typeof TodoItem.Type

С discriminated union невозможно иметь CompletedTodo без completedAt — TypeScript не позволит скомпилировать такой код.

Стратегия 2: Validate on Mutation (Проверка при каждой мутации)

Каждый метод агрегата, изменяющий состояние, проверяет все затронутые инварианты:

class TodoList extends Schema.Class<TodoList>("TodoList")({
  /* ... */
}) {
  addItem(/* ... */): Effect.Effect<TodoList, /* errors */> {
    return Effect.gen(this, function* () {
      // 1. Проверяем предусловия (state invariants)
      yield* this.ensureActive()
      
      // 2. Проверяем коллекционные инварианты
      yield* this.ensureNotFull()
      yield* this.ensureUniqueTitle(title)
      
      // 3. Создаём новое состояние
      const newList = new TodoList({ /* ... */ })
      
      // 4. Опционально: постусловие (assertion)
      // assert(newList.items.length <= newList.maxItems)
      
      return newList
    })
  }
}

Стратегия 3: Assertion Functions (Утверждения для отладки)

Дополнительно к Effect-ошибкам можно добавить assertion-функции, которые ловят программистские ошибки (defects):

class TodoList extends Schema.Class<TodoList>("TodoList")({
  /* ... */
}) {
  // Вызывается в тестах для проверки инвариантов
  assertInvariants(): void {
    // Инвариант 1: размер коллекции
    if (this.items.length > this.maxItems) {
      throw new Error(
        `Invariant violation: items count ${this.items.length} exceeds max ${this.maxItems}`
      )
    }

    // Инвариант 2: уникальность заголовков
    const titles = this.items.map((item) => item.title.value)
    const uniqueTitles = new Set(titles)
    if (titles.length !== uniqueTitles.size) {
      throw new Error(
        `Invariant violation: duplicate titles detected`
      )
    }

    // Инвариант 3: версия неотрицательна
    if (this.version < 0) {
      throw new Error(
        `Invariant violation: version is negative: ${this.version}`
      )
    }

    // Инвариант 4: архивированный список не может быть изменён
    // (этот инвариант проверяется в runtime через guard methods)
  }
}

Стратегия 4: Smart Constructors (Умные конструкторы)

Все точки входа в агрегат проходят через «умные конструкторы», которые гарантируют валидность:

import { Effect } from "effect"

// Фабрика для TodoList — единственный способ создать агрегат
const createTodoList = (params: {
  readonly id: TodoListId
  readonly name: ListName
  readonly maxItems?: number
  readonly now: Date
}): Effect.Effect<TodoList> =>
  Effect.succeed(
    new TodoList({
      id: params.id,
      name: params.name,
      status: "active",
      items: [],                     // Пустой список — инвариант #1 выполнен
      maxItems: params.maxItems ?? 50,
      createdAt: params.now,
      version: 0
    })
  )

// Восстановление из БД — тоже через «умный конструктор»
const reconstituteTodoList = (
  raw: TodoListRecord
): Effect.Effect<TodoList, InvalidAggregateState> =>
  Effect.gen(function* () {
    const list = new TodoList({
      id: new TodoListId({ value: raw.id }),
      name: new ListName({ value: raw.name }),
      status: raw.status,
      items: raw.items.map(toTodoItem),
      maxItems: raw.maxItems,
      createdAt: raw.createdAt,
      version: raw.version
    })

    // Проверяем инварианты при восстановлении
    // (данные в БД могли быть повреждены)
    if (list.items.length > list.maxItems) {
      return yield* new InvalidAggregateState({
        aggregateId: raw.id,
        reason: "items count exceeds max"
      })
    }

    return list
  })

Каталог типичных инвариантов

Для нашего Todo-приложения определим полный каталог:

Инварианты TodoList (Aggregate Root)

#ИнвариантТипСтратегия
1items.length <= maxItemsCollectionValidate on Mutation
2Все items[].title уникальныCollectionValidate on Mutation
3Архивированный список нельзя менятьStateGuard Method
4version >= 0AttributeSmart Constructor
5maxItems > 0 && maxItems <= 1000AttributeSchema refinement
6name не пустоеAttributeValue Object

Инварианты TodoItem (Child Entity)

#ИнвариантТипСтратегия
7Completed → имеет completedAtCross-AttributeDiscriminated Union
8Active → не имеет completedAtCross-AttributeDiscriminated Union
9createdAt <= completedAtCross-AttributeValidate on Mutation
10title не пустой, <= 200 символовAttributeValue Object

Инварианты и Effect.gen: полный пример

Соберём все инварианты в один метод, демонстрируя полный подход:

import { Effect, Option, ReadonlyArray, pipe } from "effect"

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())
}) {

  /**
   * Переименование задачи внутри списка.
   * 
   * Затронутые инварианты:
   * - #3: Архивированный список нельзя менять (State)
   * - #2: Уникальность заголовков (Collection)
   * - #10: Заголовок не пустой (Attribute, через Value Object)
   */
  renameItem(
    itemId: TodoItemId,
    newTitle: TodoTitle
  ): Effect.Effect<
    TodoList,
    TodoNotInList | DuplicateTitle | ListArchived
  > {
    return Effect.gen(this, function* () {
      // ── Инвариант #3: State Guard ──
      yield* this.ensureActive()

      // ── Найти элемент ──
      const { item, index } = yield* this.findItem(itemId)

      // ── Инвариант #2: уникальность заголовков ──
      // (исключаем текущий элемент из проверки)
      const hasDuplicate = this.items.some(
        (existing, i) =>
          i !== index && existing.title.value === newTitle.value
      )
      if (hasDuplicate) {
        return yield* new DuplicateTitle({ title: newTitle.value })
      }

      // ── Инвариант #10: гарантируется типом TodoTitle ──
      // (Schema.decode уже проверил при создании newTitle)

      // ── Создаём новое состояние ──
      const renamedItem = new TodoItem({ ...item, title: newTitle })
      const updatedItems = ReadonlyArray.modify(
        this.items,
        index,
        () => renamedItem
      )

      return new TodoList({
        ...this,
        items: updatedItems,
        version: this.version + 1
      })
    })
  }
}

Инварианты и тестирование

Инварианты — это идеальные кандидаты для тестирования, потому что они чётко формулируемы:

import { describe, it, expect } from "bun:test"
import { Effect, Option } from "effect"

describe("TodoList invariants", () => {
  // Инвариант #1: лимит элементов
  it("should reject adding item when list is full", async () => {
    const list = yield* createTodoListWithItems(50, { maxItems: 50 })
    
    const result = yield* Effect.either(
      list.addItem(newItemId(), newTitle("Extra"), new Date())
    )
    
    expect(result._tag).toBe("Left")
    expect(result.left._tag).toBe("TodoListFull")
  })

  // Инвариант #2: уникальность заголовков
  it("should reject duplicate title", async () => {
    const list = yield* createTodoListWithOneItem("Buy milk")
    
    const result = yield* Effect.either(
      list.addItem(newItemId(), newTitle("Buy milk"), new Date())
    )
    
    expect(result._tag).toBe("Left")
    expect(result.left._tag).toBe("DuplicateTitle")
  })

  // Инвариант #3: состояние
  it("should reject mutations on archived list", async () => {
    const list = yield* createArchivedTodoList()
    
    const result = yield* Effect.either(
      list.addItem(newItemId(), newTitle("New"), new Date())
    )
    
    expect(result._tag).toBe("Left")
    expect(result.left._tag).toBe("ListArchived")
  })

  // Property-based: инварианты после любой последовательности операций
  it("should maintain all invariants after any sequence of operations",
    async () => {
      // Для каждого сгенерированного сценария:
      // 1. Создать агрегат
      // 2. Применить случайную последовательность операций
      // 3. Проверить, что все инварианты выполнены
      // (Подробнее в модуле о Property-Based Testing)
    }
  )
})

Инварианты и Domain Events

Когда инвариант проверен и состояние изменилось, агрегат может эмитировать Domain Event. События — это «побочный продукт» успешной проверки инвариантов:

import { Effect } from "effect"

// Тип результата мутации: новое состояние + события
interface AggregateResult<A, E> {
  readonly aggregate: A
  readonly events: ReadonlyArray<DomainEvent>
}

class TodoList extends Schema.Class<TodoList>("TodoList")({
  /* ... */
}) {
  addItem(
    itemId: TodoItemId,
    title: TodoTitle,
    now: Date
  ): Effect.Effect<
    AggregateResult<TodoList, DomainEvent>,
    TodoListFull | DuplicateTitle | ListArchived
  > {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()
      yield* this.ensureNotFull()
      yield* this.ensureUniqueTitle(title)

      const newItem = new TodoItem({
        id: itemId,
        title,
        status: "active",
        createdAt: now,
        completedAt: Option.none()
      })

      const updatedList = new TodoList({
        ...this,
        items: [...this.items, newItem],
        version: this.version + 1
      })

      // Событие эмитируется ПОСЛЕ проверки инвариантов
      return {
        aggregate: updatedList,
        events: [
          new TodoItemAdded({
            todoListId: this.id.value,
            todoItemId: itemId.value,
            title: title.value,
            occurredAt: now
          })
        ]
      } as const
    })
  }
}

Резюме

Инварианты — это сердце агрегата. Без инвариантов агрегат — просто контейнер данных. С инвариантами — это защитный бастион бизнес-логики.

Стратегии защиты (от лучшей к базовой):

  1. Make Illegal States Unrepresentable — невалидное состояние невозможно на уровне типов
  2. Smart Constructors — все точки создания проходят через валидацию
  3. Validate on Mutation — каждый метод проверяет затронутые инварианты
  4. Guard Methods — переиспользуемые проверки предусловий
  5. Assertion Functions — дополнительная защита для тестов и отладки

В Effect-ts инварианты реализуются через комбинацию Schema (для attribute-уровня), Effect.gen (для проверок с типизированными ошибками) и discriminated unions (для state-уровня). Каждое нарушение инварианта — это типизированная ошибка в E-канале, которую невозможно проигнорировать.