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

Todo Entity: полная реализация с инвариантами

Production-ready реализация Todo Entity: Value Objects, Branded Types, Schema.Class, Equal/Hash, фабричный метод, guards, переходы состояний, мутация атрибутов, кросс-полевые инварианты, Encoded форма, barrel file, примеры использования, чеклист.

Структура файлов

Прежде чем писать код, определим структуру. В hexagonal architecture доменные файлы расположены в центре:

src/
  domain/
    value-objects/
      TodoId.ts
      TodoTitle.ts
      Priority.ts
      TodoStatus.ts
      DueDate.ts
    entities/
      Todo.ts              ← эта статья
    errors/
      TodoErrors.ts
    index.ts               ← barrel file (публичный API домена)

Шаг 1: Value Objects (импорты)

Предполагаем, что Value Objects определены в модуле 12. Приведём их компактно для полноты картины:

// domain/value-objects/TodoId.ts
import { Schema } from "effect"

const TodoIdBrand = Symbol.for("TodoId")
export const TodoId = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.brand(TodoIdBrand),
  Schema.annotations({ identifier: "TodoId" })
)
export type TodoId = typeof TodoId.Type

// domain/value-objects/TodoTitle.ts
const TodoTitleBrand = Symbol.for("TodoTitle")
export const TodoTitle = Schema.String.pipe(
  Schema.trimmed(),
  Schema.nonEmptyString({ message: () => "Заголовок не может быть пустым" }),
  Schema.maxLength(200, { message: () => "Заголовок не может быть длиннее 200 символов" }),
  Schema.brand(TodoTitleBrand),
  Schema.annotations({ identifier: "TodoTitle" })
)
export type TodoTitle = typeof TodoTitle.Type

// domain/value-objects/Priority.ts
export const Priority = Schema.Literal("low", "medium", "high")
export type Priority = typeof Priority.Type
export const PriorityValues = {
  Low: "low" as Priority,
  Medium: "medium" as Priority,
  High: "high" as Priority,
} as const

// domain/value-objects/TodoStatus.ts
export const TodoStatus = Schema.Literal("pending", "completed", "archived")
export type TodoStatus = typeof TodoStatus.Type
export const TodoStatusValues = {
  Pending: "pending" as TodoStatus,
  Completed: "completed" as TodoStatus,
  Archived: "archived" as TodoStatus,
} as const

// domain/value-objects/DueDate.ts
const DueDateBrand = Symbol.for("DueDate")
export const DueDate = Schema.DateTimeUtc.pipe(
  Schema.brand(DueDateBrand),
  Schema.annotations({ identifier: "DueDate" })
)
export type DueDate = typeof DueDate.Type

Шаг 2: Доменные ошибки

// domain/errors/TodoErrors.ts
import { Data } from "effect"
import type { TodoId } from "../value-objects/TodoId"
import type { TodoStatus } from "../value-objects/TodoStatus"

export class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly todoId: TodoId
}> {
  get message() {
    return `Задача ${this.todoId} не найдена`
  }
}

export class AlreadyCompleted extends Data.TaggedError("AlreadyCompleted")<{
  readonly todoId: TodoId
}> {
  get message() {
    return `Задача ${this.todoId} уже завершена`
  }
}

export class AlreadyArchived extends Data.TaggedError("AlreadyArchived")<{
  readonly todoId: TodoId
}> {
  get message() {
    return `Задача ${this.todoId} уже архивирована`
  }
}

export class InvalidTransition extends Data.TaggedError("InvalidTransition")<{
  readonly todoId: TodoId
  readonly from: TodoStatus
  readonly to: TodoStatus
}> {
  get message() {
    return `Недопустимый переход для задачи ${this.todoId}: ${this.from} → ${this.to}`
  }
}

export class TodoModificationForbidden extends Data.TaggedError("TodoModificationForbidden")<{
  readonly todoId: TodoId
  readonly reason: string
}> {
  get message() {
    return `Изменение задачи ${this.todoId} запрещено: ${this.reason}`
  }
}

