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

Ошибки — часть домена, а не инфраструктуры

Философский фундамент доменных ошибок. Три категории ошибок (Domain, Application, Infrastructure). Dependency Rule для ошибок. Expected vs Defect. Почему Effect-ts — идеальный инструмент. Принципы проектирования и антипаттерны.

Введение: ошибка — это не исключение

В классическом объектно-ориентированном программировании ошибки традиционно воспринимаются как нечто «исключительное» — отсюда и название Exception. Программа работает «нормально», а когда что-то идёт не так, она «выбрасывает исключение». Такое мышление порождает целый класс архитектурных проблем.

Рассмотрим простой пример. В системе управления задачами (Todo App) пользователь пытается завершить задачу, которая уже завершена. Является ли это «исключением»? Нет. Это абсолютно предсказуемый сценарий, описанный в бизнес-правилах. Задача имеет конечный автомат состояний, и переход из Completed в Completed невозможен по определению. Это не ошибка программы — это ошибка домена.

Когда мы относимся к таким ситуациям как к «исключениям» и бросаем throw new Error("Task already completed"), мы теряем критически важную информацию:

  • Тип ошибки — компилятор не знает, какие ошибки может вернуть функция
  • Полнота обработки — нет гарантии, что все ошибки обработаны
  • Контекст — строка ошибки не несёт структурированных данных
  • Композиция — ошибки невозможно типобезопасно комбинировать
// ❌ Антипаттерн: ошибки как строки-исключения
function completeTodo(id: string): Todo {
  const todo = findTodo(id)
  if (!todo) throw new Error("Todo not found")
  if (todo.status === "completed") throw new Error("Already completed")
  if (todo.status === "archived") throw new Error("Cannot complete archived todo")
  return { ...todo, status: "completed" }
}

В этом коде три разных доменных ошибки замаскированы под одну конструкцию throw new Error. Вызывающий код не имеет ни малейшего представления о том, какие ошибки возможны — ни на уровне типов, ни на уровне документации.


Философский фундамент: ошибки как факты домена

Ошибка — это бизнес-событие

Доменная ошибка — это не сбой программы. Это факт, который произошёл в контексте бизнес-процесса. «Задача не найдена» — это факт. «Невозможный переход состояния» — это факт. «Дублирующийся заголовок» — это факт. Каждый из этих фактов имеет:

  1. Имя — однозначно описывающее ситуацию на языке домена
  2. Контекст — данные, необходимые для понимания произошедшего
  3. Значение для бизнеса — каждая ошибка требует определённой реакции

Когда бизнес-аналитик говорит: «Если пользователь пытается завершить уже архивированную задачу, система должна уведомить его, что задача архивирована и предложить её восстановить» — он описывает бизнес-правило, включающее доменную ошибку. Эта ошибка — такой же элемент домена, как Entity или Value Object.

Ubiquitous Language для ошибок

Принцип Ubiquitous Language из DDD (Domain-Driven Design) в полной мере распространяется на ошибки. Ошибка TodoNotFound — это термин, понятный и разработчику, и бизнес-аналитику, и тестировщику. Ошибка Error: ENOENT: no such file or directory — это термин инфраструктуры, не имеющий отношения к домену.

// ✅ Доменный язык ошибок
// Каждое имя — термин из Ubiquitous Language
type DomainError =
  | TodoNotFound           // «Задача не найдена»
  | InvalidStatusTransition // «Недопустимый переход состояния»
  | DuplicateTodoTitle     // «Задача с таким заголовком уже существует»
  | TodoListFull           // «Список задач заполнен»
  | PastDueDate            // «Дата выполнения в прошлом»

Три категории ошибок

В любой программной системе ошибки можно разделить на три фундаментальные категории. Понимание этих категорий критически важно для правильного проектирования архитектуры.

1. Доменные ошибки (Domain Errors)

Определение: Ошибки, описанные в бизнес-правилах. Они существуют независимо от технологического стека.

Характеристики:

  • Являются частью Ubiquitous Language
  • Описывают нарушение бизнес-правил или инвариантов
  • Не зависят от технологий (существовали бы и в бумажном процессе)
  • Всегда ожидаемы — система должна корректно на них реагировать
  • Содержат контекстные данные домена

