Типобезопасный домен: Гексагональная архитектура на базе Effect Ошибки Todo: TodoNotFound, InvalidTransition, DuplicateTitle
Глава

Ошибки Todo: TodoNotFound, InvalidTransition, DuplicateTitle

Полная production-ready модель ошибок для Todo-домена. 8 ошибок с контекстом, message, Schema. Статусная машина и валидация переходов. Бизнес-правила для заголовков, дат, приоритетов, лимитов. HTTP-маппинг. Полный пример создания задачи со всеми проверками. Unit-тесты для каждой ошибки.

Введение: ошибки как зеркало бизнес-правил

Каждая доменная ошибка отражает бизнес-правило, которое было нарушено. Если вы не можете объяснить ошибку на языке бизнеса — вероятно, это не доменная ошибка.

Для Todo-домена мы идентифицируем следующие бизнес-правила и соответствующие ошибки:

Бизнес-правилоОшибка
Задача должна существовать для операцийTodoNotFound
Переходы между состояниями ограниченыInvalidStatusTransition
Заголовок задачи уникален в спискеDuplicateTodoTitle
Заголовок не пуст и не слишком длинныйInvalidTodoTitle
Срок выполнения не в прошломInvalidDueDate
Список задач имеет лимитTodoListFull
Приоритет задачи в допустимом диапазонеInvalidPriority
Нельзя архивировать активную задачу с подзадачамиActiveSubtasksExist

Доменные типы (предпосылки)

Прежде чем определять ошибки, зафиксируем доменные типы, на которые ошибки ссылаются:

// domain/types.ts

import { Schema } from "effect"

// --- Branded IDs ---

export const TodoIdSchema = Schema.String.pipe(
  Schema.pattern(/^[a-zA-Z0-9-]{1,64}$/),
  Schema.brand("TodoId"),
)
export type TodoId = typeof TodoIdSchema.Type

export const TodoListIdSchema = Schema.String.pipe(
  Schema.pattern(/^[a-zA-Z0-9-]{1,64}$/),
  Schema.brand("TodoListId"),
)
export type TodoListId = typeof TodoListIdSchema.Type

// --- Enums ---

export const TodoStatusSchema = Schema.Literal(
  "Active",
  "Completed",
  "Archived",
)
export type TodoStatus = typeof TodoStatusSchema.Type

export const PrioritySchema = Schema.Literal(
  "Critical",
  "High",
  "Medium",
  "Low",
  "None",
)
export type Priority = typeof PrioritySchema.Type

Полная модель ошибок

1. TodoNotFound

Бизнес-правило: Для выполнения операции задача должна существовать в системе.

Когда возникает:

  • Попытка получить задачу по ID, которого нет
  • Попытка обновить/удалить несуществующую задачу
  • Ссылка на задачу из другого агрегата
// domain/errors/todo-errors.ts

import { Schema, Option } from "effect"
import { TodoIdSchema, type TodoId } from "../types"

export class TodoNotFound extends Schema.TaggedError<TodoNotFound>()(
  "TodoNotFound",
  {
    todoId: TodoIdSchema,
  }
) {
  get message(): string {
    return `Todo with id "${this.todoId}" was not found`
  }
}

Использование в бизнес-логике:

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

// В Entity/Aggregate — при поиске
export const findTodoOrFail = (
  todos: ReadonlyArray<Todo>,
  id: TodoId,
): Effect.Effect<Todo, TodoNotFound> => {
  const found = todos.find(t => t.id === id)
  return found !== undefined
    ? Effect.succeed(found)
    : Effect.fail(new TodoNotFound({ todoId: id }))
}

// В Repository (порт)
export interface TodoRepository {
  readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
  readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>
}

// В Use Case
export const getTodo = (id: TodoId) =>
  pipe(
    TodoRepository.pipe(
      Effect.flatMap(repo => repo.findById(id))
    ),
    // Можно обогатить контекстом
    Effect.tapError(() =>
      Effect.logWarning("Requested todo not found", { todoId: id })
    ),
  )

2. InvalidStatusTransition

Бизнес-правило: Задача имеет конечный автомат состояний. Не все переходы допустимы.

          ┌─────────────┐
          │   Active     │
          └──────┬───────┘

        ┌────────┼────────┐
        ▼        │        ▼
┌───────────┐    │   ┌──────────┐
│ Completed │    │   │ Archived │
└─────┬─────┘    │   └────┬─────┘
      │          │        │
      └──────────┼────────┘

            (обратно в Active)