// Union всех доменных ошибок Todo
export type TodoError =
  | TodoNotFound
  | AlreadyCompleted
  | AlreadyArchived
  | InvalidTransition
  | TodoModificationForbidden

Шаг 3: Полная реализация Todo Entity

// domain/entities/Todo.ts
import {
  Schema,
  Effect,
  DateTime,
  Option,
  Equal,
  Hash,
  pipe,
} from "effect"
import { TodoId } from "../value-objects/TodoId"
import { TodoTitle } from "../value-objects/TodoTitle"
import { Priority, PriorityValues } from "../value-objects/Priority"
import { TodoStatus, TodoStatusValues } from "../value-objects/TodoStatus"
import { DueDate } from "../value-objects/DueDate"
import {
  AlreadyCompleted,
  AlreadyArchived,
  InvalidTransition,
  TodoModificationForbidden,
} from "../errors/TodoErrors"

// ─────────────────────────────────────────────────────────────
// Генерация ID
// ─────────────────────────────────────────────────────────────

const generateTodoId: Effect.Effect<TodoId> = Effect.sync(() =>
  TodoId.make(`todo_${crypto.randomUUID()}`)
)

// ─────────────────────────────────────────────────────────────
// Допустимые переходы состояний
// ─────────────────────────────────────────────────────────────

const VALID_TRANSITIONS: ReadonlyArray<{
  readonly from: TodoStatus
  readonly to: TodoStatus
}> = [
  { from: "pending", to: "completed" },
  { from: "pending", to: "archived" },
  { from: "completed", to: "archived" },
] as const

const isValidTransition = (from: TodoStatus, to: TodoStatus): boolean =>
  VALID_TRANSITIONS.some((t) => t.from === from && t.to === to)

// ─────────────────────────────────────────────────────────────
// Entity
// ─────────────────────────────────────────────────────────────