Примеры для Todo App:

  • TodoNotFound — задача с указанным идентификатором не существует
  • InvalidStatusTransition — попытка перевести задачу в недопустимое состояние
  • DuplicateTodoTitle — задача с таким заголовком уже есть в списке
  • TodoListFull — достигнут лимит задач в списке
  • InvalidTodoTitle — заголовок задачи не соответствует бизнес-правилам (пустой, слишком длинный)
  • PastDueDate — попытка установить срок выполнения в прошлом
import { Data } from "effect"

// Доменная ошибка: содержит бизнес-контекст
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly todoId: TodoId
}> {}

// Доменная ошибка: описывает конкретный невозможный переход
class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{
  readonly todoId: TodoId
  readonly currentStatus: TodoStatus
  readonly targetStatus: TodoStatus
}> {}

2. Ошибки приложения (Application Errors)

Определение: Ошибки, возникающие на уровне координации бизнес-процессов, не принадлежащие ни домену, ни инфраструктуре.

Характеристики:

  • Связаны с оркестрацией, авторизацией, валидацией входных данных
  • Могут оборачивать или трансформировать доменные ошибки
  • Зависят от архитектурного решения, но не от конкретной технологии

Примеры:

  • UnauthorizedAccess — пользователь не имеет прав на операцию
  • ValidationError — входные данные не прошли валидацию (до попадания в домен)
  • ConcurrencyConflict — конфликт параллельного обновления
  • UseCaseError — обобщённая ошибка уровня Use Case
class UnauthorizedAccess extends Data.TaggedError("UnauthorizedAccess")<{
  readonly userId: UserId
  readonly resource: string
  readonly action: string
}> {}

class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly field: string
  readonly message: string
  readonly value: unknown
}> {}

3. Инфраструктурные ошибки (Infrastructure Errors)

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

Характеристики:

  • Зависят от конкретной технологии (SQLite, HTTP, файловая система)
  • Являются неожиданными с точки зрения бизнес-логики
  • Домен не должен знать об их существовании
  • Должны быть трансформированы в доменные или application ошибки на границе адаптера

Примеры:

  • DatabaseConnectionError — разрыв соединения с БД
  • NetworkTimeout — таймаут сетевого запроса
  • FileSystemError — ошибка доступа к файлу
  • SerializationError — ошибка сериализации/десериализации
class DatabaseError extends Data.TaggedError("DatabaseError")<{
  readonly operation: string
  readonly cause: unknown
}> {}

class NetworkError extends Data.TaggedError("NetworkError")<{
  readonly url: string
  readonly statusCode: number
  readonly cause: unknown
}> {}

Ключевой принцип: Dependency Rule для ошибок

В гексагональной архитектуре Dependency Rule гласит: зависимости указывают внутрь. Этот же принцип распространяется на ошибки:

┌──────────────────────────────────────────────────────────┐
│                    Infrastructure                         │
│   DatabaseError, NetworkError, FileSystemError           │
│                                                          │
│   ┌──────────────────────────────────────────────────┐   │
│   │              Application Layer                    │   │
│   │   UnauthorizedAccess, ValidationError            │   │
│   │                                                  │   │
│   │   ┌──────────────────────────────────────────┐   │   │
│   │   │              Domain                       │   │   │
│   │   │   TodoNotFound, InvalidTransition        │   │   │
│   │   │   DuplicateTitle, TodoListFull           │   │   │
│   │   └──────────────────────────────────────────┘   │   │
│   └──────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────┘

Правила:

  1. Домен знает только о доменных ошибках. Никаких SqliteError, HttpError или NetworkTimeout в доменном слое.

  2. Application Layer знает о доменных и application ошибках. Он может ловить и трансформировать доменные ошибки, добавлять свои, но не работает с инфраструктурными напрямую.

  3. Адаптеры трансформируют инфраструктурные ошибки в доменные. Если SQLite-адаптер не может найти запись — он возвращает TodoNotFound, а не SQLITE_NOTFOUND.

  4. Инфраструктурные ошибки НЕ пересекают границу порта. Порт определяет контракт — включая ошибки. И этот контракт выражен в доменных терминах.

// ❌ Нарушение: инфраструктурная ошибка протекает в домен
interface TodoRepository {
  findById(id: TodoId): Effect.Effect<Todo, SqliteError>
  //                                       ^^^^^^^^^^
  //                          Домен знает о SQLite — нарушение!
}

