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

Инварианты Entity: бизнес-правила внутри сущности

Три уровня защиты инвариантов: типы данных, Branded Types, Schema.filter. Конструктивные гарантии vs защитные проверки. Паттерны инвариантов: временные, state machine, бизнес-ограничения, вычисляемые. Типизированные ошибки нарушения инвариантов. Инварианты vs валидация.

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

Инвариант (invariant) — это условие, которое всегда должно быть истинным для данной Entity на протяжении всего её жизненного цикла. Если инвариант нарушен, Entity находится в невалидном состоянии, которое не имеет смысла в предметной области.

Примеры инвариантов для Todo-задачи:

  • Заголовок не может быть пустым
  • Завершённая задача не может быть переведена в статус «ожидает»
  • Дата завершения не может быть раньше даты создания
  • Архивированная задача не может быть изменена
  • Приоритет можно менять только у незавершённых задач

Инварианты — это бизнес-правила, а не технические ограничения. Они определяются предметной областью, а не базой данных или фреймворком.


Принцип «Make Illegal States Unrepresentable»

Ядерный принцип функционального моделирования: сделать невалидные состояния непредставимыми на уровне типов. Если система типов не позволяет создать невалидный объект, инвариант никогда не будет нарушен.

Уровень 1: Типы данных

Первая линия защиты — правильно выбранные типы:

// ❌ ПЛОХО: title может быть пустым, priority — чем угодно
interface Todo {
  title: string       // "" — невалидно, но допустимо типом
  priority: string    // "banana" — невалидно, но допустимо типом
  status: string      // "flying" — невалидно, но допустимо типом
}

// ✅ ХОРОШО: типы исключают невалидные значения
interface Todo {
  title: TodoTitle          // Branded type, минимум 1 символ
  priority: Priority        // "high" | "medium" | "low" — только три значения
  status: TodoStatus        // Enum с фиксированными состояниями
}

Уровень 2: Branded Types с валидацией

Value Objects из модуля 12 — второй уровень защиты. Они гарантируют, что каждое значение прошло валидацию:

const TodoTitle = Schema.String.pipe(
  Schema.trimmed(),
  Schema.minLength(1, {
    message: () => "Заголовок задачи не может быть пустым"
  }),
  Schema.maxLength(200, {
    message: () => "Заголовок не может превышать 200 символов"
  }),
  Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type

// Невозможно создать TodoTitle с пустой строкой
// Schema.decode(TodoTitle)("") → ParseError ✅

Уровень 3: Инварианты Entity (кросс-полевые правила)

Первые два уровня защищают отдельные поля. Но многие инварианты зависят от комбинации полей:

// Инвариант: если status = "completed", то completedAt обязательно
// Инвариант: если status = "pending", то completedAt = None
// Инвариант: completedAt >= createdAt

Эти правила нельзя выразить через типы отдельных полей. Нужен третий уровень.


Реализация инвариантов через Schema.filter

Schema.filter позволяет добавить произвольную проверку к Schema, включая кросс-полевые инварианты:

import { Schema, Option } from "effect"

class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: TodoTitle,
  priority: Priority,
  status: TodoStatus,
  createdAt: Schema.DateTimeUtc,
  updatedAt: Schema.DateTimeUtc,
  completedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc),
}) {
  [Equal.symbol](that: Equal.Equal): boolean {
    return that instanceof Todo && this.id === that.id
  }
  [Hash.symbol](): number {
    return Hash.string(this.id)
  }
}

// Schema с инвариантами
const ValidatedTodo = Todo.pipe(
  Schema.filter((todo) => {
    const errors: Array<Schema.FilterIssue> = []

    // Инвариант 1: completedAt обязателен для completed
    if (todo.status === TodoStatus.Completed && Option.isNone(todo.completedAt)) {
      errors.push({
        path: ["completedAt"],
        message: "Завершённая задача должна иметь дату завершения"
      })
    }

    // Инвариант 2: completedAt запрещён для pending
    if (todo.status === TodoStatus.Pending && Option.isSome(todo.completedAt)) {
      errors.push({
        path: ["completedAt"],
        message: "Незавершённая задача не может иметь дату завершения"
      })
    }

    // Инвариант 3: completedAt >= createdAt
    if (Option.isSome(todo.completedAt)) {
      const completed = Option.getOrThrow(todo.completedAt)
      if (DateTime.lessThan(completed, todo.createdAt)) {
        errors.push({
          path: ["completedAt"],
          message: "Дата завершения не может быть раньше даты создания"
        })
      }
    }

    // Инвариант 4: updatedAt >= createdAt
    if (DateTime.lessThan(todo.updatedAt, todo.createdAt)) {
      errors.push({
        path: ["updatedAt"],
        message: "Дата обновления не может быть раньше даты создания"
      })
    }

    return errors.length > 0 ? errors : undefined
  })
)