export class Todo extends Schema.Class<Todo>("Todo")({
  // ─── Идентичность ──────────────────────────────────────
  id: TodoId,

  // ─── Атрибуты (Value Objects) ──────────────────────────
  title: TodoTitle,
  description: Schema.OptionFromNullOr(Schema.String),
  priority: Priority,
  status: TodoStatus,
  dueDate: Schema.OptionFromNullOr(DueDate),

  // ─── Временные метки жизненного цикла ──────────────────
  createdAt: Schema.DateTimeUtc,
  updatedAt: Schema.DateTimeUtc,
  completedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc),
  archivedAt: 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)
  }

  // ═══════════════════════════════════════════════════════════
  // Конструктор: создание новой задачи
  // ═══════════════════════════════════════════════════════════

  static readonly create = (params: {
    readonly title: TodoTitle
    readonly description?: string
    readonly priority?: Priority
    readonly dueDate?: DueDate
  }): 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 ?? PriorityValues.Medium,
        status: TodoStatusValues.Pending,
        dueDate: Option.fromNullable(params.dueDate),
        createdAt: now,
        updatedAt: now,
        completedAt: Option.none(),
        archivedAt: Option.none(),
      })
    })

  // ═══════════════════════════════════════════════════════════
  // Guards: проверки состояния
  // ═══════════════════════════════════════════════════════════

  get isPending(): boolean {
    return this.status === TodoStatusValues.Pending
  }

  get isCompleted(): boolean {
    return this.status === TodoStatusValues.Completed
  }

  get isArchived(): boolean {
    return this.status === TodoStatusValues.Archived
  }

  get isHighPriority(): boolean {
    return this.priority === PriorityValues.High
  }

  get hasDescription(): boolean {
    return Option.isSome(this.description)
  }

  get hasDueDate(): boolean {
    return Option.isSome(this.dueDate)
  }

  isOverdue(now: DateTime.Utc): boolean {
    return Option.match(this.dueDate, {
      onNone: () => false,
      onSome: (due) =>
        DateTime.greaterThan(now, due) && !this.isCompleted,
    })
  }

  // ═══════════════════════════════════════════════════════════
  // Переходы состояний
  // ═══════════════════════════════════════════════════════════

  /**
   * Завершить задачу.
   * Допустимо только из Pending.
   */
  readonly complete = (): Effect.Effect<Todo, AlreadyCompleted | AlreadyArchived> => {
    if (this.isCompleted) {
      return Effect.fail(new AlreadyCompleted({ todoId: this.id }))
    }
    if (this.isArchived) {
      return Effect.fail(new AlreadyArchived({ todoId: this.id }))
    }

    return Effect.map(DateTime.now, (now) =>
      new Todo({
        ...this,
        status: TodoStatusValues.Completed,
        completedAt: Option.some(now),
        updatedAt: now,
      })
    )
  }

  /**
   * Архивировать задачу.
   * Допустимо из Pending и Completed.
   */
  readonly archive = (): Effect.Effect<Todo, AlreadyArchived> => {
    if (this.isArchived) {
      return Effect.fail(new AlreadyArchived({ todoId: this.id }))
    }

    return Effect.map(DateTime.now, (now) =>
      new Todo({
        ...this,
        status: TodoStatusValues.Archived,
        archivedAt: Option.some(now),
        updatedAt: now,
      })
    )
  }

  // ═══════════════════════════════════════════════════════════
  // Мутация атрибутов
  // ═══════════════════════════════════════════════════════════

  /**
   * Общий guard: Entity можно менять только если она не архивирована
   * и не завершена (для определённых операций).
   */
  private ensureModifiable(): Effect.Effect<void, TodoModificationForbidden> {
    if (this.isArchived) {
      return Effect.fail(new TodoModificationForbidden({
        todoId: this.id,
        reason: "Архивированная задача не может быть изменена"
      }))
    }
    return Effect.void
  }

  private ensurePending(): Effect.Effect<void, TodoModificationForbidden> {
    if (!this.isPending) {
      return Effect.fail(new TodoModificationForbidden({
        todoId: this.id,
        reason: `Операция доступна только для задач в статусе pending, текущий: ${this.status}`
      }))
    }
    return Effect.void
  }

  /**
   * Обновить заголовок.
   * Доступно только для не-архивированных задач.
   */
  readonly updateTitle = (
    newTitle: TodoTitle,
  ): Effect.Effect<Todo, TodoModificationForbidden> =>
    Effect.gen(this, function* () {
      yield* this.ensureModifiable()
      const now = yield* DateTime.now
      return new Todo({ ...this, title: newTitle, updatedAt: now })
    })

  /**
   * Обновить описание.
   * Доступно только для не-архивированных задач.
   */
  readonly updateDescription = (
    description: Option.Option<string>,
  ): Effect.Effect<Todo, TodoModificationForbidden> =>
    Effect.gen(this, function* () {
      yield* this.ensureModifiable()
      const now = yield* DateTime.now
      return new Todo({ ...this, description, updatedAt: now })
    })

  /**
   * Обновить приоритет.
   * Доступно только для pending задач.
   */
  readonly updatePriority = (
    newPriority: Priority,
  ): Effect.Effect<Todo, TodoModificationForbidden> =>
    Effect.gen(this, function* () {
      yield* this.ensurePending()
      const now = yield* DateTime.now
      return new Todo({ ...this, priority: newPriority, updatedAt: now })
    })

  /**
   * Установить/обновить дату выполнения.
   * Доступно только для pending задач.
   */
  readonly setDueDate = (
    dueDate: Option.Option<DueDate>,
  ): Effect.Effect<Todo, TodoModificationForbidden> =>
    Effect.gen(this, function* () {
      yield* this.ensurePending()
      const now = yield* DateTime.now
      return new Todo({ ...this, dueDate, updatedAt: now })
    })
}

