Типобезопасный домен: Гексагональная архитектура на базе Effect Жизненный цикл: create → update → complete → archive
Глава

Жизненный цикл: 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, → ArchivedupdateTitle, 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

  1. completedAt обязателен для CompletedTodo и невозможен для PendingTodoна уровне типов
  2. archivedAt обязателен для ArchivedTodoна уровне типов
  3. 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 UnionPhantom Types
СложностьНизкаяСредняяВысокая
Compile-time гарантииНетДа, через _tagДа, через бренд
Runtime проверкиОбязательныМинимальныНе нужны
Pattern matchingif/switchMatch.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 курса.


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

  1. Entity — это конечный автомат с определёнными состояниями и переходами
  2. Три подхода: единый тип (простой), tagged union (типобезопасный), phantom types (строгий)
  3. Каждый переход проверяет допустимость и возвращает типизированную ошибку
  4. Метки времени документируют историю Entity
  5. Переходы генерируют события — фундамент для Event Sourcing
  6. Tagged Union — лучший выбор для сложных Entity с различными полями в разных состояниях
  7. updatedAt обновляется автоматически при каждом изменении