Типобезопасный домен: Гексагональная архитектура на базе Effect Домен Todo: первая модель — Task, Status, Priority
Глава

Домен Todo: первая модель — Task, Status, Priority

Полная практическая реализация доменной модели Todo: Value Objects (TodoId, TodoTitle, TodoDescription, Priority, TodoStatus, DueDate), Todo Entity с командами (complete, archive, changePriority, rename, setDueDate) и запросами (isOverdue, isActive, canBeCompleted), доменные ошибки (TodoNotFound, InvalidStatusTransition, DuplicateTitle), доменные события (TodoCreated, TodoCompleted, TodoArchived, TodoRenamed, TodoPriorityChanged), доменные сервисы (фильтрация, сортировка, статистика, валидация уникальности), структура файлов, barrel file, тесты домена

Введение: от теории к практике

В предыдущих главах мы разобрали теорию доменного моделирования: что такое домен, зачем нужна чистота, какие типы существуют, как строить Ubiquitous Language. Теперь пришло время применить все знания и создать первую доменную модель нашего Todo-приложения.

Это будет рабочий код, который мы будем развивать на протяжении всего курса. Каждый последующий модуль будет добавлять к нему новые слои и возможности.

Анализ домена: что мы моделируем

Вернёмся к нашему диалогу с бизнес-экспертом и формализуем требования:

Бизнес-правила Todo

  1. Задача (Todo) — единица работы
  2. Задача имеет заголовок (1-255 символов, не пустой)
  3. Задача имеет приоритет: Low, Medium, High, Critical
  4. Задача имеет статус: Active, Completed, Archived
  5. Задача создаётся в статусе Active
  6. Активную задачу можно завершить (Active → Completed)
  7. Задачу можно архивировать (Active → Archived, Completed → Archived)
  8. Архивная задача — конечное состояние (из Archived нельзя перейти никуда)
  9. Завершённую задачу нельзя завершить повторно
  10. У задачи есть дата создания и опциональная дата завершения
  11. У задачи есть опциональное описание
  12. У задачи может быть срок выполнения (due date)
  13. Если срок истёк, а задача активна — она просрочена

Диаграмма состояний

                  ┌──────────────┐
                  │              │
    create()      │   Active     │
    ──────────►   │              │
                  └──────┬───────┘

              ┌──────────┼──────────┐
              │                     │
         complete()            archive()
              │                     │
              ▼                     │
       ┌──────────────┐            │
       │              │            │
       │  Completed   │            │
       │              │            │
       └──────┬───────┘            │
              │                    │
         archive()                 │
              │                    │
              ▼                    ▼
       ┌───────────────────────────┐
       │                           │
       │        Archived           │
       │     (конечное состояние)  │
       │                           │
       └───────────────────────────┘

Шаг 1: Value Objects

Начнём с самых базовых строительных блоков — Value Objects. Каждый Value Object инкапсулирует одно понятие домена с валидацией.

TodoId — уникальный идентификатор задачи

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

/**
 * TodoId — уникальный идентификатор задачи.
 * Branded type гарантирует, что обычная строка
 * не может быть использована как TodoId без валидации.
 */
export const TodoIdBrand = Schema.String.pipe(
  Schema.minLength(1, {
    message: () => "TodoId cannot be empty"
  }),
  Schema.brand("TodoId")
)

export type TodoId = Schema.Schema.Type<typeof TodoIdBrand>

/**
 * Создание TodoId из строки с валидацией.
 * Возвращает Effect с ошибкой, если строка пуста.
 */
export const makeTodoId = Schema.decodeUnknown(TodoIdBrand)

TodoTitle — заголовок задачи

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

/**
 * TodoTitle — заголовок задачи.
 * Бизнес-правила:
 * - Не может быть пустым
 * - Максимум 255 символов
 * - Автоматически обрезает пробелы по краям
 */
export const TodoTitleBrand = Schema.String.pipe(
  Schema.trimmed(),
  Schema.minLength(1, {
    message: () => "Заголовок задачи не может быть пустым"
  }),
  Schema.maxLength(255, {
    message: () => "Заголовок задачи не может превышать 255 символов"
  }),
  Schema.brand("TodoTitle")
)

export type TodoTitle = Schema.Schema.Type<typeof TodoTitleBrand>

export const makeTodoTitle = Schema.decodeUnknown(TodoTitleBrand)

TodoDescription — описание задачи

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

/**
 * TodoDescription — опциональное описание задачи.
 * Бизнес-правила:
 * - Максимум 10000 символов
 * - Автоматически обрезает пробелы по краям
 */