Допустимые переходы:

  • Active → Completed
  • Active → Archived
  • Completed → Active ✅ (reopened)
  • Completed → Archived
  • Archived → Active ✅ (restored)
  • Completed → Completed
  • Archived → Completed
  • Archived → Archived
import { Schema } from "effect"
import { TodoIdSchema, TodoStatusSchema, type TodoId, type TodoStatus } from "../types"

export class InvalidStatusTransition extends Schema.TaggedError<InvalidStatusTransition>()(
  "InvalidStatusTransition",
  {
    todoId: TodoIdSchema,
    currentStatus: TodoStatusSchema,
    targetStatus: TodoStatusSchema,
    allowedTransitions: Schema.Array(TodoStatusSchema),
  }
) {
  get message(): string {
    const allowed = this.allowedTransitions.length > 0
      ? this.allowedTransitions.join(", ")
      : "none"
    return (
      `Cannot transition todo "${this.todoId}" ` +
      `from "${this.currentStatus}" to "${this.targetStatus}". ` +
      `Allowed transitions from "${this.currentStatus}": [${allowed}]`
    )
  }
}

Использование в бизнес-логике:

// domain/todo-status-machine.ts

import { Effect } from "effect"

// Статусная машина как ReadonlyMap (иммутабельна)
const STATUS_TRANSITIONS: ReadonlyMap<TodoStatus, ReadonlyArray<TodoStatus>> = new Map([
  ["Active", ["Completed", "Archived"] as const],
  ["Completed", ["Active", "Archived"] as const],
  ["Archived", ["Active"] as const],
] as const)

// Валидация перехода — чистая функция
export const validateTransition = (
  todoId: TodoId,
  currentStatus: TodoStatus,
  targetStatus: TodoStatus,
): Effect.Effect<TodoStatus, InvalidStatusTransition> => {
  const allowed = STATUS_TRANSITIONS.get(currentStatus) ?? []
  
  if (allowed.includes(targetStatus)) {
    return Effect.succeed(targetStatus)
  }
  
  return Effect.fail(new InvalidStatusTransition({
    todoId,
    currentStatus,
    targetStatus,
    allowedTransitions: allowed,
  }))
}

// Использование в Entity
export const completeTodo = (
  todo: Todo,
): Effect.Effect<Todo, InvalidStatusTransition> =>
  pipe(
    validateTransition(todo.id, todo.status, "Completed"),
    Effect.map(newStatus => ({
      ...todo,
      status: newStatus,
      completedAt: new Date(),
      updatedAt: new Date(),
    })),
  )

export const archiveTodo = (
  todo: Todo,
): Effect.Effect<Todo, InvalidStatusTransition> =>
  pipe(
    validateTransition(todo.id, todo.status, "Archived"),
    Effect.map(newStatus => ({
      ...todo,
      status: newStatus,
      archivedAt: new Date(),
      updatedAt: new Date(),
    })),
  )

export const reopenTodo = (
  todo: Todo,
): Effect.Effect<Todo, InvalidStatusTransition> =>
  pipe(
    validateTransition(todo.id, todo.status, "Active"),
    Effect.map(newStatus => ({
      ...todo,
      status: newStatus,
      completedAt: undefined,
      archivedAt: undefined,
      updatedAt: new Date(),
    })),
  )

3. DuplicateTodoTitle

Бизнес-правило: В пределах одного TodoList заголовки задач должны быть уникальными.

import { Schema, Option } from "effect"
import { TodoIdSchema } from "../types"

export class DuplicateTodoTitle extends Schema.TaggedError<DuplicateTodoTitle>()(
  "DuplicateTodoTitle",
  {
    title: Schema.String,
    existingTodoId: Schema.OptionFromNullOr(TodoIdSchema),
  }
) {
  get message(): string {
    return Option.match(this.existingTodoId, {
      onNone: () => `A todo with title "${this.title}" already exists`,
      onSome: (id) => `A todo with title "${this.title}" already exists (id: ${id})`,
    })
  }
}

Использование в бизнес-логике:

// domain/todo-list.ts (Aggregate)

export const checkTitleUniqueness = (
  todos: ReadonlyArray<Todo>,
  title: string,
  excludeId?: TodoId,
): Effect.Effect<void, DuplicateTodoTitle> => {
  const duplicate = todos.find(
    t => t.title.toLowerCase() === title.toLowerCase() && t.id !== excludeId
  )
  
  return duplicate !== undefined
    ? Effect.fail(new DuplicateTodoTitle({
        title,
        existingTodoId: Option.some(duplicate.id),
      }))
    : Effect.void
}

4. InvalidTodoTitle

Бизнес-правило: Заголовок задачи должен быть непустым и не превышать 200 символов.

