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

TodoList Aggregate: список задач как единица согласованности

Полная production-ready реализация TodoList Aggregate для сквозного проекта: от бизнес-требований до barrel exports. 8 бизнес-правил, 8 операций, 8 событий, 0 инфраструктурных зависимостей. Проверка архитектурной чистоты: R=never, иммутабельность, типизированные ошибки.

Бизнес-требования

Начнём с того, что нужно бизнесу. Наше Todo-приложение имеет следующие требования:

Функциональные требования

  1. Пользователь может создавать списки задач (TodoList)
  2. Каждый список имеет название и владельца
  3. В список можно добавлять задачи с заголовком и приоритетом
  4. Задачу можно завершить, переименовать, изменить приоритет, удалить
  5. Список можно архивировать (после этого он становится read-only)
  6. Список можно переименовать

Бизнес-правила (инварианты)

#ПравилоКатегория
BR-1В списке не более 50 задачКоллекция
BR-2Заголовки задач уникальны внутри спискаКоллекция
BR-3Завершённую задачу нельзя завершить повторноСостояние
BR-4Архивированный список нельзя изменятьСостояние
BR-5Название списка — от 1 до 100 символовАтрибут
BR-6Заголовок задачи — от 1 до 200 символовАтрибут
BR-7Приоритет — low / medium / high / criticalАтрибут
BR-8При создании список пустНачальное состояние

Проектирование агрегата

Шаг 1: Определяем границы

Применяем эвристики из предыдущей статьи:

Инварианты: BR-1 и BR-2 затрагивают и TodoList, и TodoItem[] → одни агрегат.

Жизненный цикл: TodoItem не существует без TodoList → один жизненный цикл.

Размер коллекции: Максимум 50 элементов → приемлемый размер.

Конкурентность: Todo-приложение — индивидуальное использование, низкий параллелизм → допустимо.

Решение: TodoList — Aggregate Root, TodoItem — дочерняя Entity.

Шаг 2: Определяем состояния

TodoList States:
  ┌────────┐    archive()    ┌──────────┐
  │ Active │ ──────────────▶ │ Archived │
  └────────┘                 └──────────┘

       │ addItem / completeItem / renameItem / etc.

  ┌────────┐
  │ Active │ (обновлённая версия)
  └────────┘

TodoItem States:
  ┌────────┐    complete()   ┌───────────┐
  │ Active │ ──────────────▶ │ Completed │
  └────────┘                 └───────────┘

Шаг 3: Определяем события

Каждая значимая операция порождает событие:

ОперацияСобытие
Создание спискаTodoListCreated
Добавление задачиTodoItemAdded
Завершение задачиTodoItemCompleted
Удаление задачиTodoItemRemoved
Переименование задачиTodoItemRenamed
Изменение приоритетаTodoItemPriorityChanged
Переименование спискаTodoListRenamed
Архивация спискаTodoListArchived

Полная реализация

Файл: domain/todo-list/value-objects.ts

import { Schema } from "effect"

// ── Идентификаторы ──

export class TodoListId extends Schema.TaggedClass<TodoListId>()(
  "TodoListId",
  { value: Schema.String.pipe(Schema.brand("TodoListId")) }
) {}

export class TodoItemId extends Schema.TaggedClass<TodoItemId>()(
  "TodoItemId",
  { value: Schema.String.pipe(Schema.brand("TodoItemId")) }
) {}

// ── Доменные значения ──

export class ListName extends Schema.TaggedClass<ListName>()(
  "ListName",
  {
    value: Schema.String.pipe(
      Schema.trimmed(),
      Schema.minLength(1),
      Schema.maxLength(100),
      Schema.brand("ListName")
    )
  }
) {}

export class TodoTitle extends Schema.TaggedClass<TodoTitle>()(
  "TodoTitle",
  {
    value: Schema.String.pipe(
      Schema.trimmed(),
      Schema.minLength(1),
      Schema.maxLength(200),
      Schema.brand("TodoTitle")
    )
  }
) {}

export const Priority = Schema.Literal("low", "medium", "high", "critical")
export type Priority = typeof Priority.Type

export const TodoItemStatus = Schema.Literal("active", "completed")
export type TodoItemStatus = typeof TodoItemStatus.Type

export const TodoListStatus = Schema.Literal("active", "archived")
export type TodoListStatus = typeof TodoListStatus.Type

Файл: domain/todo-list/errors.ts