export const TodoDescriptionBrand = Schema.String.pipe(
  Schema.trimmed(),
  Schema.maxLength(10000, {
    message: () => "Описание задачи не может превышать 10000 символов"
  }),
  Schema.brand("TodoDescription")
)

export type TodoDescription = Schema.Schema.Type<typeof TodoDescriptionBrand>

export const makeTodoDescription = Schema.decodeUnknown(TodoDescriptionBrand)

Priority — приоритет задачи

// domain/value-objects/priority.ts
import { Schema, Order } from "effect"

/**
 * Priority — приоритет задачи.
 * Бизнес-значения: Low, Medium, High, Critical.
 */
export const Priority = Schema.Literal("Low", "Medium", "High", "Critical")
export type Priority = Schema.Schema.Type<typeof Priority>

/**
 * Числовой вес приоритета для сортировки и сравнения.
 * Чистая функция — не зависит от внешних ресурсов.
 */
const PRIORITY_WEIGHT: Record<Priority, number> = {
  Low: 0,
  Medium: 1,
  High: 2,
  Critical: 3,
} as const

/**
 * Order для сортировки приоритетов.
 * Low < Medium < High < Critical
 */
export const PriorityOrder: Order.Order<Priority> = Order.mapInput(
  Order.number,
  (p: Priority) => PRIORITY_WEIGHT[p]
)

/**
 * Проверка: является ли приоритет «высоким» (High или Critical).
 */
export const isHighPriority = (priority: Priority): boolean =>
  priority === "High" || priority === "Critical"

/**
 * Проверка: a выше по приоритету, чем b.
 */
export const isHigherThan = (a: Priority, b: Priority): boolean =>
  PRIORITY_WEIGHT[a] > PRIORITY_WEIGHT[b]

TodoStatus — статус задачи

// domain/value-objects/todo-status.ts
import { Schema, HashMap, HashSet } from "effect"

/**
 * TodoStatus — статус задачи.
 * Active → Completed → Archived
 * Active → Archived (прямое архивирование)
 */
export const TodoStatus = Schema.Literal("Active", "Completed", "Archived")
export type TodoStatus = Schema.Schema.Type<typeof TodoStatus>

/**
 * Допустимые переходы между статусами.
 * Определены как неизменяемая структура — это бизнес-правило.
 */
const VALID_TRANSITIONS: ReadonlyMap<TodoStatus, ReadonlySet<TodoStatus>> =
  new Map([
    ["Active", new Set<TodoStatus>(["Completed", "Archived"])],
    ["Completed", new Set<TodoStatus>(["Archived"])],
    ["Archived", new Set<TodoStatus>()],
  ])

/**
 * Проверяет, допустим ли переход из одного статуса в другой.
 * Чистая функция — бизнес-правило без побочных эффектов.
 */
export const canTransition = (
  from: TodoStatus,
  to: TodoStatus
): boolean => {
  const allowed = VALID_TRANSITIONS.get(from)
  return allowed !== undefined && allowed.has(to)
}

/**
 * Получить все допустимые переходы из текущего статуса.
 */
export const getValidTransitions = (
  from: TodoStatus
): ReadonlySet<TodoStatus> =>
  VALID_TRANSITIONS.get(from) ?? new Set()

/**
 * Является ли статус конечным (нет допустимых переходов).
 */
export const isTerminal = (status: TodoStatus): boolean =>
  getValidTransitions(status).size === 0

DueDate — срок выполнения

// domain/value-objects/due-date.ts
import { Schema, Option } from "effect"

/**
 * DueDate — срок выполнения задачи.
 * Опциональное значение (задача может не иметь срока).
 */
export const DueDate = Schema.DateFromSelf

export type DueDate = Schema.Schema.Type<typeof DueDate>

/**
 * Проверяет, просрочен ли срок.
 * Чистая функция: время передаётся как параметр.
 */
export const isPastDue = (dueDate: Date, now: Date): boolean =>
  now > dueDate

/**
 * Рассчитывает количество дней до/после срока.
 * Положительное число — дней осталось.
 * Отрицательное — дней просрочки.
 */
export const daysUntilDue = (dueDate: Date, now: Date): number => {
  const diffMs = dueDate.getTime() - now.getTime()
  return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
}

Шаг 2: Domain Errors

Определим все ошибки, которые может порождать наш домен.

// domain/errors/todo-errors.ts
import { Data } from "effect"

/**
 * Задача не найдена.
 * Возникает при попытке получить задачу по несуществующему ID.
 */