// ✅ Правильно: порт возвращает доменную ошибку
interface TodoRepository {
  findById(id: TodoId): Effect.Effect<Todo, TodoNotFound>
  //                                       ^^^^^^^^^^^^
  //                        Доменная ошибка — часть контракта
}

Почему Effect-ts — идеальный инструмент для доменных ошибок

Ошибка в типовой сигнатуре

В Effect каждый эффект имеет тип Effect<Success, Error, Requirements>. Канал E (Error) — это типизированная часть контракта. Компилятор TypeScript проверяет, что:

  1. Все возможные ошибки объявлены в типе
  2. Все возможные ошибки обработаны вызывающим кодом
  3. При композиции эффектов ошибки корректно объединяются
import { Effect } from "effect"

// Тип явно говорит: эта операция может вернуть Todo
// или завершиться ошибкой TodoNotFound
declare const findTodo: (
  id: TodoId
) => Effect.Effect<Todo, TodoNotFound>

// Тип явно говорит: два возможных типа ошибок
declare const completeTodo: (
  id: TodoId
) => Effect.Effect<Todo, TodoNotFound | InvalidStatusTransition>

Сравнение подходов к обработке ошибок

Аспектthrow/catchResult<T, E>Effect<A, E, R>
Видимость в типах❌ Нет✅ Да✅ Да
Полнота обработки❌ Нет✅ Да✅ Да
Композиция❌ try/catch⚠️ Вручную✅ Автоматически
Контекст ошибки❌ Строка✅ Тип✅ Тип + metadata
Трансформация❌ re-throw⚠️ map✅ mapError, catchTag
Множественные ошибки❌ Невозможно⚠️ Union вручную✅ Автоматический union
Отложенное выполнение❌ Нет❌ Нет✅ Да
Ресурсы при ошибке❌ finally❌ Нет✅ Scope + finalizer

Автоматическое объединение ошибок

Одно из самых мощных свойств Effect — автоматическое объединение типов ошибок при композиции:

import { Effect, pipe } from "effect"

declare const findTodo: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
declare const validateTransition: (todo: Todo) => Effect.Effect<Todo, InvalidStatusTransition>
declare const checkDuplicate: (title: string) => Effect.Effect<void, DuplicateTodoTitle>

// При последовательной композиции (flatMap/pipe) 
// типы ошибок автоматически объединяются в union
const completeTodo = (id: TodoId) =>
  pipe(
    findTodo(id),                    // Error: TodoNotFound
    Effect.flatMap(validateTransition), // Error: TodoNotFound | InvalidStatusTransition
    Effect.tap(() => checkDuplicate("test")), // Error: TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle
  )

// TypeScript выводит:
// Effect<Todo, TodoNotFound | InvalidStatusTransition | DuplicateTodoTitle>

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


Разделение Expected vs Unexpected ошибок

Effect проводит фундаментальное разграничение между двумя типами ошибок:

Expected Errors (Ожидаемые ошибки)

Ошибки, которые являются частью бизнес-логики. Они представлены каналом E в Effect<A, E, R>.

// Expected error: TodoNotFound — это нормальный бизнес-сценарий
const findTodo = (id: TodoId): Effect.Effect<Todo, TodoNotFound> =>
  pipe(
    repository.findById(id),
    Effect.flatMap(Option.match({
      onNone: () => Effect.fail(new TodoNotFound({ todoId: id })),
      onSome: Effect.succeed,
    }))
  )

Unexpected Errors (Defects — дефекты)

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

// Defect: это никогда не должно произойти
// Если массив состояний пуст — это баг, а не бизнес-ошибка
const getInitialStatus = (): TodoStatus => {
  const statuses = getAllStatuses()
  if (statuses.length === 0) {
    // Effect.die — дефект, НЕ отражается в E-канале
    throw new Error("BUG: Status list is empty — invariant violated")
  }
  return statuses[0]
}

Таблица решений: Expected vs Defect

СитуацияТипВ Effect
Задача не найдена по IDExpectedEffect.fail(new TodoNotFound(...))
Пользователь не авторизованExpectedEffect.fail(new Unauthorized(...))
Null pointer при обращении к полюDefectEffect.die(new Error("BUG: ..."))
Нарушение инварианта (пустой массив)DefectEffect.die(...) или throw
Соединение с БД разорваноExpected*Effect.fail(new DatabaseError(...))
Деление на ноль в бизнес-логикеDefectEffect.die(new Error("BUG: ..."))
Невалидный email от пользователяExpectedEffect.fail(new InvalidEmail(...))