import { Schema } from "effect"

export class TodoListNotFound extends Schema.TaggedError<TodoListNotFound>()(
  "TodoListNotFound",
  { todoListId: Schema.String }
) {}

export class TodoListFull extends Schema.TaggedError<TodoListFull>()(
  "TodoListFull",
  {
    todoListId: Schema.String,
    maxSize: Schema.Number,
    currentSize: Schema.Number
  }
) {}

export class DuplicateTitle extends Schema.TaggedError<DuplicateTitle>()(
  "DuplicateTitle",
  {
    todoListId: Schema.String,
    title: Schema.String
  }
) {}

export class TodoNotInList extends Schema.TaggedError<TodoNotInList>()(
  "TodoNotInList",
  {
    todoListId: Schema.String,
    todoItemId: Schema.String
  }
) {}

export class TodoAlreadyCompleted extends Schema.TaggedError<TodoAlreadyCompleted>()(
  "TodoAlreadyCompleted",
  { todoItemId: Schema.String }
) {}

export class ListArchived extends Schema.TaggedError<ListArchived>()(
  "ListArchived",
  { todoListId: Schema.String }
) {}

// ── Тип-объединение всех ошибок ──

export type TodoListDomainError =
  | TodoListNotFound
  | TodoListFull
  | DuplicateTitle
  | TodoNotInList
  | TodoAlreadyCompleted
  | ListArchived

Файл: domain/todo-list/events.ts

import { Schema } from "effect"
import { Priority } from "./value-objects.js"

// ── Общие поля для всех событий ──

const BaseEvent = {
  occurredAt: Schema.DateFromSelf,
  aggregateId: Schema.String,
  version: Schema.Number
} as const

// ── Конкретные события ──

export class TodoListCreated extends Schema.TaggedClass<TodoListCreated>()(
  "TodoListCreated",
  { ...BaseEvent, name: Schema.String, ownerId: Schema.String, maxItems: Schema.Number }
) {}

export class TodoItemAdded extends Schema.TaggedClass<TodoItemAdded>()(
  "TodoItemAdded",
  { ...BaseEvent, todoItemId: Schema.String, title: Schema.String, priority: Priority }
) {}

export class TodoItemCompleted extends Schema.TaggedClass<TodoItemCompleted>()(
  "TodoItemCompleted",
  { ...BaseEvent, todoItemId: Schema.String, completedAt: Schema.DateFromSelf }
) {}

export class TodoItemRemoved extends Schema.TaggedClass<TodoItemRemoved>()(
  "TodoItemRemoved",
  { ...BaseEvent, todoItemId: Schema.String, title: Schema.String }
) {}

export class TodoItemRenamed extends Schema.TaggedClass<TodoItemRenamed>()(
  "TodoItemRenamed",
  { ...BaseEvent, todoItemId: Schema.String, oldTitle: Schema.String, newTitle: Schema.String }
) {}

export class TodoItemPriorityChanged extends Schema.TaggedClass<TodoItemPriorityChanged>()(
  "TodoItemPriorityChanged",
  { ...BaseEvent, todoItemId: Schema.String, oldPriority: Priority, newPriority: Priority }
) {}

export class TodoListRenamed extends Schema.TaggedClass<TodoListRenamed>()(
  "TodoListRenamed",
  { ...BaseEvent, oldName: Schema.String, newName: Schema.String }
) {}

export class TodoListArchivedEvent extends Schema.TaggedClass<TodoListArchivedEvent>()(
  "TodoListArchived",
  { ...BaseEvent, itemCount: Schema.Number, activeItemCount: Schema.Number }
) {}

// ── Union всех событий ──

export type TodoListEvent =
  | TodoListCreated
  | TodoItemAdded
  | TodoItemCompleted
  | TodoItemRemoved
  | TodoItemRenamed
  | TodoItemPriorityChanged
  | TodoListRenamed
  | TodoListArchivedEvent

Файл: domain/todo-list/todo-item.ts

import { Schema, Option } from "effect"
import { TodoItemId, TodoTitle, Priority, TodoItemStatus } from "./value-objects.js"