export class TodoNotFoundError extends Data.TaggedError(
  "TodoNotFoundError"
)<{
  readonly todoId: string
}> {
  get message() {
    return `Задача с id "${this.todoId}" не найдена`
  }
}

/**
 * Недопустимый переход статуса.
 * Возникает при попытке выполнить запрещённый переход
 * (например, Completed → Active).
 */
export class InvalidStatusTransitionError extends Data.TaggedError(
  "InvalidStatusTransitionError"
)<{
  readonly from: string
  readonly to: string
}> {
  get message() {
    return `Невозможно перевести задачу из "${this.from}" в "${this.to}"`
  }
}

/**
 * Дублирующийся заголовок.
 * Возникает при попытке создать задачу с заголовком,
 * который уже используется в данном контексте.
 */
export class DuplicateTitleError extends Data.TaggedError(
  "DuplicateTitleError"
)<{
  readonly title: string
}> {
  get message() {
    return `Задача с заголовком "${this.title}" уже существует`
  }
}

/**
 * Пустой заголовок.
 * Возникает при попытке создать/переименовать задачу с пустым заголовком.
 */
export class EmptyTitleError extends Data.TaggedError(
  "EmptyTitleError"
)<{}> {
  get message() {
    return "Заголовок задачи не может быть пустым"
  }
}

/**
 * Заголовок слишком длинный.
 */
export class TitleTooLongError extends Data.TaggedError(
  "TitleTooLongError"
)<{
  readonly title: string
  readonly maxLength: number
}> {
  get message() {
    return `Заголовок "${this.title.slice(0, 50)}..." превышает ${this.maxLength} символов`
  }
}

/**
 * Union всех доменных ошибок Todo.
 * Используется для типизации E-канала в Effect.
 */
export type TodoDomainError =
  | TodoNotFoundError
  | InvalidStatusTransitionError
  | DuplicateTitleError
  | EmptyTitleError
  | TitleTooLongError

Шаг 3: Todo Entity

Теперь собираем всё вместе — создаём Entity Todo с полным поведением.

// domain/entities/todo.ts
import { Schema, Effect, Option, pipe } from "effect"
import type { TodoId } from "../value-objects/todo-id.js"
import { TodoTitleBrand, type TodoTitle } from "../value-objects/todo-title.js"
import type { TodoDescription } from "../value-objects/todo-description.js"
import { type Priority, isHighPriority } from "../value-objects/priority.js"
import {
  type TodoStatus,
  canTransition,
  isTerminal,
} from "../value-objects/todo-status.js"
import { isPastDue, daysUntilDue } from "../value-objects/due-date.js"
import {
  InvalidStatusTransitionError,
} from "../errors/todo-errors.js"

// ─────────────────────────────────────────────
// Todo Entity — полная реализация
// ─────────────────────────────────────────────

/**
 * Todo — доменная сущность «Задача».
 *
 * Ключевые свойства:
 * - Идентичность определяется полем `id` (TodoId)
 * - Неизменяемость: каждая операция возвращает новый объект
 * - Самовалидация: Schema гарантирует корректность данных
 * - Инварианты: бизнес-правила защищены через методы
 *
 * Жизненный цикл:
 *   create → Active → complete → Completed → archive → Archived
 *                   → archive → Archived (напрямую)
 */