export class InvalidTodoTitle extends Schema.TaggedError<InvalidTodoTitle>()(
  "InvalidTodoTitle",
  {
    title: Schema.String,
    reason: Schema.Literal("empty", "too_long", "invalid_characters"),
    maxLength: Schema.Number,
  }
) {
  get message(): string {
    switch (this.reason) {
      case "empty":
        return "Todo title cannot be empty"
      case "too_long":
        return `Todo title exceeds maximum length of ${this.maxLength} characters (got ${this.title.length})`
      case "invalid_characters":
        return `Todo title contains invalid characters`
    }
  }
}

Использование:

const MAX_TITLE_LENGTH = 200

export const validateTodoTitle = (
  title: string,
): Effect.Effect<string, InvalidTodoTitle> => {
  const trimmed = title.trim()
  
  if (trimmed.length === 0) {
    return Effect.fail(new InvalidTodoTitle({
      title,
      reason: "empty",
      maxLength: MAX_TITLE_LENGTH,
    }))
  }
  
  if (trimmed.length > MAX_TITLE_LENGTH) {
    return Effect.fail(new InvalidTodoTitle({
      title,
      reason: "too_long",
      maxLength: MAX_TITLE_LENGTH,
    }))
  }
  
  // Проверка на недопустимые символы (например, control characters)
  if (/[\x00-\x1f\x7f]/.test(trimmed)) {
    return Effect.fail(new InvalidTodoTitle({
      title,
      reason: "invalid_characters",
      maxLength: MAX_TITLE_LENGTH,
    }))
  }
  
  return Effect.succeed(trimmed)
}

5. InvalidDueDate

Бизнес-правило: Срок выполнения задачи не может быть в прошлом. Максимум — 5 лет вперёд.

export class InvalidDueDate extends Schema.TaggedError<InvalidDueDate>()(
  "InvalidDueDate",
  {
    date: Schema.DateFromString,
    reason: Schema.Literal("past_date", "too_far_future"),
  }
) {
  get message(): string {
    switch (this.reason) {
      case "past_date":
        return `Due date ${this.date.toISOString()} is in the past`
      case "too_far_future":
        return `Due date ${this.date.toISOString()} is more than 5 years in the future`
    }
  }
}

Использование:

const MAX_FUTURE_YEARS = 5

export const validateDueDate = (
  date: Date,
  now: Date = new Date(),
): Effect.Effect<Date, InvalidDueDate> => {
  if (date.getTime() < now.getTime()) {
    return Effect.fail(new InvalidDueDate({ date, reason: "past_date" }))
  }
  
  const maxFuture = new Date(now)
  maxFuture.setFullYear(maxFuture.getFullYear() + MAX_FUTURE_YEARS)
  
  if (date.getTime() > maxFuture.getTime()) {
    return Effect.fail(new InvalidDueDate({ date, reason: "too_far_future" }))
  }
  
  return Effect.succeed(date)
}

6. TodoListFull

Бизнес-правило: Список задач имеет лимит (по умолчанию 1000 задач).

export class TodoListFull extends Schema.TaggedError<TodoListFull>()(
  "TodoListFull",
  {
    listId: TodoListIdSchema,
    maxSize: Schema.Number,
    currentSize: Schema.Number,
  }
) {
  get message(): string {
    return (
      `Todo list "${this.listId}" is full. ` +
      `Maximum: ${this.maxSize}, current: ${this.currentSize}`
    )
  }
}

7. InvalidPriority

Бизнес-правило: Приоритет задачи должен быть из допустимого набора.

export class InvalidPriority extends Schema.TaggedError<InvalidPriority>()(
  "InvalidPriority",
  {
    value: Schema.String,
    allowedValues: Schema.Array(PrioritySchema),
  }
) {
  get message(): string {
    return `Invalid priority "${this.value}". Allowed: ${this.allowedValues.join(", ")}`
  }
}

8. ActiveSubtasksExist

Бизнес-правило: Нельзя архивировать задачу, у которой есть незавершённые подзадачи.

export class ActiveSubtasksExist extends Schema.TaggedError<ActiveSubtasksExist>()(
  "ActiveSubtasksExist",
  {
    todoId: TodoIdSchema,
    activeSubtaskIds: Schema.Array(TodoIdSchema),
  }
) {
  get message(): string {
    return (
      `Cannot archive todo "${this.todoId}": ` +
      `${this.activeSubtaskIds.length} active subtask(s) exist`
    )
  }
}

Union-тип и Schema для всех ошибок

// domain/errors/index.ts