export class TodoItem extends Schema.Class<TodoItem>("TodoItem")({
  id: TodoItemId,
  title: TodoTitle,
  priority: Priority,
  status: TodoItemStatus,
  createdAt: Schema.DateFromSelf,
  completedAt: Schema.OptionFromSelf(Schema.DateFromSelf)
}) {
  get isActive(): boolean {
    return this.status === "active"
  }

  get isCompleted(): boolean {
    return this.status === "completed"
  }

  get isCritical(): boolean {
    return this.priority === "critical"
  }

  get isHighPriority(): boolean {
    return this.priority === "high" || this.priority === "critical"
  }
}

Файл: domain/todo-list/todo-list.ts

import { Effect, Schema, Option, ReadonlyArray } from "effect"
import {
  TodoListId, TodoItemId, ListName, TodoTitle,
  Priority, TodoListStatus
} from "./value-objects.js"
import { TodoItem } from "./todo-item.js"
import {
  TodoListFull, DuplicateTitle, TodoNotInList,
  TodoAlreadyCompleted, ListArchived
} from "./errors.js"
import {
  TodoListCreated, TodoItemAdded, TodoItemCompleted,
  TodoItemRemoved, TodoItemRenamed, TodoItemPriorityChanged,
  TodoListRenamed, TodoListArchivedEvent,
  type TodoListEvent
} from "./events.js"

// ── Результат мутации ──

export interface MutationResult {
  readonly list: TodoList
  readonly events: ReadonlyArray<TodoListEvent>
}

// ── Aggregate Root ──