export class Todo extends Schema.Class<Todo>("Todo")({
  /** Уникальный идентификатор задачи */
  id: Schema.String.pipe(Schema.brand("TodoId")),

  /** Заголовок задачи (1-255 символов) */
  title: Schema.String.pipe(
    Schema.trimmed(),
    Schema.minLength(1),
    Schema.maxLength(255)
  ),

  /** Опциональное описание задачи */
  description: Schema.OptionFromNullOr(
    Schema.String.pipe(Schema.trimmed(), Schema.maxLength(10000))
  ),

  /** Текущий статус: Active | Completed | Archived */
  status: Schema.Literal("Active", "Completed", "Archived"),

  /** Приоритет: Low | Medium | High | Critical */
  priority: Schema.Literal("Low", "Medium", "High", "Critical"),

  /** Дата создания (устанавливается при создании, не меняется) */
  createdAt: Schema.DateFromSelf,

  /** Дата завершения (устанавливается при переходе в Completed) */
  completedAt: Schema.OptionFromNullOr(Schema.DateFromSelf),

  /** Срок выполнения (опциональный) */
  dueDate: Schema.OptionFromNullOr(Schema.DateFromSelf),
}) {

  // ─────────────────────────────────────────
  // Commands (операции, изменяющие состояние)
  // ─────────────────────────────────────────

  /**
   * Завершить задачу.
   *
   * Бизнес-правила:
   * - Только активная задача может быть завершена
   * - Устанавливается дата завершения
   *
   * @param now - текущее время (передаётся извне для чистоты)
   */
  complete(
    now: Date
  ): Effect.Effect<Todo, InvalidStatusTransitionError> {
    if (!canTransition(this.status, "Completed")) {
      return Effect.fail(
        new InvalidStatusTransitionError({
          from: this.status,
          to: "Completed",
        })
      )
    }

    return Effect.succeed(
      new Todo({
        ...this,
        status: "Completed",
        completedAt: now,
      })
    )
  }

  /**
   * Архивировать задачу.
   *
   * Бизнес-правила:
   * - Активная или завершённая задача может быть архивирована
   * - Архивная задача не может быть архивирована повторно
   */
  archive(): Effect.Effect<Todo, InvalidStatusTransitionError> {
    if (!canTransition(this.status, "Archived")) {
      return Effect.fail(
        new InvalidStatusTransitionError({
          from: this.status,
          to: "Archived",
        })
      )
    }

    return Effect.succeed(
      new Todo({
        ...this,
        status: "Archived",
      })
    )
  }

  /**
   * Изменить приоритет задачи.
   *
   * Бизнес-правила:
   * - Можно менять приоритет только активной задачи
   * - Завершённые и архивные задачи нельзя менять
   */
  changePriority(
    newPriority: Priority
  ): Effect.Effect<Todo, InvalidStatusTransitionError> {
    if (this.status !== "Active") {
      return Effect.fail(
        new InvalidStatusTransitionError({
          from: this.status,
          to: this.status, // Не переход статуса, а запрет модификации
        })
      )
    }

    return Effect.succeed(
      new Todo({
        ...this,
        priority: newPriority,
      })
    )
  }

  /**
   * Переименовать задачу.
   *
   * Бизнес-правила:
   * - Можно переименовать только активную задачу
   * - Новый заголовок не может быть пустым
   * - Новый заголовок не может превышать 255 символов
   */
  rename(
    newTitle: string
  ): Effect.Effect<Todo, InvalidStatusTransitionError> {
    if (this.status !== "Active") {
      return Effect.fail(
        new InvalidStatusTransitionError({
          from: this.status,
          to: this.status,
        })
      )
    }

    const trimmed = newTitle.trim()

    // Валидация через Schema будет при decode,
    // но базовую проверку делаем в методе
    return Effect.succeed(
      new Todo({
        ...this,
        title: trimmed,
      })
    )
  }

  /**
   * Установить срок выполнения.
   *
   * Бизнес-правила:
   * - Можно установить срок только для активной задачи
   */
  setDueDate(
    dueDate: Date | null
  ): Effect.Effect<Todo, InvalidStatusTransitionError> {
    if (this.status !== "Active") {
      return Effect.fail(
        new InvalidStatusTransitionError({
          from: this.status,
          to: this.status,
        })
      )
    }

    return Effect.succeed(
      new Todo({
        ...this,
        dueDate,
      })
    )
  }

  /**
   * Обновить описание задачи.
   *
   * Бизнес-правила:
   * - Можно менять описание только активной задачи
   */
  updateDescription(
    description: string | null
  ): Effect.Effect<Todo, InvalidStatusTransitionError> {
    if (this.status !== "Active") {
      return Effect.fail(
        new InvalidStatusTransitionError({
          from: this.status,
          to: this.status,
        })
      )
    }

    return Effect.succeed(
      new Todo({
        ...this,
        description: description?.trim() ?? null,
      })
    )
  }

  // ─────────────────────────────────────
  // Queries (запросы, не меняющие состояние)
  // ─────────────────────────────────────

  /**
   * Является ли задача активной.
   */
  get isActive(): boolean {
    return this.status === "Active"
  }

  /**
   * Является ли задача завершённой.
   */
  get isCompleted(): boolean {
    return this.status === "Completed"
  }

  /**
   * Является ли задача архивной.
   */
  get isArchived(): boolean {
    return this.status === "Archived"
  }

  /**
   * Просрочена ли задача.
   *
   * Задача просрочена, если:
   * 1. Она активна (Active)
   * 2. У неё установлен срок
   * 3. Текущее время превышает срок
   *
   * @param now - текущее время (передаётся для чистоты)
   */
  isOverdue(now: Date): boolean {
    return (
      this.status === "Active" &&
      Option.isSome(this.dueDate) &&
      isPastDue(this.dueDate.value, now)
    )
  }

  /**
   * Является ли задача высокоприоритетной (High или Critical).
   */
  get isHighPriority(): boolean {
    return isHighPriority(this.priority)
  }

  /**
   * Количество дней до/после срока.
   * Положительное — дней осталось. Отрицательное — дней просрочки.
   * None — если срок не установлен.
   */
  daysUntilDue(now: Date): Option.Option<number> {
    return pipe(
      this.dueDate,
      Option.map((due) => daysUntilDue(due, now))
    )
  }

  /**
   * Можно ли данную задачу завершить.
   */
  get canBeCompleted(): boolean {
    return canTransition(this.status, "Completed")
  }

  /**
   * Можно ли данную задачу архивировать.
   */
  get canBeArchived(): boolean {
    return canTransition(this.status, "Archived")
  }

  /**
   * Находится ли задача в конечном состоянии.
   */
  get isInTerminalState(): boolean {
    return isTerminal(this.status)
  }
}