Когда проверяются инварианты?

Инварианты проверяются в двух точках:

  1. При создании — фабричный метод create гарантирует, что Entity создаётся в валидном состоянии
  2. При decode — когда данные приходят из внешнего источника (БД, HTTP), Schema.decode проверяет все инварианты

Между этими точками инварианты поддерживаются методами поведения (чистыми функциями), которые по построению не могут нарушить инвариант.


Стратегия «Trust but verify»

В нашем подходе есть два уровня гарантий:

Конструктивная гарантия (statically enforced)

Поведение Entity реализовано так, что инвариант невозможно нарушить по построению:

// Метод complete автоматически проставляет completedAt
// Инвариант "completed → completedAt exists" гарантирован конструктивно
const complete = (todo: Todo): Effect.Effect<Todo, AlreadyCompleted> =>
  todo.status === TodoStatus.Completed
    ? Effect.fail(new AlreadyCompleted({ todoId: todo.id }))
    : Effect.gen(function* () {
        const now = yield* DateTime.now
        return new Todo({
          ...todo,
          status: TodoStatus.Completed,
          completedAt: Option.some(now),
          updatedAt: now,
        })
      })

Здесь невозможно забыть проставить completedAt, потому что это делает сама функция complete. Инвариант гарантирован по построению кода, а не проверкой в runtime.

Защитная проверка (runtime verified)

Schema.filter — это страховочная сетка на случай:

  • Данных из внешних источников (БД может содержать legacy-данные)
  • Ошибок в маппинге адаптера
  • Коррупции данных
// При чтении из БД — проверяем инварианты
const todoFromDb = Schema.decodeUnknown(ValidatedTodo)(rawRow)
// Если БД содержит невалидные данные, получим ParseError

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

Паттерн 1: Временные инварианты

Связь между датами — один из самых частых инвариантов:

// createdAt <= updatedAt <= completedAt
// createdAt неизменна после создания
// updatedAt обновляется при каждом изменении

const updateTitle = (todo: Todo, newTitle: TodoTitle): Effect.Effect<Todo, TodoArchived> =>
  todo.status === TodoStatus.Archived
    ? Effect.fail(new TodoArchived({ todoId: todo.id }))
    : Effect.gen(function* () {
        const now = yield* DateTime.now
        return new Todo({
          ...todo,
          title: newTitle,
          updatedAt: now,  // Инвариант: updatedAt >= createdAt автоматически
        })
      })

Паттерн 2: Инварианты состояния (State Machine)

Переходы между состояниями — мощнейший тип инварианта. Не все переходы допустимы:

  ┌─────────┐      ┌───────────┐      ┌──────────┐
  │ Pending  │─────→│ Completed │─────→│ Archived │
  │         │      │           │      │          │
  └─────────┘      └───────────┘      └──────────┘
       │                                    ▲
       └────────────────────────────────────┘
       
  Допустимые переходы:
  Pending   → Completed  ✅
  Pending   → Archived   ✅
  Completed → Archived   ✅
  Completed → Pending    ❌ (нельзя «разозавершить»)
  Archived  → *          ❌ (архив — терминальное состояние)

Реализуем через тип:

// Определяем допустимые переходы на уровне типов
type TodoTransition =
  | { readonly from: "pending"; readonly to: "completed" }
  | { readonly from: "pending"; readonly to: "archived" }
  | { readonly from: "completed"; readonly to: "archived" }

// Проверка допустимости перехода
const isValidTransition = (from: TodoStatus, to: TodoStatus): boolean => {
  const transitions: ReadonlyArray<TodoTransition> = [
    { from: "pending", to: "completed" },
    { from: "pending", to: "archived" },
    { from: "completed", to: "archived" },
  ] as const

  return transitions.some((t) => t.from === from && t.to === to)
}

