Жизненный цикл: create → update → complete → archive
Entity как конечный автомат (FSM). Три подхода к реализации: единый тип с runtime-проверками, Tagged Union с compile-time гарантиями, Phantom Types. Сравнение подходов. Temporal паттерны и метки времени. Связь с Domain Events.
Entity как конечный автомат
Каждая Entity проходит через определённые состояния на протяжении своей жизни. Переходы между состояниями подчиняются бизнес-правилам: не любой переход допустим, и каждый переход может нести побочные эффекты (изменение полей, генерация событий).
Моделирование жизненного цикла как конечного автомата (Finite State Machine, FSM) — это мощный подход, который:
- Делает все допустимые состояния явными
- Делает все допустимые переходы документированными
- Делает невалидные переходы невозможными (на уровне типов или runtime)
Жизненный цикл Todo
Определим полный жизненный цикл задачи:
┌─── updateTitle ───┐
│ updatePriority │
│ setDescription │
▼ │
┌──────────┐ │
create ──→ │ Pending │ ◄──────────┘
└──────────┘
│ │
complete │ │ archive
▼ ▼
┌───────────┐ ┌───────────┐
│ Completed │ │ Archived │ ◄── терминальное
└───────────┘ └───────────┘ состояние
│
archive │
▼
┌───────────┐
│ Archived │
└───────────┘
Формализация
| Состояние | Допустимые переходы | Допустимые операции |
|---|---|---|
| Pending | → Completed, → Archived | updateTitle, updatePriority, setDescription, setDueDate |
| Completed | → Archived | (только чтение и архивирование) |
| Archived | (нет переходов) | (только чтение) |
Реализация: простой подход — единый тип с runtime-проверками
Самый прямолинейный подход — один класс Todo со всеми полями и проверками в каждом методе:
import { Schema, Effect, DateTime, Option, Data } from "effect"
// ─── Статусы ────────────────────────────────────────────────
const TodoStatus = {
Pending: "pending",
Completed: "completed",
Archived: "archived",
} as const
type TodoStatus = (typeof TodoStatus)[keyof typeof TodoStatus]
// ─── Entity ─────────────────────────────────────────────────
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: TodoTitle,
description: Schema.OptionFromNullOr(Schema.String),
priority: Priority,
status: Schema.Literal("pending", "completed", "archived"),
createdAt: Schema.DateTimeUtc,
updatedAt: Schema.DateTimeUtc,
completedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc),
archivedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc),
}) {
// ... Equal, Hash ...
// ─── Guard: проверка что Entity можно менять ──────────
private static ensureModifiable(todo: Todo): Effect.Effect<void, TodoArchived> {
return todo.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: todo.id }))
: Effect.void
}
// ─── Создание ─────────────────────────────────────────
static readonly create = (params: {
readonly title: TodoTitle
readonly description?: string
readonly priority?: Priority
}): Effect.Effect<Todo> =>
Effect.gen(function* () {
const now = yield* DateTime.now
const id = yield* generateTodoId
return new Todo({
id,
title: params.title,
description: Option.fromNullable(params.description),
priority: params.priority ?? Priority.Medium,
status: TodoStatus.Pending,
createdAt: now,
updatedAt: now,
completedAt: Option.none(),
archivedAt: Option.none(),
})
})
// ─── Переход: Pending → Completed ────────────────────
readonly complete = (): Effect.Effect<Todo, AlreadyCompleted | TodoArchived> =>
this.status === TodoStatus.Completed
? Effect.fail(new AlreadyCompleted({ todoId: this.id }))
: this.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: this.id }))
: Effect.map(DateTime.now, (now) =>
new Todo({
...this,
status: TodoStatus.Completed,
completedAt: Option.some(now),
updatedAt: now,
})
)
// ─── Переход: * → Archived ───────────────────────────
readonly archive = (): Effect.Effect<Todo, AlreadyArchived> =>
this.status === TodoStatus.Archived
? Effect.fail(new AlreadyArchived({ todoId: this.id }))
: Effect.map(DateTime.now, (now) =>
new Todo({
...this,
status: TodoStatus.Archived,
archivedAt: Option.some(now),
updatedAt: now,
})
)
// ─── Мутация: обновление атрибутов ───────────────────
readonly updateTitle = (newTitle: TodoTitle): Effect.Effect<Todo, TodoArchived> =>
Effect.gen(this, function* () {
yield* Todo.ensureModifiable(this)
const now = yield* DateTime.now
return new Todo({ ...this, title: newTitle, updatedAt: now })
})
readonly updatePriority = (
newPriority: Priority,
): Effect.Effect<Todo, TodoArchived | CompletedTodoModification> =>
this.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: this.id }))
: this.status === TodoStatus.Completed
? Effect.fail(new CompletedTodoModification({ todoId: this.id }))
: Effect.map(DateTime.now, (now) =>
new Todo({ ...this, priority: newPriority, updatedAt: now })
)
}
Этот подход прост и понятен, но у него есть недостаток: все проверки — в runtime. Компилятор не мешает вызвать complete() на архивированной задаче.
Реализация: продвинутый подход — Phantom Types
Для более строгой типобезопасности можно использовать phantom types — типы-фантомы, которые существуют только на уровне компилятора:
// Phantom type для статуса
declare const StatusBrand: unique symbol
type StatusTag<S extends string> = { readonly [StatusBrand]: S }
// Entity параметризована статусом
type TodoInStatus<S extends TodoStatus> = Todo & StatusTag<S>
// Функции принимают только допустимые статусы
const complete = (
todo: TodoInStatus<"pending">
): Effect.Effect<TodoInStatus<"completed">, never> =>
Effect.map(DateTime.now, (now) =>
new Todo({
...todo,
status: TodoStatus.Completed,
completedAt: Option.some(now),
updatedAt: now,
}) as TodoInStatus<"completed">
)
// Компилятор не даст завершить архивированную задачу
const archivedTodo: TodoInStatus<"archived"> = ...
complete(archivedTodo) // ❌ Type error!
Этот подход мощный, но может быть слишком сложным для большинства проектов. Рекомендуем использовать его выборочно — там, где критически важна compile-time гарантия.
Реализация: Tagged Union подход
Ещё один мощный паттерн — моделирование каждого состояния как отдельного типа:
import { Schema, Data, DateTime, Option, Effect } from "effect"
// Каждое состояние — отдельный класс с релевантными полями
class PendingTodo extends Schema.TaggedClass<PendingTodo>()("PendingTodo", {
id: TodoId,
title: TodoTitle,
description: Schema.OptionFromNullOr(Schema.String),
priority: Priority,
createdAt: Schema.DateTimeUtc,
updatedAt: Schema.DateTimeUtc,
}) {}
class CompletedTodo extends Schema.TaggedClass<CompletedTodo>()("CompletedTodo", {
id: TodoId,
title: TodoTitle,
description: Schema.OptionFromNullOr(Schema.String),
priority: Priority,
createdAt: Schema.DateTimeUtc,
updatedAt: Schema.DateTimeUtc,
completedAt: Schema.DateTimeUtc, // Обязательно! Не Option
}) {}
class ArchivedTodo extends Schema.TaggedClass<ArchivedTodo>()("ArchivedTodo", {
id: TodoId,
title: TodoTitle,
description: Schema.OptionFromNullOr(Schema.String),
priority: Priority,
createdAt: Schema.DateTimeUtc,
updatedAt: Schema.DateTimeUtc,
completedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc),
archivedAt: Schema.DateTimeUtc, // Обязательно!
}) {}
// Union тип
type Todo = PendingTodo | CompletedTodo | ArchivedTodo
const TodoSchema = Schema.Union(PendingTodo, CompletedTodo, ArchivedTodo)
Преимущества Tagged Union
- completedAt обязателен для
CompletedTodoи невозможен дляPendingTodo— на уровне типов - archivedAt обязателен для
ArchivedTodo— на уровне типов - Pattern matching через
_tag— исчерпывающий и типобезопасный
import { Match } from "effect"
const describeStatus = Match.type<Todo>().pipe(
Match.tag("PendingTodo", (t) => `Ожидает выполнения с ${t.createdAt}`),
Match.tag("CompletedTodo", (t) => `Завершена ${t.completedAt}`),
Match.tag("ArchivedTodo", (t) => `Архивирована ${t.archivedAt}`),
Match.exhaustive // Компилятор проверяет, что все варианты обработаны
)
Переходы между состояниями
// create → PendingTodo (единственная точка входа)
const create = (params: {
readonly title: TodoTitle
readonly priority?: Priority
}): Effect.Effect<PendingTodo> =>
Effect.gen(function* () {
const now = yield* DateTime.now
const id = yield* generateTodoId
return new PendingTodo({
id,
title: params.title,
description: Option.none(),
priority: params.priority ?? Priority.Medium,
createdAt: now,
updatedAt: now,
})
})
// PendingTodo → CompletedTodo
const complete = (todo: PendingTodo): Effect.Effect<CompletedTodo> =>
Effect.map(DateTime.now, (now) =>
new CompletedTodo({
id: todo.id,
title: todo.title,
description: todo.description,
priority: todo.priority,
createdAt: todo.createdAt,
updatedAt: now,
completedAt: now,
})
)
// PendingTodo | CompletedTodo → ArchivedTodo
const archive = (todo: PendingTodo | CompletedTodo): Effect.Effect<ArchivedTodo> =>
Effect.map(DateTime.now, (now) =>
new ArchivedTodo({
id: todo.id,
title: todo.title,
description: todo.description,
priority: todo.priority,
createdAt: todo.createdAt,
updatedAt: now,
completedAt: todo._tag === "CompletedTodo"
? Option.some(todo.completedAt)
: Option.none(),
archivedAt: now,
})
)
// Обновление — только для PendingTodo
const updateTitle = (todo: PendingTodo, title: TodoTitle): Effect.Effect<PendingTodo> =>
Effect.map(DateTime.now, (now) =>
new PendingTodo({ ...todo, title, updatedAt: now })
)
Невалидные переходы — ошибки компиляции
// ❌ Не скомпилируется! complete принимает только PendingTodo
const archived: ArchivedTodo = ...
complete(archived) // Type error: ArchivedTodo is not assignable to PendingTodo
// ❌ Не скомпилируется! updateTitle принимает только PendingTodo
const completed: CompletedTodo = ...
updateTitle(completed, newTitle) // Type error!
Сравнение подходов
| Аспект | Единый тип | Tagged Union | Phantom Types |
|---|---|---|---|
| Сложность | Низкая | Средняя | Высокая |
| Compile-time гарантии | Нет | Да, через _tag | Да, через бренд |
| Runtime проверки | Обязательны | Минимальны | Не нужны |
| Pattern matching | if/switch | Match.tag (exhaustive) | Не применимо |
| Сериализация | Простая | Через Union Schema | Сложная |
| Расширяемость | Просто добавить статус | Новый класс + рефактор | Сложно |
| Когда использовать | Простые Entity (2-3 статуса) | Сложные Entity (4+ статусов, разные поля) | Критические бизнес-процессы |
Рекомендация для курса
Для Todo-приложения единый тип с runtime-проверками — оптимальный выбор:
- Достаточно простая модель (3 состояния)
- Понятный код
- Schema.Class работает из коробки
- Runtime-ошибки типизированы через Effect E-канал
Tagged Union — отлично подходит, когда:
- Разные состояния имеют разные наборы полей
- Нужен exhaustive pattern matching
- Количество состояний растёт и бизнес-правила усложняются
Temporal паттерны: метки времени в жизненном цикле
Каждый переход состояния оставляет временной след:
// Полная временная линия Entity
interface TodoTimeline {
readonly createdAt: DateTime.Utc // Момент создания (неизменный)
readonly updatedAt: DateTime.Utc // Последнее любое изменение
readonly completedAt?: DateTime.Utc // Момент завершения
readonly archivedAt?: DateTime.Utc // Момент архивирования
}
// Инварианты временной линии:
// createdAt <= updatedAt
// createdAt <= completedAt (если есть)
// createdAt <= archivedAt (если есть)
// completedAt <= archivedAt (если оба есть)
Автоматическое обновление updatedAt
Вместо ручного проставления updatedAt в каждом методе, можно создать helper:
const withTimestamp = <A extends Todo>(
todo: A,
updates: Partial<Omit<typeof Todo.Type, "id" | "createdAt">>,
): Effect.Effect<Todo> =>
Effect.map(DateTime.now, (now) =>
new Todo({
...todo,
...updates,
updatedAt: now,
})
)
// Использование
const updateTitle = (todo: Todo, title: TodoTitle): Effect.Effect<Todo, TodoArchived> =>
todo.status === TodoStatus.Archived
? Effect.fail(new TodoArchived({ todoId: todo.id }))
: withTimestamp(todo, { title })
Жизненный цикл и Domain Events
Каждый переход состояния — это событие в домене. В модуле 17 мы подробно рассмотрим Domain Events, но уже сейчас можно заложить фундамент:
// Каждый переход генерирует событие
const complete = (todo: Todo): Effect.Effect<{
readonly todo: Todo
readonly event: TodoCompleted
}, AlreadyCompleted> =>
todo.status === TodoStatus.Completed
? Effect.fail(new AlreadyCompleted({ todoId: todo.id }))
: Effect.gen(function* () {
const now = yield* DateTime.now
const completed = new Todo({
...todo,
status: TodoStatus.Completed,
completedAt: Option.some(now),
updatedAt: now,
})
const event = new TodoCompleted({
todoId: todo.id,
completedAt: now,
})
return { todo: completed, event }
})
Этот паттерн станет основой для Event Sourcing в части IX курса.
Ключевые выводы
- Entity — это конечный автомат с определёнными состояниями и переходами
- Три подхода: единый тип (простой), tagged union (типобезопасный), phantom types (строгий)
- Каждый переход проверяет допустимость и возвращает типизированную ошибку
- Метки времени документируют историю Entity
- Переходы генерируют события — фундамент для Event Sourcing
- Tagged Union — лучший выбор для сложных Entity с различными полями в разных состояниях
- updatedAt обновляется автоматически при каждом изменении