Шаг 4: Фабричная функция создания Todo

Фабрика создания задачи — отдельная функция, поскольку создание может включать валидацию и генерацию данных.

// domain/entities/todo-factory.ts
import { Effect, Option } from "effect"
import { Todo } from "./todo.js"
import type { TodoId } from "../value-objects/todo-id.js"
import type { Priority } from "../value-objects/priority.js"

/**
 * Входные данные для создания задачи.
 * Это доменный тип, не DTO — нет инфраструктурных полей.
 */
export interface CreateTodoInput {
  readonly id: TodoId
  readonly title: string
  readonly priority: Priority
  readonly description?: string | null
  readonly dueDate?: Date | null
  readonly now: Date
}

/**
 * Создаёт новую задачу в статусе Active.
 *
 * Это фабричная функция — единственный правильный способ
 * создания нового Todo. Гарантирует начальные инварианты:
 * - Статус = Active
 * - completedAt = None
 * - createdAt = переданное время
 *
 * Валидация title (minLength, maxLength) выполняется
 * конструктором Schema.Class.
 */
export const createTodo = (
  input: CreateTodoInput
): Effect.Effect<Todo, Schema.ParseError> =>
  Effect.try({
    try: () =>
      new Todo({
        id: input.id,
        title: input.title.trim(),
        description: input.description?.trim() ?? null,
        status: "Active",
        priority: input.priority,
        createdAt: input.now,
        completedAt: null,
        dueDate: input.dueDate ?? null,
      }),
    catch: (error) => error as Schema.ParseError,
  })

Шаг 5: Domain Events

Определим события, которые генерирует наш домен.

// domain/events/todo-events.ts
import { Schema } from "effect"

/**
 * Задача создана.
 *
 * Генерируется при создании новой задачи.
 * Содержит все основные данные задачи на момент создания.
 */
export class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
  "TodoCreated",
  {
    todoId: Schema.String,
    title: Schema.String,
    priority: Schema.Literal("Low", "Medium", "High", "Critical"),
    occurredAt: Schema.DateFromSelf,
  }
) {}

/**
 * Задача завершена.
 *
 * Генерируется при переходе Active → Completed.
 */
export class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
  "TodoCompleted",
  {
    todoId: Schema.String,
    completedAt: Schema.DateFromSelf,
    occurredAt: Schema.DateFromSelf,
  }
) {}

/**
 * Задача архивирована.
 *
 * Генерируется при переходе в Archived.
 */
export class TodoArchived extends Schema.TaggedClass<TodoArchived>()(
  "TodoArchived",
  {
    todoId: Schema.String,
    previousStatus: Schema.Literal("Active", "Completed"),
    occurredAt: Schema.DateFromSelf,
  }
) {}

/**
 * Заголовок задачи изменён.
 */
export class TodoRenamed extends Schema.TaggedClass<TodoRenamed>()(
  "TodoRenamed",
  {
    todoId: Schema.String,
    oldTitle: Schema.String,
    newTitle: Schema.String,
    occurredAt: Schema.DateFromSelf,
  }
) {}

/**
 * Приоритет задачи изменён.
 */
export class TodoPriorityChanged extends Schema.TaggedClass<TodoPriorityChanged>()(
  "TodoPriorityChanged",
  {
    todoId: Schema.String,
    oldPriority: Schema.Literal("Low", "Medium", "High", "Critical"),
    newPriority: Schema.Literal("Low", "Medium", "High", "Critical"),
    occurredAt: Schema.DateFromSelf,
  }
) {}

/**
 * Union всех доменных событий Todo.
 */