*Потеря соединения с БД — Expected на уровне инфраструктуры, потому что мы знаем, что это может произойти, и должны обработать.


Ошибки и контракт порта

В гексагональной архитектуре порт — это контракт между ядром и внешним миром. И ошибки — неотъемлемая часть этого контракта.

import { Effect, Context } from "effect"

// Порт (контракт) явно объявляет все возможные ошибки
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
    readonly save: (todo: Todo) => Effect.Effect<void, DuplicateTodoTitle>
    readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>
    readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>>
    //                                                       ^ нет ошибок = never
  }
>() {}

Обратите внимание:

  1. findById может вернуть TodoNotFound — это доменная ошибка в контракте
  2. save может вернуть DuplicateTodoTitle — бизнес-правило о уникальности
  3. delete может вернуть TodoNotFound — нельзя удалить несуществующее
  4. findAll не возвращает ошибок (тип never) — пустой список допустим

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

import { Effect, Layer } from "effect"

// SQLite-адаптер трансформирует инфраструктурные ошибки в доменные
const TodoRepositorySqliteLive = Layer.succeed(
  TodoRepository,
  {
    findById: (id: TodoId) =>
      pipe(
        // Вызов SQLite — может бросить SqliteError
        queryOne(`SELECT * FROM todos WHERE id = ?`, [id]),
        // Трансформация: SqliteError → TodoNotFound
        Effect.mapError(() => new TodoNotFound({ todoId: id })),
        Effect.flatMap(row =>
          row === null
            ? Effect.fail(new TodoNotFound({ todoId: id }))
            : Effect.succeed(rowToTodo(row))
        ),
      ),
    // ... остальные методы
  }
)

Практические принципы проектирования доменных ошибок

Принцип 1: Одна ошибка — одна ситуация

Каждая доменная ошибка должна описывать ровно одну бизнес-ситуацию. Не создавайте «универсальные» ошибки.

// ❌ Антипаттерн: одна ошибка на всё
class DomainError extends Data.TaggedError("DomainError")<{
  readonly code: string
  readonly message: string
}> {}

// ✅ Правильно: одна ошибка — одна ситуация
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly todoId: TodoId
}> {}

class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{
  readonly todoId: TodoId
  readonly currentStatus: TodoStatus
  readonly targetStatus: TodoStatus
}> {}

class DuplicateTodoTitle extends Data.TaggedError("DuplicateTodoTitle")<{
  readonly title: string
  readonly existingTodoId: TodoId
}> {}

Принцип 2: Ошибка содержит контекст

Доменная ошибка должна содержать все данные, необходимые для понимания и обработки ситуации.

// ❌ Антипаттерн: ошибка без контекста
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{}> {}

// ✅ Правильно: ошибка содержит бизнес-контекст
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly todoId: TodoId    // какую задачу искали?
}> {}

// ✅ Ещё лучше для сложных случаев
class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{
  readonly todoId: TodoId           // какая задача?
  readonly currentStatus: TodoStatus // текущее состояние
  readonly targetStatus: TodoStatus  // куда пытались перейти
  readonly allowedTransitions: ReadonlyArray<TodoStatus> // куда можно перейти
}> {}

Принцип 3: Ошибки используют доменные типы

Поля ошибок должны использовать те же типы, что и остальной домен — Value Objects, идентификаторы, перечисления.

// ❌ Антипаттерн: примитивные типы
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly todoId: string  // string — не доменный тип!
}> {}

// ✅ Правильно: доменные типы
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly todoId: TodoId  // TodoId — branded тип из домена
}> {}

Принцип 4: Имена ошибок — на языке домена

Имя ошибки должно быть понятно бизнес-стейкхолдерам, а не только разработчикам.

// ❌ Антипаттерн: технические имена
class EntityNotFoundException extends Data.TaggedError("EntityNotFoundException")<{...}> {}
class StateTransitionException extends Data.TaggedError("StateTransitionException")<{...}> {}

// ✅ Правильно: доменные имена
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{...}> {}
class InvalidStatusTransition extends Data.TaggedError("InvalidStatusTransition")<{...}> {}

Принцип 5: Ошибки — иммутабельные значения

Доменная ошибка — это Value Object. Она неизменяема, сравнивается по значению и не имеет побочных эффектов.