export class TodoList extends Schema.Class<TodoList>("TodoList")({
  id: TodoListId,
  name: ListName,
  status: TodoListStatus,
  items: Schema.Array(TodoItem),
  maxItems: Schema.Number.pipe(Schema.int(), Schema.positive()),
  ownerId: Schema.String.pipe(Schema.brand("UserId")),
  createdAt: Schema.DateFromSelf,
  updatedAt: Schema.DateFromSelf,
  version: Schema.Number.pipe(Schema.int(), Schema.nonNegative())
}) {

  // ── Guards ──

  private ensureActive(): Effect.Effect<void, ListArchived> {
    return this.status === "archived"
      ? new ListArchived({ todoListId: this.id.value })
      : Effect.void
  }

  private ensureNotFull(): Effect.Effect<void, TodoListFull> {
    return this.items.length >= this.maxItems
      ? new TodoListFull({
          todoListId: this.id.value,
          maxSize: this.maxItems,
          currentSize: this.items.length
        })
      : Effect.void
  }

  private ensureUniqueTitle(
    title: TodoTitle,
    excludeIndex?: number
  ): Effect.Effect<void, DuplicateTitle> {
    const isDuplicate = this.items.some(
      (item, i) => i !== excludeIndex && item.title.value === title.value
    )
    return isDuplicate
      ? new DuplicateTitle({
          todoListId: this.id.value,
          title: title.value
        })
      : Effect.void
  }

  private findItemAt(
    itemId: TodoItemId
  ): Effect.Effect<
    { readonly item: TodoItem; readonly index: number },
    TodoNotInList
  > {
    const index = this.items.findIndex(
      (item) => item.id.value === itemId.value
    )
    return index === -1
      ? new TodoNotInList({
          todoListId: this.id.value,
          todoItemId: itemId.value
        })
      : Effect.succeed({ item: this.items[index]!, index } as const)
  }

  // ── Helpers ──

  private next(
    fields: Partial<{
      readonly name: ListName
      readonly status: TodoListStatus
      readonly items: ReadonlyArray<TodoItem>
    }>,
    now: Date
  ): TodoList {
    return new TodoList({
      ...this,
      ...fields,
      updatedAt: now,
      version: this.version + 1
    })
  }

  private meta(now: Date) {
    return {
      occurredAt: now,
      aggregateId: this.id.value,
      version: this.version + 1
    } as const
  }

  // ── Factory ──

  static create(params: {
    readonly id: TodoListId
    readonly name: ListName
    readonly ownerId: string & { readonly UserId: unique symbol }
    readonly maxItems?: number
    readonly now: Date
  }): Effect.Effect<MutationResult> {
    const maxItems = params.maxItems ?? 50
    const list = new TodoList({
      id: params.id,
      name: params.name,
      status: "active",
      items: [],
      maxItems,
      ownerId: params.ownerId,
      createdAt: params.now,
      updatedAt: params.now,
      version: 0
    })

    return Effect.succeed({
      list,
      events: [
        new TodoListCreated({
          occurredAt: params.now,
          aggregateId: params.id.value,
          version: 0,
          name: params.name.value,
          ownerId: params.ownerId,
          maxItems
        })
      ]
    })
  }

  // ── Commands ──

  addItem(params: {
    readonly itemId: TodoItemId
    readonly title: TodoTitle
    readonly priority: Priority
    readonly now: Date
  }): Effect.Effect<MutationResult, TodoListFull | DuplicateTitle | ListArchived> {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()
      yield* this.ensureNotFull()
      yield* this.ensureUniqueTitle(params.title)

      const newItem = new TodoItem({
        id: params.itemId,
        title: params.title,
        priority: params.priority,
        status: "active",
        createdAt: params.now,
        completedAt: Option.none()
      })

      return {
        list: this.next({ items: [...this.items, newItem] }, params.now),
        events: [
          new TodoItemAdded({
            ...this.meta(params.now),
            todoItemId: params.itemId.value,
            title: params.title.value,
            priority: params.priority
          })
        ]
      }
    })
  }

  completeItem(params: {
    readonly itemId: TodoItemId
    readonly now: Date
  }): Effect.Effect<MutationResult, TodoNotInList | TodoAlreadyCompleted | ListArchived> {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()
      const { item, index } = yield* this.findItemAt(params.itemId)

      if (item.isCompleted) {
        return yield* new TodoAlreadyCompleted({ todoItemId: params.itemId.value })
      }

      const completed = new TodoItem({
        ...item,
        status: "completed",
        completedAt: Option.some(params.now)
      })

      return {
        list: this.next({
          items: ReadonlyArray.modify(this.items, index, () => completed)
        }, params.now),
        events: [
          new TodoItemCompleted({
            ...this.meta(params.now),
            todoItemId: params.itemId.value,
            completedAt: params.now
          })
        ]
      }
    })
  }

  removeItem(params: {
    readonly itemId: TodoItemId
    readonly now: Date
  }): Effect.Effect<MutationResult, TodoNotInList | ListArchived> {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()
      const { item } = yield* this.findItemAt(params.itemId)

      return {
        list: this.next({
          items: this.items.filter((i) => i.id.value !== params.itemId.value)
        }, params.now),
        events: [
          new TodoItemRemoved({
            ...this.meta(params.now),
            todoItemId: params.itemId.value,
            title: item.title.value
          })
        ]
      }
    })
  }

  renameItem(params: {
    readonly itemId: TodoItemId
    readonly newTitle: TodoTitle
    readonly now: Date
  }): Effect.Effect<MutationResult, TodoNotInList | DuplicateTitle | ListArchived> {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()
      const { item, index } = yield* this.findItemAt(params.itemId)
      yield* this.ensureUniqueTitle(params.newTitle, index)

      const renamed = new TodoItem({ ...item, title: params.newTitle })

      return {
        list: this.next({
          items: ReadonlyArray.modify(this.items, index, () => renamed)
        }, params.now),
        events: [
          new TodoItemRenamed({
            ...this.meta(params.now),
            todoItemId: params.itemId.value,
            oldTitle: item.title.value,
            newTitle: params.newTitle.value
          })
        ]
      }
    })
  }

  changePriority(params: {
    readonly itemId: TodoItemId
    readonly newPriority: Priority
    readonly now: Date
  }): Effect.Effect<MutationResult, TodoNotInList | ListArchived> {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()
      const { item, index } = yield* this.findItemAt(params.itemId)

      const updated = new TodoItem({ ...item, priority: params.newPriority })

      return {
        list: this.next({
          items: ReadonlyArray.modify(this.items, index, () => updated)
        }, params.now),
        events: [
          new TodoItemPriorityChanged({
            ...this.meta(params.now),
            todoItemId: params.itemId.value,
            oldPriority: item.priority,
            newPriority: params.newPriority
          })
        ]
      }
    })
  }

  rename(params: {
    readonly newName: ListName
    readonly now: Date
  }): Effect.Effect<MutationResult, ListArchived> {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()

      return {
        list: this.next({ name: params.newName }, params.now),
        events: [
          new TodoListRenamed({
            ...this.meta(params.now),
            oldName: this.name.value,
            newName: params.newName.value
          })
        ]
      }
    })
  }

  archive(now: Date): Effect.Effect<MutationResult, ListArchived> {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()

      return {
        list: this.next({ status: "archived" }, now),
        events: [
          new TodoListArchivedEvent({
            ...this.meta(now),
            itemCount: this.items.length,
            activeItemCount: this.activeItemCount
          })
        ]
      }
    })
  }

  // ── Queries ──

  get activeItems(): ReadonlyArray<TodoItem> {
    return this.items.filter((i) => i.isActive)
  }

  get completedItems(): ReadonlyArray<TodoItem> {
    return this.items.filter((i) => i.isCompleted)
  }

  get activeItemCount(): number {
    return this.activeItems.length
  }

  get completedItemCount(): number {
    return this.completedItems.length
  }

  get isEmpty(): boolean {
    return this.items.length === 0
  }

  get isFull(): boolean {
    return this.items.length >= this.maxItems
  }

  get isArchived(): boolean {
    return this.status === "archived"
  }

  get completionPercentage(): number {
    return this.items.length === 0
      ? 0
      : Math.round((this.completedItemCount / this.items.length) * 100)
  }

  get criticalItems(): ReadonlyArray<TodoItem> {
    return this.items.filter((i) => i.isCritical && i.isActive)
  }

  get highPriorityItems(): ReadonlyArray<TodoItem> {
    return this.items.filter((i) => i.isHighPriority && i.isActive)
  }

  findItem(itemId: TodoItemId): Option.Option<TodoItem> {
    return Option.fromNullable(
      this.items.find((i) => i.id.value === itemId.value)
    )
  }

  hasItemWithTitle(title: TodoTitle): boolean {
    return this.items.some((i) => i.title.value === title.value)
  }
}