export type TodoEvent =
  | TodoCreated
  | TodoCompleted
  | TodoArchived
  | TodoRenamed
  | TodoPriorityChanged

Шаг 6: Domain Services

Доменные сервисы — чистые функции, работающие с коллекциями Entity.

// domain/services/todo-query-service.ts
import { type Todo } from "../entities/todo.js"
import type { Priority } from "../value-objects/priority.js"
import type { TodoStatus } from "../value-objects/todo-status.js"
import { PriorityOrder } from "../value-objects/priority.js"
import { Order } from "effect"

// ─────────────────────────────────────────
// Фильтрация
// ─────────────────────────────────────────

/**
 * Фильтр задач по статусу.
 */
export const filterByStatus = (
  todos: ReadonlyArray<Todo>,
  status: TodoStatus
): ReadonlyArray<Todo> =>
  todos.filter((t) => t.status === status)

/**
 * Фильтр задач по приоритету.
 */
export const filterByPriority = (
  todos: ReadonlyArray<Todo>,
  priority: Priority
): ReadonlyArray<Todo> =>
  todos.filter((t) => t.priority === priority)

/**
 * Фильтр просроченных задач.
 */
export const filterOverdue = (
  todos: ReadonlyArray<Todo>,
  now: Date
): ReadonlyArray<Todo> =>
  todos.filter((t) => t.isOverdue(now))

/**
 * Фильтр активных высокоприоритетных задач.
 */