// Обобщённая функция перехода
const transition = (
  todo: Todo,
  to: TodoStatus,
): Effect.Effect<Todo, InvalidTransition> =>
  isValidTransition(todo.status, to)
    ? Effect.gen(function* () {
        const now = yield* DateTime.now
        return new Todo({
          ...todo,
          status: to,
          updatedAt: now,
          completedAt: to === TodoStatus.Completed
            ? Option.some(now)
            : todo.completedAt,
        })
      })
    : Effect.fail(new InvalidTransition({
        todoId: todo.id,
        from: todo.status,
        to,
      }))

Паттерн 3: Бизнес-ограничения

Ограничения, специфичные для предметной области:

// Инвариант: задача с высоким приоритетом должна иметь описание
const TodoWithHighPriorityNeedsDescription = Todo.pipe(
  Schema.filter((todo) =>
    todo.priority === Priority.High && Option.isNone(todo.description)
      ? {
          path: ["description"],
          message: "Задача с высоким приоритетом должна иметь описание"
        }
      : undefined
  )
)

// Инвариант: заголовок не может совпадать с другой задачей
// ⚠️ Это НЕ инвариант Entity — это инвариант АГРЕГАТА
// Entity не знает о других Entity, только Aggregate знает о коллекции

Паттерн 4: Вычисляемые инварианты

Некоторые поля являются производными от других и должны оставаться согласованными:

class TodoWithProgress extends Schema.Class<TodoWithProgress>("TodoWithProgress")({
  id: TodoId,
  title: TodoTitle,
  status: TodoStatus,
  subtasks: Schema.Array(Subtask),
  completionPercentage: Schema.Number.pipe(
    Schema.between(0, 100)
  ),
}) {}

// Инвариант: completionPercentage должен соответствовать реальному проценту
const ValidatedTodoWithProgress = TodoWithProgress.pipe(
  Schema.filter((todo) => {
    const completedCount = todo.subtasks.filter(
      (s) => s.status === "completed"
    ).length
    const expected = todo.subtasks.length > 0
      ? Math.round((completedCount / todo.subtasks.length) * 100)
      : 0

    return todo.completionPercentage !== expected
      ? {
          path: ["completionPercentage"],
          message: `Ожидается ${expected}%, получено ${todo.completionPercentage}%`
        }
      : undefined
  })
)

Лучший подход: вычисляемые поля лучше вообще не хранить, а вычислять на лету через getter или функцию. Это устраняет инвариант по определению.

// Лучше: вычисляемое свойство вместо хранимого
const completionPercentage = (todo: TodoWithProgress): number => {
  const completed = todo.subtasks.filter((s) => s.status === "completed").length
  return todo.subtasks.length > 0
    ? Math.round((completed / todo.subtasks.length) * 100)
    : 0
}

Типизированные ошибки нарушения инвариантов

Когда попытка нарушить инвариант обнаруживается в runtime (например, попытка завершить уже завершённую задачу), мы возвращаем типизированную доменную ошибку:

import { Data } from "effect"

// Доменные ошибки — часть контракта Entity
class AlreadyCompleted extends Data.TaggedError("AlreadyCompleted")<{
  readonly todoId: TodoId
}> {}

class InvalidTransition extends Data.TaggedError("InvalidTransition")<{
  readonly todoId: TodoId
  readonly from: TodoStatus
  readonly to: TodoStatus
}> {}

class TodoArchived extends Data.TaggedError("TodoArchived")<{
  readonly todoId: TodoId
}> {}

class InvalidPriorityChange extends Data.TaggedError("InvalidPriorityChange")<{
  readonly todoId: TodoId
  readonly reason: string
}> {}

Эти ошибки появляются в E-канале Effect и видны в типах:

// Тип чётко говорит: complete может завершиться AlreadyCompleted
const complete: (todo: Todo) => Effect.Effect<Todo, AlreadyCompleted>

// Вызывающий код ОБЯЗАН обработать ошибку
const result = complete(todo).pipe(
  Effect.catchTag("AlreadyCompleted", (err) =>
    Effect.logWarning(`Todo ${err.todoId} is already completed`)
  )
)

Инварианты vs Валидация: важное различие