Файл: domain/todo-list/index.ts (barrel export)

// ── Value Objects ──
export {
  TodoListId, TodoItemId, ListName, TodoTitle,
  Priority, TodoItemStatus, TodoListStatus
} from "./value-objects.js"

// ── Entities ──
export { TodoItem } from "./todo-item.js"

// ── Aggregate ──
export { TodoList, type MutationResult } from "./todo-list.js"

// ── Errors ──
export {
  TodoListNotFound, TodoListFull, DuplicateTitle,
  TodoNotInList, TodoAlreadyCompleted, ListArchived,
  type TodoListDomainError
} from "./errors.js"

// ── Events ──
export {
  TodoListCreated, TodoItemAdded, TodoItemCompleted,
  TodoItemRemoved, TodoItemRenamed, TodoItemPriorityChanged,
  TodoListRenamed, TodoListArchivedEvent,
  type TodoListEvent
} from "./events.js"

Проверка архитектурной чистоты

Убедимся, что наш агрегат соблюдает все принципы:

✅ Нулевые зависимости от инфраструктуры

// Все импорты — только effect и локальные файлы домена
import { Effect, Schema, Option, ReadonlyArray } from "effect"
import { /* ... */ } from "./value-objects.js"
import { /* ... */ } from "./errors.js"
import { /* ... */ } from "./events.js"

// Нет импортов:
// ❌ sqlite, http, fs, config, logger
// ❌ express, fastify, prisma, drizzle

✅ R-канал = never

// Все методы агрегата: Effect<Result, Error, never>
// Нет внешних зависимостей в R-канале
addItem(/*...*/): Effect.Effect<MutationResult, TodoListFull | DuplicateTitle | ListArchived>
//                                                                          R = never (опущен)

✅ Иммутабельность

// Каждый метод возвращает НОВЫЙ объект
return { list: this.next(/* ... */), events: [/* ... */] }
// this никогда не мутируется

✅ Типизированные ошибки

// Каждое нарушение инварианта — конкретный тип ошибки
// Компилятор заставит обработать каждую
Effect.catchTag(result, "TodoListFull", (e) => /* ... */)
Effect.catchTag(result, "DuplicateTitle", (e) => /* ... */)

Резюме

В этой статье мы создали полноценный TodoList Aggregate:

  1. 5 файлов — чёткое разделение Value Objects, Errors, Events, Child Entity, Aggregate Root
  2. 8 бизнес-правил — каждое реализовано и защищено
  3. 8 операций — create, addItem, completeItem, removeItem, renameItem, changePriority, rename, archive
  4. 8 событий — каждая операция порождает соответствующее событие
  5. 0 инфраструктурных зависимостей — чистый домен

Этот агрегат будет использоваться на протяжении всего курса — от Repository до HTTP-адаптеров и тестов. Его границы, инварианты и поведение полностью определены, и любое нарушение невозможно обойти благодаря типовой системе Effect-ts.