export const filterUrgent = (
  todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> =>
  todos.filter((t) => t.isActive && t.isHighPriority)

// ─────────────────────────────────────────
// Сортировка
// ─────────────────────────────────────────

/**
 * Сортировка задач по приоритету (от высшего к низшему).
 * Стабильная сортировка — порядок задач с одинаковым приоритетом сохраняется.
 */
export const sortByPriority = (
  todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> => {
  const reversed = Order.reverse(PriorityOrder)
  return [...todos].sort((a, b) =>
    reversed(a.priority, b.priority)
  )
}

/**
 * Сортировка по дате создания (новые первыми).
 */
export const sortByCreatedAt = (
  todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> =>
  [...todos].sort(
    (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
  )

/**
 * Сортировка по сроку выполнения (ближайшие первыми).
 * Задачи без срока — в конце.
 */
export const sortByDueDate = (
  todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> =>
  [...todos].sort((a, b) => {
    const aDue = a.dueDate._tag === "Some" ? a.dueDate.value.getTime() : Infinity
    const bDue = b.dueDate._tag === "Some" ? b.dueDate.value.getTime() : Infinity
    return aDue - bDue
  })

// ─────────────────────────────────────────
// Статистика
// ─────────────────────────────────────────

/**
 * Статистика по коллекции задач.
 * Чистая функция — вычисляет агрегированные данные.
 */
export interface TodoStats {
  readonly total: number
  readonly active: number
  readonly completed: number
  readonly archived: number
  readonly overdue: number
  readonly byPriority: Readonly<Record<Priority, number>>
  readonly completionRate: number // 0.0 - 1.0
}

export const calculateStats = (
  todos: ReadonlyArray<Todo>,
  now: Date
): TodoStats => {
  const active = todos.filter((t) => t.status === "Active").length
  const completed = todos.filter((t) => t.status === "Completed").length
  const archived = todos.filter((t) => t.status === "Archived").length
  const overdue = todos.filter((t) => t.isOverdue(now)).length
  const total = todos.length

  const nonArchived = active + completed
  const completionRate = nonArchived > 0 ? completed / nonArchived : 0

  return {
    total,
    active,
    completed,
    archived,
    overdue,
    byPriority: {
      Low: todos.filter((t) => t.priority === "Low").length,
      Medium: todos.filter((t) => t.priority === "Medium").length,
      High: todos.filter((t) => t.priority === "High").length,
      Critical: todos.filter((t) => t.priority === "Critical").length,
    },
    completionRate,
  }
}
// domain/services/todo-validation-service.ts
import { Effect } from "effect"
import type { Todo } from "../entities/todo.js"
import { DuplicateTitleError } from "../errors/todo-errors.js"

/**
 * Проверяет уникальность заголовка среди существующих задач.
 *
 * Чистая доменная функция — принимает данные как параметры,
 * не обращается к репозиторию/БД.
 */
export const checkTitleUniqueness = (
  existingTodos: ReadonlyArray<Todo>,
  newTitle: string
): Effect.Effect<void, DuplicateTitleError> => {
  const normalizedTitle = newTitle.trim().toLowerCase()
  const isDuplicate = existingTodos.some(
    (t) =>
      t.title.toLowerCase() === normalizedTitle &&
      t.status !== "Archived" // Архивные задачи не учитываются
  )

  return isDuplicate
    ? Effect.fail(new DuplicateTitleError({ title: newTitle }))
    : Effect.void
}

/**
 * Проверяет уникальность заголовка, исключая конкретную задачу.
 * Используется при переименовании — задача не должна конфликтовать сама с собой.
 */
export const checkTitleUniquenessExcluding = (
  existingTodos: ReadonlyArray<Todo>,
  newTitle: string,
  excludeTodoId: string
): Effect.Effect<void, DuplicateTitleError> => {
  const normalizedTitle = newTitle.trim().toLowerCase()
  const isDuplicate = existingTodos.some(
    (t) =>
      t.id !== excludeTodoId &&
      t.title.toLowerCase() === normalizedTitle &&
      t.status !== "Archived"
  )

  return isDuplicate
    ? Effect.fail(new DuplicateTitleError({ title: newTitle }))
    : Effect.void
}

Шаг 7: Структура файлов домена

src/
└── domain/
    ├── entities/
    │   ├── todo.ts              ← Todo Entity
    │   └── todo-factory.ts      ← Фабричная функция
    ├── value-objects/
    │   ├── todo-id.ts           ← TodoId
    │   ├── todo-title.ts        ← TodoTitle (branded)
    │   ├── todo-description.ts  ← TodoDescription (branded)
    │   ├── priority.ts          ← Priority + Order + helpers
    │   ├── todo-status.ts       ← TodoStatus + transitions
    │   └── due-date.ts          ← DueDate + helpers
    ├── errors/
    │   └── todo-errors.ts       ← Все доменные ошибки
    ├── events/
    │   └── todo-events.ts       ← Все доменные события
    ├── services/
    │   ├── todo-query-service.ts     ← Фильтрация, сортировка, статистика
    │   └── todo-validation-service.ts ← Проверки (уникальность и т.д.)
    └── index.ts                 ← Barrel file (публичный API домена)

Barrel file

// domain/index.ts — публичный API домена

// Entities
export { Todo } from "./entities/todo.js"
export { createTodo, type CreateTodoInput } from "./entities/todo-factory.js"

// Value Objects
export { TodoIdBrand, type TodoId, makeTodoId } from "./value-objects/todo-id.js"
export { TodoTitleBrand, type TodoTitle, makeTodoTitle } from "./value-objects/todo-title.js"
export { Priority, PriorityOrder, isHighPriority, isHigherThan } from "./value-objects/priority.js"
export { TodoStatus, canTransition, isTerminal } from "./value-objects/todo-status.js"

// Errors
export {
  TodoNotFoundError,
  InvalidStatusTransitionError,
  DuplicateTitleError,
  EmptyTitleError,
  TitleTooLongError,
  type TodoDomainError,
} from "./errors/todo-errors.js"

// Events
export {
  TodoCreated,
  TodoCompleted,
  TodoArchived,
  TodoRenamed,
  TodoPriorityChanged,
  type TodoEvent,
} from "./events/todo-events.js"

// Services
export {
  filterByStatus,
  filterByPriority,
  filterOverdue,
  filterUrgent,
  sortByPriority,
  sortByCreatedAt,
  sortByDueDate,
  calculateStats,
  type TodoStats,
} from "./services/todo-query-service.js"
export {
  checkTitleUniqueness,
  checkTitleUniquenessExcluding,
} from "./services/todo-validation-service.js"

Проверка чистоты: что мы имеем

Давайте убедимся, что наш домен чист:

ПроверкаРезультат
Импорты из bun:sqlite?❌ Нет
Импорты из @effect/platform?❌ Нет
Импорты HTTP-фреймворков?❌ Нет
process.env?❌ Нет
console.log?❌ Нет
new Date() внутри функций?❌ Нет (время — параметр)
fetch / сетевые вызовы?❌ Нет
R-канал = never?✅ Да, все функции чистые
Immutable данные?✅ Да, Schema.Class
Только effect в зависимостях?✅ Да

Результат: домен абсолютно чист. Его можно тестировать без инфраструктуры, переносить между платформами, рефакторить без риска.

Пример использования домена в тестах

// __tests__/domain/todo.test.ts
import { describe, it, expect } from "bun:test"
import { Effect, Option } from "effect"
import { Todo } from "../../src/domain/entities/todo.js"
import { createTodo } from "../../src/domain/entities/todo-factory.js"
import { calculateStats } from "../../src/domain/services/todo-query-service.js"

const NOW = new Date("2025-01-15T10:00:00Z")

const makeTodo = (overrides: Partial<Parameters<typeof Todo["make"]>[0]> = {}) =>
  new Todo({
    id: "test-1",
    title: "Test todo",
    description: null,
    status: "Active",
    priority: "Medium",
    createdAt: NOW,
    completedAt: null,
    dueDate: null,
    ...overrides,
  })

describe("Todo Entity", () => {
  describe("complete", () => {
    it("should complete an active todo", async () => {
      const todo = makeTodo()
      const result = await Effect.runPromise(todo.complete(NOW))
      
      expect(result.status).toBe("Completed")
      expect(Option.isSome(result.completedAt)).toBe(true)
    })

    it("should reject completing a completed todo", async () => {
      const todo = makeTodo({ status: "Completed", completedAt: NOW })
      const exit = await Effect.runPromiseExit(todo.complete(NOW))
      
      expect(exit._tag).toBe("Failure")
    })

    it("should reject completing an archived todo", async () => {
      const todo = makeTodo({ status: "Archived" })
      const exit = await Effect.runPromiseExit(todo.complete(NOW))
      
      expect(exit._tag).toBe("Failure")
    })
  })

  describe("archive", () => {
    it("should archive an active todo", async () => {
      const todo = makeTodo()
      const result = await Effect.runPromise(todo.archive())
      
      expect(result.status).toBe("Archived")
    })

    it("should archive a completed todo", async () => {
      const todo = makeTodo({ status: "Completed", completedAt: NOW })
      const result = await Effect.runPromise(todo.archive())
      
      expect(result.status).toBe("Archived")
    })

    it("should reject archiving an already archived todo", async () => {
      const todo = makeTodo({ status: "Archived" })
      const exit = await Effect.runPromiseExit(todo.archive())
      
      expect(exit._tag).toBe("Failure")
    })
  })

  describe("isOverdue", () => {
    it("should be overdue when active and past due date", () => {
      const todo = makeTodo({
        dueDate: new Date("2025-01-10T00:00:00Z"), // 5 дней назад
      })
      
      expect(todo.isOverdue(NOW)).toBe(true)
    })

    it("should not be overdue when completed", () => {
      const todo = makeTodo({
        status: "Completed",
        completedAt: NOW,
        dueDate: new Date("2025-01-10T00:00:00Z"),
      })
      
      expect(todo.isOverdue(NOW)).toBe(false)
    })

    it("should not be overdue without due date", () => {
      const todo = makeTodo()
      
      expect(todo.isOverdue(NOW)).toBe(false)
    })
  })
})

describe("TodoStats", () => {
  it("should calculate correct stats", () => {
    const todos = [
      makeTodo({ id: "1", status: "Active", priority: "High" }),
      makeTodo({ id: "2", status: "Active", priority: "Low" }),
      makeTodo({ id: "3", status: "Completed", priority: "Medium", completedAt: NOW }),
      makeTodo({ id: "4", status: "Archived", priority: "Critical" }),
    ]

    const stats = calculateStats(todos, NOW)

    expect(stats.total).toBe(4)
    expect(stats.active).toBe(2)
    expect(stats.completed).toBe(1)
    expect(stats.archived).toBe(1)
    expect(stats.completionRate).toBeCloseTo(1 / 3)
    expect(stats.byPriority.High).toBe(1)
  })
})

Обратите внимание: ни одного мока, ни одного стаба, ни одной базы данных. Чистый домен тестируется мгновенно.

Резюме

В этой главе мы создали первую рабочую доменную модель Todo-приложения:

  • 6 Value Objects: TodoId, TodoTitle, TodoDescription, Priority, TodoStatus, DueDate
  • 1 Entity: Todo с полным набором команд и запросов
  • 5 Domain Events: TodoCreated, TodoCompleted, TodoArchived, TodoRenamed, TodoPriorityChanged
  • 5 Domain Errors: TodoNotFound, InvalidStatusTransition, DuplicateTitle, EmptyTitle, TitleTooLong
  • 2 Domain Services: QueryService (фильтрация, сортировка, статистика) и ValidationService (уникальность)

Весь код — чистый TypeScript + Effect. Нулевые инфраструктурные зависимости. Мгновенная тестируемость. Полная типовая безопасность.

Эта модель будет развиваться в последующих модулях: мы добавим Aggregates (Модуль 15), более сложные Value Objects с Effect Schema (Модуль 11-12), и полноценные Entity с инвариантами (Модуль 13).