Защита инвариантов: правила внутри агрегата
Классификация инвариантов (атрибутные, межатрибутные, коллекционные, состояния, агрегатные). Пять стратегий защиты: 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)
| # | Инвариант | Тип | Стратегия |
|---|---|---|---|
| 1 | items.length <= maxItems | Collection | Validate on Mutation |
| 2 | Все items[].title уникальны | Collection | Validate on Mutation |
| 3 | Архивированный список нельзя менять | State | Guard Method |
| 4 | version >= 0 | Attribute | Smart Constructor |
| 5 | maxItems > 0 && maxItems <= 1000 | Attribute | Schema refinement |
| 6 | name не пустое | Attribute | Value Object |
Инварианты TodoItem (Child Entity)
| # | Инвариант | Тип | Стратегия |
|---|---|---|---|
| 7 | Completed → имеет completedAt | Cross-Attribute | Discriminated Union |
| 8 | Active → не имеет completedAt | Cross-Attribute | Discriminated Union |
| 9 | createdAt <= completedAt | Cross-Attribute | Validate on Mutation |
| 10 | title не пустой, <= 200 символов | Attribute | Value 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
})
}
}
Резюме
Инварианты — это сердце агрегата. Без инвариантов агрегат — просто контейнер данных. С инвариантами — это защитный бастион бизнес-логики.
Стратегии защиты (от лучшей к базовой):
- Make Illegal States Unrepresentable — невалидное состояние невозможно на уровне типов
- Smart Constructors — все точки создания проходят через валидацию
- Validate on Mutation — каждый метод проверяет затронутые инварианты
- Guard Methods — переиспользуемые проверки предусловий
- Assertion Functions — дополнительная защита для тестов и отладки
В Effect-ts инварианты реализуются через комбинацию Schema (для attribute-уровня), Effect.gen (для проверок с типизированными ошибками) и discriminated unions (для state-уровня). Каждое нарушение инварианта — это типизированная ошибка в E-канале, которую невозможно проигнорировать.