import { Data, Equal } from "effect"

// Data.TaggedError автоматически реализует Equal
const error1 = new TodoNotFound({ todoId: TodoId.make("abc-123") })
const error2 = new TodoNotFound({ todoId: TodoId.make("abc-123") })

// Структурное равенство — true
Equal.equals(error1, error2) // true

Антипаттерны: что НЕ делать

Антипаттерн 1: Строковые ошибки

// ❌ Никогда не используйте строки как ошибки
Effect.fail("Todo not found")
Effect.fail("Invalid status transition")

// TypeScript выведет: Effect<never, string>
// Невозможно различить ошибки по типу!

Антипаттерн 2: Коды ошибок

// ❌ Коды ошибок — это маскировка проблемы
Effect.fail({ code: "TODO_001", message: "Not found" })
Effect.fail({ code: "TODO_002", message: "Invalid transition" })

// Обработка по строковому коду — хрупкая и ненадёжная

Антипаттерн 3: Наследование ошибок через class hierarchy

// ❌ Глубокая иерархия наследования
class AppError extends Error {}
class DomainError extends AppError {}
class TodoError extends DomainError {}
class TodoNotFound extends TodoError {}

// Проблемы: проверка через instanceof, мутабельность, запутанная иерархия

Антипаттерн 4: Ошибки с побочными эффектами

// ❌ НИКОГДА: ошибка, которая логирует при создании
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly todoId: TodoId
}> {
  constructor(props: { todoId: TodoId }) {
    super(props)
    console.error(`Todo ${props.todoId} not found!`) // Побочный эффект!
  }
}

Антипаттерн 5: Инфраструктурные ошибки в доменных операциях

// ❌ Домен знает о SQLite
const completeTodo = (id: TodoId): Effect.Effect<Todo, SqliteError | TodoNotFound> => ...
//                                                      ^^^^^^^^^^^
//                                           Инфраструктурная ошибка в домене!

// ✅ Домен знает только о доменных ошибках
const completeTodo = (id: TodoId): Effect.Effect<Todo, TodoNotFound | InvalidStatusTransition> => ...

Ошибки и тестируемость

Одно из главных преимуществ типизированных доменных ошибок — тестируемость. Когда ошибки типизированы и являются значениями, их легко проверять в тестах:

import { Effect, Exit } from "effect"
import { describe, it, expect } from "bun:test"

describe("completeTodo", () => {
  it("should fail with TodoNotFound for non-existent todo", async () => {
    const program = completeTodo(TodoId.make("non-existent"))
    
    const exit = await Effect.runPromiseExit(
      program.pipe(
        Effect.provide(TestTodoRepository),
      )
    )
    
    // Проверяем конкретный тип ошибки
    expect(exit).toEqual(
      Exit.fail(new TodoNotFound({ todoId: TodoId.make("non-existent") }))
    )
  })

  it("should fail with InvalidStatusTransition for completed todo", async () => {
    const program = completeTodo(completedTodoId)
    
    const exit = await Effect.runPromiseExit(
      program.pipe(
        Effect.provide(TestTodoRepository),
      )
    )
    
    // Exit.fail позволяет точно проверить ошибку
    expect(Exit.isFailure(exit)).toBe(true)
    if (Exit.isFailure(exit)) {
      const error = Cause.failureOption(exit.cause)
      expect(Option.isSome(error)).toBe(true)
      if (Option.isSome(error)) {
        expect(error.value._tag).toBe("InvalidStatusTransition")
      }
    }
  })
})

Резюме

ПринципОписание
Ошибки — часть доменаДоменные ошибки описывают бизнес-ситуации, а не технические сбои
Типизация в E-каналеEffect гарантирует видимость всех ошибок на уровне типов
Expected vs DefectБизнес-ошибки — Expected (Effect.fail), баги — Defect (Effect.die)
Dependency RuleИнфраструктурные ошибки не проникают в домен
Контекст в ошибкеКаждая ошибка содержит данные для понимания ситуации
Доменные типыОшибки используют Value Objects и Entity IDs из домена
ИммутабельностьОшибки — неизменяемые значения (Value Objects)
ТестируемостьТипизированные ошибки легко проверять в тестах

В следующей статье мы подробно разберём Data.TaggedError — основной инструмент создания доменных ошибок в Effect-ts.