export {
  TodoNotFound,
  InvalidStatusTransition,
  InvalidTodoTitle,
  DuplicateTodoTitle,
  InvalidDueDate,
  TodoListFull,
  InvalidPriority,
  ActiveSubtasksExist,
} from "./todo-errors"

import { Schema } from "effect"

// Union тип для TypeScript
export type TodoDomainError =
  | TodoNotFound
  | InvalidStatusTransition
  | InvalidTodoTitle
  | DuplicateTodoTitle
  | InvalidDueDate
  | TodoListFull
  | InvalidPriority
  | ActiveSubtasksExist

// Schema для сериализации
export const TodoDomainErrorSchema = Schema.Union(
  TodoNotFound,
  InvalidStatusTransition,
  InvalidTodoTitle,
  DuplicateTodoTitle,
  InvalidDueDate,
  TodoListFull,
  InvalidPriority,
  ActiveSubtasksExist,
)

Полный пример: создание задачи

Соберём все ошибки в одном бизнес-сценарии:

// domain/todo-list.ts (Aggregate)

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

const DEFAULT_MAX_SIZE = 1000

export const addTodo = (
  list: TodoList,
  title: string,
  dueDate: Date | undefined,
  priority: Priority,
): Effect.Effect<
  TodoList,
  | InvalidTodoTitle
  | DuplicateTodoTitle
  | TodoListFull
  | InvalidDueDate
  | InvalidPriority
> =>
  pipe(
    // 1. Валидация заголовка
    validateTodoTitle(title),
    
    // 2. Проверка уникальности
    Effect.flatMap(validTitle =>
      pipe(
        checkTitleUniqueness(list.todos, validTitle),
        Effect.map(() => validTitle),
      )
    ),
    
    // 3. Проверка лимита
    Effect.flatMap(validTitle => {
      if (list.todos.length >= (list.maxSize ?? DEFAULT_MAX_SIZE)) {
        return Effect.fail(new TodoListFull({
          listId: list.id,
          maxSize: list.maxSize ?? DEFAULT_MAX_SIZE,
          currentSize: list.todos.length,
        }))
      }
      return Effect.succeed(validTitle)
    }),
    
    // 4. Валидация даты (если указана)
    Effect.flatMap(validTitle =>
      dueDate !== undefined
        ? pipe(
            validateDueDate(dueDate),
            Effect.map(validDate => ({ title: validTitle, dueDate: validDate })),
          )
        : Effect.succeed({ title: validTitle, dueDate: undefined }),
    ),
    
    // 5. Создание задачи
    Effect.map(({ title: validTitle, dueDate: validDueDate }) => {
      const newTodo: Todo = {
        id: generateTodoId(),
        title: validTitle,
        status: "Active" as const,
        priority,
        dueDate: validDueDate,
        createdAt: new Date(),
        updatedAt: new Date(),
      }
      
      return {
        ...list,
        todos: [...list.todos, newTodo] as const,
        updatedAt: new Date(),
      }
    }),
  )

HTTP-маппинг ошибок

// infrastructure/adapters/http/error-mapper.ts

import { Effect } from "effect"

const TODO_ERROR_HTTP_MAP: Record<TodoDomainError["_tag"], number> = {
  TodoNotFound: 404,
  InvalidStatusTransition: 409,
  InvalidTodoTitle: 400,
  DuplicateTodoTitle: 409,
  InvalidDueDate: 400,
  TodoListFull: 422,
  InvalidPriority: 400,
  ActiveSubtasksExist: 409,
} as const

export const todoErrorToHttp = (error: TodoDomainError) =>
  Effect.succeed({
    status: TODO_ERROR_HTTP_MAP[error._tag],
    body: {
      error: error._tag,
      message: error.message,
      // Добавляем контекстные данные в зависимости от типа ошибки
      ...serializeErrorContext(error),
    },
  })

const serializeErrorContext = (error: TodoDomainError): Record<string, unknown> => {
  switch (error._tag) {
    case "TodoNotFound":
      return { todoId: error.todoId }
    case "InvalidStatusTransition":
      return {
        currentStatus: error.currentStatus,
        targetStatus: error.targetStatus,
        allowedTransitions: error.allowedTransitions,
      }
    case "InvalidTodoTitle":
      return { reason: error.reason, maxLength: error.maxLength }
    case "DuplicateTodoTitle":
      return { title: error.title }
    case "InvalidDueDate":
      return { reason: error.reason }
    case "TodoListFull":
      return { maxSize: error.maxSize, currentSize: error.currentSize }
    case "InvalidPriority":
      return { allowedValues: error.allowedValues }
    case "ActiveSubtasksExist":
      return { activeSubtaskCount: error.activeSubtaskIds.length }
  }
}