АспектИнвариантВалидация
ГдеВнутри доменаНа границе (вход)
КогдаВсегдаПри получении данных
Что проверяетБизнес-правилаФормат/наличие данных
Пример«Завершённая задача имеет completedAt»«Поле title обязательно»
НарушениеБаг в системеНекорректный ввод
РеакцияDefect / невозможное состояниеОшибка валидации пользователю

Валидация отвечает на вопрос: «Корректны ли входные данные?» Инвариант отвечает на вопрос: «Находится ли объект в допустимом состоянии?»

// ВАЛИДАЦИЯ: проверяем входные данные на границе
const CreateTodoInput = Schema.Struct({
  title: Schema.String.pipe(Schema.minLength(1)),   // валидация
  priority: Schema.optional(Priority),                // валидация
})

// ИНВАРИАНТ: Entity всегда в валидном состоянии
// completedAt и status всегда согласованы — это гарантирует логика create/complete

Антипаттерн: инварианты в неправильном месте

❌ Инвариант в контроллере

// Плохо: бизнес-правило в HTTP-адаптере
app.post("/todos/:id/complete", (req, res) => {
  const todo = findTodo(req.params.id)
  if (todo.status === "completed") {    // Бизнес-правило!
    return res.status(400).json({ error: "Already completed" })
  }
  // ...
})

❌ Инвариант в сервисе приложения

// Плохо: бизнес-правило размазано по Application Service
const completeTodoUseCase = (id: TodoId) =>
  Effect.gen(function* () {
    const todo = yield* TodoRepository.findById(id)
    if (todo.status === "completed") {   // Бизнес-правило!
      return yield* Effect.fail(new AlreadyCompleted({ todoId: id }))
    }
    // ...
  })

✅ Инвариант в Entity

// Правильно: бизнес-правило в Entity
// complete() сама знает, можно ли завершить задачу
const completeTodoUseCase = (id: TodoId) =>
  Effect.gen(function* () {
    const todo = yield* TodoRepository.findById(id)
    const completed = yield* Todo.complete(todo) // Entity решает!
    yield* TodoRepository.save(completed)
    return completed
  })

Инвариант должен жить там, где живут данные — в Entity. Application Service лишь вызывает метод Entity и обрабатывает результат.


Тестирование инвариантов

Инварианты легко тестировать, потому что они выражены через чистые функции:

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

describe("Todo invariants", () => {
  it("не позволяет завершить уже завершённую задачу", () =>
    Effect.gen(function* () {
      const todo = yield* Todo.create({ title: TodoTitle.make("Test") })
      const completed = yield* Todo.complete(todo)
      
      const result = yield* Todo.complete(completed).pipe(Effect.either)
      
      expect(Either.isLeft(result)).toBe(true)
      expect(result.pipe(Either.getOrThrow)).toBeInstanceOf(AlreadyCompleted) // только для Left
    }).pipe(Effect.runPromise)
  )

  it("всегда проставляет completedAt при завершении", () =>
    Effect.gen(function* () {
      const todo = yield* Todo.create({ title: TodoTitle.make("Test") })
      const completed = yield* Todo.complete(todo)
      
      expect(Option.isSome(completed.completedAt)).toBe(true)
    }).pipe(Effect.runPromise)
  )

  it("не позволяет изменять архивированную задачу", () =>
    Effect.gen(function* () {
      const todo = yield* Todo.create({ title: TodoTitle.make("Test") })
      const archived = yield* Todo.archive(todo)
      
      const result = yield* Todo.updateTitle(archived, TodoTitle.make("New")).pipe(Effect.either)
      
      expect(Either.isLeft(result)).toBe(true)
    }).pipe(Effect.runPromise)
  )
})

Ключевые выводы

  1. Инвариант — это бизнес-правило, которое всегда истинно для Entity
  2. «Make Illegal States Unrepresentable» — три уровня защиты: типы → Value Objects → Schema.filter
  3. Конструктивная гарантия — методы поведения по построению не нарушают инвариант
  4. Schema.filter — страховочная сетка для данных из внешних источников
  5. Нарушение инварианта — типизированная доменная ошибка в E-канале
  6. Инварианты живут в Entity, не в контроллерах или сервисах
  7. Вычисляемые свойства лучше не хранить, а вычислять на лету