Шаг 4: Schema с кросс-полевыми инвариантами

Для валидации данных из внешних источников (БД, HTTP) добавляем Schema.filter:

// domain/entities/Todo.ts (продолжение)

/**
 * Schema с валидацией кросс-полевых инвариантов.
 * Используется при decode данных из внешних источников.
 */
export const ValidatedTodo = Todo.pipe(
  Schema.filter((todo) => {
    const issues: Array<Schema.FilterIssue> = []

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

    // Инвариант: pending → completedAt отсутствует
    if (
      todo.status === TodoStatusValues.Pending &&
      Option.isSome(todo.completedAt)
    ) {
      issues.push({
        path: ["completedAt"],
        message: "Pending задача не может иметь completedAt"
      })
    }

    // Инвариант: archived → archivedAt обязателен
    if (
      todo.status === TodoStatusValues.Archived &&
      Option.isNone(todo.archivedAt)
    ) {
      issues.push({
        path: ["archivedAt"],
        message: "Архивированная задача должна иметь archivedAt"
      })
    }

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

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

    // Инвариант: archivedAt >= createdAt
    if (Option.isSome(todo.archivedAt)) {
      const archived = Option.getOrThrow(todo.archivedAt)
      if (DateTime.lessThan(archived, todo.createdAt)) {
        issues.push({
          path: ["archivedAt"],
          message: "archivedAt не может быть раньше createdAt"
        })
      }
    }

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

Шаг 5: Barrel file — публичный API домена

// domain/index.ts

// Value Objects
export { TodoId } from "./value-objects/TodoId"
export type { TodoId } from "./value-objects/TodoId"
export { TodoTitle } from "./value-objects/TodoTitle"
export type { TodoTitle } from "./value-objects/TodoTitle"
export { Priority, PriorityValues } from "./value-objects/Priority"
export type { Priority } from "./value-objects/Priority"
export { TodoStatus, TodoStatusValues } from "./value-objects/TodoStatus"
export type { TodoStatus } from "./value-objects/TodoStatus"
export { DueDate } from "./value-objects/DueDate"
export type { DueDate } from "./value-objects/DueDate"

// Entities
export { Todo, ValidatedTodo } from "./entities/Todo"

// Errors
export {
  TodoNotFound,
  AlreadyCompleted,
  AlreadyArchived,
  InvalidTransition,
  TodoModificationForbidden,
} from "./errors/TodoErrors"
export type { TodoError } from "./errors/TodoErrors"

Шаг 6: Примеры использования

Сценарий 1: Создание и завершение задачи

import { Effect, Option } from "effect"
import { Todo, TodoTitle, PriorityValues } from "./domain"

const scenario1 = Effect.gen(function* () {
  // Создание
  const todo = yield* Todo.create({
    title: TodoTitle.make("Написать статью про Entity"),
    priority: PriorityValues.High,
  })
  yield* Effect.log(`Создана задача: ${todo.id}`)

  // Обновление описания
  const withDesc = yield* todo.updateDescription(
    Option.some("Подробная статья с примерами кода")
  )

  // Завершение
  const completed = yield* withDesc.complete()
  yield* Effect.log(`Задача завершена: ${completed.isCompleted}`) // true

  // Попытка изменить завершённую задачу
  const result = yield* completed.updatePriority(PriorityValues.Low).pipe(
    Effect.either
  )
  // result = Left(TodoModificationForbidden)

  // Архивирование
  const archived = yield* completed.archive()
  yield* Effect.log(`Задача архивирована: ${archived.isArchived}`) // true

  return archived
})

Сценарий 2: Обработка ошибок

const scenario2 = (todoId: TodoId) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository

    const todo = yield* repo.findById(todoId)

    // Попытка завершить — может быть уже завершена
    const completed = yield* todo.complete().pipe(
      Effect.catchTag("AlreadyCompleted", (err) =>
        Effect.gen(function* () {
          yield* Effect.logWarning(`Задача ${err.todoId} уже завершена`)
          return todo // возвращаем как есть
        })
      ),
      Effect.catchTag("AlreadyArchived", (err) =>
        Effect.fail(new TodoModificationForbidden({
          todoId: err.todoId,
          reason: "Невозможно завершить архивированную задачу"
        }))
      )
    )

    yield* repo.save(completed)
    return completed
  })

Сценарий 3: Работа с коллекциями Entity

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

const getOverdueTodos = (todos: ReadonlyArray<Todo>) =>
  Effect.gen(function* () {
    const now = yield* DateTime.now

    return pipe(
      todos,
      ReadonlyArray.filter((todo) => todo.isOverdue(now)),
      ReadonlyArray.sort((a, b) => {
        const aDue = Option.getOrElse(a.dueDate, () => DateTime.unsafeMake(0))
        const bDue = Option.getOrElse(b.dueDate, () => DateTime.unsafeMake(0))
        return DateTime.Order(aDue, bDue)
      })
    )
  })

const getStats = (todos: ReadonlyArray<Todo>) => ({
  total: todos.length,
  pending: todos.filter((t) => t.isPending).length,
  completed: todos.filter((t) => t.isCompleted).length,
  archived: todos.filter((t) => t.isArchived).length,
  highPriority: todos.filter((t) => t.isHighPriority).length,
})

Шаг 7: Сериализация — граница с инфраструктурой

// В адаптере (НЕ в домене)
import { Schema } from "effect"
import { Todo, ValidatedTodo } from "../domain"

// Encode: Todo → JSON-ready объект (для записи в БД)
const encodeTodo = Schema.encode(Todo)

// Decode: неизвестные данные → валидный Todo (для чтения из БД)
const decodeTodo = Schema.decodeUnknown(ValidatedTodo)

// Использование в SQLite-адаптере
const saveTodo = (todo: Todo) =>
  Effect.gen(function* () {
    const encoded = yield* encodeTodo(todo)
    // encoded.id     → string (без бренда)
    // encoded.status → "pending" | "completed" | "archived"
    // encoded.completedAt → string | null
    yield* executeSql(
      "INSERT INTO todos VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
      [encoded.id, encoded.title, /* ... */]
    )
  })

const loadTodo = (id: string) =>
  Effect.gen(function* () {
    const row = yield* querySql("SELECT * FROM todos WHERE id = ?", [id])
    const todo = yield* decodeTodo(row)
    // todo — полноценный Todo с инвариантами и поведением
    return todo
  })

Чеклист: правильно ли реализована Entity?

  • Branded Type для идентификатора — предотвращает смешивание ID
  • Equal по id — два экземпляра с одинаковым id равны
  • Hash по id — для корректной работы с HashMap/HashSet
  • Фабричный метод create — единственный способ создания нового экземпляра
  • Все поля readonly — иммутабельность гарантирована
  • Поведение возвращает новый экземпляр — никакой мутации
  • Ошибки типизированы — E-канал Effect содержит доменные ошибки
  • Кросс-полевые инварианты — через Schema.filter для внешних данных
  • Нулевые зависимости — не импортирует инфраструктуру
  • Метки времени — createdAt, updatedAt, completedAt проставляются автоматически
  • State Machine — переходы проверяют допустимость

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

  1. Todo Entity — центральный объект домена с полным поведением
  2. Все Value Objects — TodoId, TodoTitle, Priority, TodoStatus — типобезопасны
  3. Инварианты гарантируются конструктивно (поведение) и защитно (Schema.filter)
  4. Жизненный цикл — Pending → Completed → Archived с чёткими правилами
  5. Ошибки — типизированные, в E-канале Effect, с человекочитаемыми сообщениями
  6. Сериализация — автоматическая через Schema.encode/decode на границах
  7. Entity живёт в домене — ноль зависимостей от инфраструктуры