Тестирование ошибок

Unit-тесты для каждой ошибки

// domain/__tests__/errors.test.ts

import { describe, it, expect } from "bun:test"
import { Effect, Exit, Cause, Option, Schema, Equal } from "effect"

describe("TodoNotFound", () => {
  it("should create with todoId", () => {
    const error = new TodoNotFound({ todoId: "abc-123" as TodoId })
    expect(error._tag).toBe("TodoNotFound")
    expect(error.todoId).toBe("abc-123")
    expect(error.message).toContain("abc-123")
  })

  it("should support structural equality", () => {
    const e1 = new TodoNotFound({ todoId: "abc" as TodoId })
    const e2 = new TodoNotFound({ todoId: "abc" as TodoId })
    expect(Equal.equals(e1, e2)).toBe(true)
  })

  it("should encode/decode via Schema", () => {
    const error = new TodoNotFound({ todoId: "abc-123" as TodoId })
    const encoded = Schema.encodeSync(TodoNotFound)(error)
    expect(encoded._tag).toBe("TodoNotFound")
    expect(encoded.todoId).toBe("abc-123")
    
    const decoded = Schema.decodeSync(TodoNotFound)(encoded)
    expect(Equal.equals(decoded, error)).toBe(true)
  })
})

describe("InvalidStatusTransition", () => {
  it("should list allowed transitions in message", () => {
    const error = new InvalidStatusTransition({
      todoId: "abc" as TodoId,
      currentStatus: "Archived",
      targetStatus: "Completed",
      allowedTransitions: ["Active"],
    })
    expect(error.message).toContain("Active")
    expect(error.message).toContain("Archived")
    expect(error.message).toContain("Completed")
  })
})

describe("validateTransition", () => {
  it("should allow Active → Completed", async () => {
    const result = await Effect.runPromiseExit(
      validateTransition("abc" as TodoId, "Active", "Completed")
    )
    expect(Exit.isSuccess(result)).toBe(true)
  })

  it("should reject Archived → Completed", async () => {
    const result = await Effect.runPromiseExit(
      validateTransition("abc" as TodoId, "Archived", "Completed")
    )
    expect(Exit.isFailure(result)).toBe(true)
    if (Exit.isFailure(result)) {
      const error = Cause.failureOption(result.cause)
      expect(Option.isSome(error)).toBe(true)
      if (Option.isSome(error)) {
        expect(error.value._tag).toBe("InvalidStatusTransition")
        expect(error.value.allowedTransitions).toEqual(["Active"])
      }
    }
  })
})

describe("validateTodoTitle", () => {
  it("should reject empty title", async () => {
    const result = await Effect.runPromiseExit(validateTodoTitle(""))
    expect(Exit.isFailure(result)).toBe(true)
  })

  it("should reject too long title", async () => {
    const longTitle = "a".repeat(201)
    const result = await Effect.runPromiseExit(validateTodoTitle(longTitle))
    expect(Exit.isFailure(result)).toBe(true)
    if (Exit.isFailure(result)) {
      const error = Cause.failureOption(result.cause)
      if (Option.isSome(error)) {
        expect(error.value.reason).toBe("too_long")
      }
    }
  })

  it("should accept valid title", async () => {
    const result = await Effect.runPromiseExit(validateTodoTitle("Buy groceries"))
    expect(Exit.isSuccess(result)).toBe(true)
  })
})

Резюме: карта ошибок Todo-домена

TodoDomainError
├── TodoNotFound                    → 404 Not Found
│     todoId: TodoId

├── InvalidStatusTransition         → 409 Conflict
│     todoId, currentStatus, targetStatus, allowedTransitions

├── InvalidTodoTitle                → 400 Bad Request
│     title, reason, maxLength

├── DuplicateTodoTitle              → 409 Conflict
│     title, existingTodoId

├── InvalidDueDate                  → 400 Bad Request
│     date, reason

├── TodoListFull                    → 422 Unprocessable Entity
│     listId, maxSize, currentSize

├── InvalidPriority                 → 400 Bad Request
│     value, allowedValues

└── ActiveSubtasksExist             → 409 Conflict
      todoId, activeSubtaskIds

Каждая ошибка:

  • Имеет уникальный _tag для pattern matching
  • Содержит полный бизнес-контекст
  • Имеет человекочитаемый message
  • Поддерживает Schema encode/decode
  • Использует доменные типы (TodoId, TodoStatus, Priority)
  • Покрыта unit-тестами