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

Schema для ошибок: сериализация и десериализация

Schema.TaggedError: ошибки с поддержкой encode/decode. Encode в JSON для API, Event Store, логирования. Decode с валидацией. Union Schema для множества ошибок. Практические сценарии: HTTP API, Event Store, inter-service. Генерация тестовых данных через Arbitrary. Версионирование ошибок. Выбор между Data.TaggedError и Schema.TaggedError.

Введение: зачем нужна иерархия ошибок

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

Представьте: SQLite-адаптер бросает SQLITE_CONSTRAINT_UNIQUE. Если эта ошибка попадает в Use Case или, ещё хуже, в HTTP-контроллер — весь стек начинает зависеть от SQLite. Замена базы данных на PostgreSQL сломает обработку ошибок во всём приложении.

Иерархия ошибок решает эту проблему: каждый слой определяет свои ошибки, а на границах слоёв происходит трансформация — маппинг из одного типа в другой.


Архитектура ошибок в Hexagonal

Три слоя ошибок

┌─────────────────────────────────────────────────────────────────────┐
│                        HTTP / CLI / UI                               │
│  Presentation Errors: 404 Not Found, 400 Bad Request, 500 ISE       │
│                                                                      │
│  ┌───────────────────────────────────────────────────────────────┐   │
│  │                    Application Layer                           │   │
│  │  App Errors: UnauthorizedAccess, ValidationError,             │   │
│  │              ConcurrencyConflict, RateLimitExceeded           │   │
│  │                                                               │   │
│  │  ┌───────────────────────────────────────────────────────┐   │   │
│  │  │                  Domain Layer                          │   │   │
│  │  │  Domain Errors: TodoNotFound, InvalidStatusTransition, │   │   │
│  │  │                 DuplicateTodoTitle, TodoListFull       │   │   │
│  │  └───────────────────────────────────────────────────────┘   │   │
│  └───────────────────────────────────────────────────────────────┘   │
│                                                                      │
│  ┌───────────────────────────────────────────────────────────────┐   │
│  │                  Infrastructure Layer                          │   │
│  │  Infra Errors: DatabaseError, NetworkError, FileSystemError,  │   │
│  │                SerializationError, TimeoutError                │   │
│  └───────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘

Правила пересечения границ

  1. Domain Layer → наружу: Доменные ошибки могут пересекать границу Application Layer (Application Layer знает о домене)
  2. Infrastructure → Domain: Запрещено! Инфраструктурные ошибки трансформируются в доменные на границе адаптера
  3. Application → Presentation: Application ошибки трансформируются в presentation-ошибки (HTTP коды, UI сообщения)
  4. Domain → Presentation: Доменные ошибки тоже трансформируются в presentation (часто через Application Layer)

Слой 1: Domain Errors

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

  • Чистые — не зависят от инфраструктуры или фреймворков
  • Бизнесовые — описывают нарушения бизнес-правил
  • Контекстные — содержат доменные типы (Value Objects, Entity IDs)
  • Стабильные — не меняются при замене технологий

Определение

// domain/errors.ts

import { Data, Option } from "effect"
import type { TodoId, TodoStatus, TodoListId } from "./types"

// --- Entity-level ошибки ---

export class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly todoId: TodoId
}> {}

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

export class InvalidTodoTitle extends Data.TaggedError("InvalidTodoTitle")<{
  readonly title: string
  readonly reason: "empty" | "too_long" | "invalid_characters"
  readonly maxLength: number
}> {}

export class InvalidDueDate extends Data.TaggedError("InvalidDueDate")<{
  readonly date: Date
  readonly reason: "past_date" | "too_far_future"
}> {}

// --- Aggregate-level ошибки ---

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

export class TodoListFull extends Data.TaggedError("TodoListFull")<{
  readonly listId: TodoListId
  readonly maxSize: number
  readonly currentSize: number
}> {}

// --- Domain Service ошибки ---

export class PriorityConflict extends Data.TaggedError("PriorityConflict")<{
  readonly todoId: TodoId
  readonly conflictingTodoId: TodoId
  readonly priority: number
}> {}

// --- Union type для всех доменных ошибок ---

export type TodoDomainError =
  | TodoNotFound
  | InvalidStatusTransition
  | InvalidTodoTitle
  | InvalidDueDate
  | DuplicateTodoTitle
  | TodoListFull
  | PriorityConflict

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

// domain/todo.ts

import { Effect } from "effect"
import { InvalidStatusTransition, InvalidTodoTitle } from "./errors"

// Статусная машина — чистая функция домена
const STATUS_TRANSITIONS: ReadonlyMap<TodoStatus, ReadonlyArray<TodoStatus>> = new Map([
  ["Active", ["Completed", "Archived"]],
  ["Completed", ["Active", "Archived"]],
  ["Archived", ["Active"]],
])

export const transitionStatus = (
  todo: Todo,
  targetStatus: TodoStatus,
): Effect.Effect<Todo, InvalidStatusTransition> => {
  const allowed = STATUS_TRANSITIONS.get(todo.status) ?? []
  
  if (!allowed.includes(targetStatus)) {
    return Effect.fail(new InvalidStatusTransition({
      todoId: todo.id,
      currentStatus: todo.status,
      targetStatus,
      allowedTransitions: allowed,
    }))
  }
  
  return Effect.succeed({
    ...todo,
    status: targetStatus,
    updatedAt: new Date(),
  })
}

Слой 2: Application Errors

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

  • Оркестрационные — связаны с координацией бизнес-процессов
  • Кросс-доменные — могут оборачивать ошибки из нескольких доменов
  • Зависят от Use Case — привязаны к конкретным сценариям использования
  • Включают авторизацию и валидацию — ошибки до попадания в домен

Определение

// application/errors.ts

import { Data } from "effect"
import type { UserId } from "../domain/types"

// --- Авторизация ---

export class UnauthorizedAccess extends Data.TaggedError("UnauthorizedAccess")<{
  readonly userId: UserId
  readonly resource: string
  readonly action: string
}> {}

export class ForbiddenOperation extends Data.TaggedError("ForbiddenOperation")<{
  readonly userId: UserId
  readonly reason: string
}> {}

// --- Валидация входных данных ---

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

export class BatchValidationError extends Data.TaggedError("BatchValidationError")<{
  readonly errors: ReadonlyArray<InputValidationError>
}> {}

// --- Конкурентность ---

export class ConcurrencyConflict extends Data.TaggedError("ConcurrencyConflict")<{
  readonly entityId: string
  readonly expectedVersion: number
  readonly actualVersion: number
}> {}

export class OptimisticLockError extends Data.TaggedError("OptimisticLockError")<{
  readonly entityId: string
  readonly entityType: string
}> {}

// --- Rate Limiting ---

export class RateLimitExceeded extends Data.TaggedError("RateLimitExceeded")<{
  readonly userId: UserId
  readonly limit: number
  readonly windowSeconds: number
  readonly retryAfterSeconds: number
}> {}

// --- Union type ---

export type ApplicationError =
  | UnauthorizedAccess
  | ForbiddenOperation
  | InputValidationError
  | BatchValidationError
  | ConcurrencyConflict
  | OptimisticLockError
  | RateLimitExceeded

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

// application/use-cases/complete-todo.ts

import { Effect, pipe } from "effect"
import { TodoRepository } from "../../domain/ports"
import { transitionStatus } from "../../domain/todo"
import { UnauthorizedAccess } from "../errors"
import type { TodoNotFound, InvalidStatusTransition } from "../../domain/errors"

export interface CompleteTodoCommand {
  readonly todoId: TodoId
  readonly userId: UserId
}

// Use Case: оркестрация бизнес-процесса
export const completeTodo = (
  command: CompleteTodoCommand
): Effect.Effect<
  Todo,
  // Application-level + Domain-level ошибки
  TodoNotFound | InvalidStatusTransition | UnauthorizedAccess,
  TodoRepository | AuthorizationService
> =>
  pipe(
    // 1. Проверка авторизации (Application concern)
    checkAuthorization(command.userId, command.todoId),
    // 2. Загрузка сущности (через порт)
    Effect.flatMap(() =>
      TodoRepository.pipe(
        Effect.flatMap(repo => repo.findById(command.todoId))
      )
    ),
    // 3. Бизнес-логика (Domain)
    Effect.flatMap(todo => transitionStatus(todo, "Completed")),
    // 4. Сохранение (через порт)
    Effect.flatMap(todo =>
      TodoRepository.pipe(
        Effect.flatMap(repo => repo.save(todo)),
        Effect.map(() => todo),
      )
    ),
  )

const checkAuthorization = (
  userId: UserId,
  todoId: TodoId,
): Effect.Effect<void, UnauthorizedAccess, AuthorizationService> =>
  pipe(
    AuthorizationService,
    Effect.flatMap(auth => auth.canModify(userId, todoId)),
    Effect.flatMap(canModify =>
      canModify
        ? Effect.void
        : Effect.fail(new UnauthorizedAccess({
            userId,
            resource: `todo:${todoId}`,
            action: "complete",
          }))
    ),
  )

Слой 3: Infrastructure Errors

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

  • Технологические — привязаны к конкретной технологии
  • Транзиентные — часто временные (таймаут, разрыв соединения)
  • Детальные — содержат низкоуровневую информацию
  • НЕ пересекают границу порта — трансформируются в доменные ошибки

Определение

// infrastructure/errors.ts

import { Data } from "effect"

// --- Database ---

export class DatabaseError extends Data.TaggedError("DatabaseError")<{
  readonly operation: string
  readonly table: string
  readonly cause: unknown
}> {}

export class DatabaseConnectionError extends Data.TaggedError("DatabaseConnectionError")<{
  readonly host: string
  readonly port: number
  readonly cause: unknown
}> {}

export class UniqueConstraintViolation extends Data.TaggedError("UniqueConstraintViolation")<{
  readonly table: string
  readonly column: string
  readonly value: string
}> {}

// --- Network ---

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

export class TimeoutError extends Data.TaggedError("TimeoutError")<{
  readonly operation: string
  readonly timeoutMs: number
}> {}

// --- File System ---

export class FileSystemError extends Data.TaggedError("FileSystemError")<{
  readonly path: string
  readonly operation: "read" | "write" | "delete" | "list"
  readonly cause: unknown
}> {}

export class FileNotFoundError extends Data.TaggedError("FileNotFoundError")<{
  readonly path: string
}> {}

// --- Serialization ---

export class SerializationError extends Data.TaggedError("SerializationError")<{
  readonly format: "json" | "binary" | "xml"
  readonly operation: "serialize" | "deserialize"
  readonly cause: unknown
}> {}

Трансформация ошибок на границах

Граница: Infrastructure → Domain (Adapter)

Это самая важная граница. Адаптер обязан перехватить все инфраструктурные ошибки и трансформировать их в доменные.

// infrastructure/adapters/todo-repository-sqlite.ts

import { Effect, pipe, Layer } from "effect"
import { TodoRepository } from "../../domain/ports"
import { TodoNotFound, DuplicateTodoTitle } from "../../domain/errors"
import { UniqueConstraintViolation, DatabaseError } from "../errors"
import type { Option } from "effect"

const makeTodoRepositorySqlite = (db: SqliteClient) => ({
  findById: (id: TodoId): Effect.Effect<Todo, TodoNotFound> =>
    pipe(
      // 1. Выполняем SQL-запрос
      db.query(`SELECT * FROM todos WHERE id = ?`, [id]),
      // 2. Маппим инфраструктурную ошибку в доменную
      Effect.mapError(() => new TodoNotFound({ todoId: id })),
      // 3. Проверяем результат
      Effect.flatMap(rows =>
        rows.length === 0
          ? Effect.fail(new TodoNotFound({ todoId: id }))
          : Effect.succeed(rowToTodo(rows[0]!))
      ),
    ),

  save: (todo: Todo): Effect.Effect<void, DuplicateTodoTitle> =>
    pipe(
      db.execute(
        `INSERT INTO todos (id, title, status) VALUES (?, ?, ?)
         ON CONFLICT(id) DO UPDATE SET title = ?, status = ?`,
        [todo.id, todo.title, todo.status, todo.title, todo.status]
      ),
      // Маппинг: UNIQUE constraint → DuplicateTodoTitle
      Effect.catchTag("UniqueConstraintViolation", (error) =>
        error.column === "title"
          ? Effect.fail(new DuplicateTodoTitle({
              title: todo.title,
              existingTodoId: Option.none(),
            }))
          : Effect.die(error) // Неожиданный constraint — это баг
      ),
      // Все остальные DB ошибки — дефекты
      Effect.catchTag("DatabaseError", (error) =>
        Effect.die(error)
      ),
    ),

  delete: (id: TodoId): Effect.Effect<void, TodoNotFound> =>
    pipe(
      db.execute(`DELETE FROM todos WHERE id = ?`, [id]),
      Effect.flatMap(result =>
        result.changes === 0
          ? Effect.fail(new TodoNotFound({ todoId: id }))
          : Effect.void
      ),
      Effect.mapError(() => new TodoNotFound({ todoId: id })),
    ),

  findAll: (): Effect.Effect<ReadonlyArray<Todo>> =>
    pipe(
      db.query(`SELECT * FROM todos ORDER BY created_at DESC`),
      Effect.map(rows => rows.map(rowToTodo)),
      // DB ошибка при findAll — дефект (не доменная ошибка)
      Effect.orDie,
    ),
})

Ключевые моменты:

  1. Effect.mapError — прямая трансформация одной ошибки в другую
  2. Effect.catchTag — обработка конкретной инфраструктурной ошибки
  3. Effect.die / Effect.orDie — преобразование неожиданных ошибок в дефекты
  4. Никогда не пропускайте DatabaseError в доменный контракт

Граница: Domain → Application

Application Layer может работать с доменными ошибками напрямую или обернуть их:

// application/use-cases/create-todo.ts

import { Effect, pipe } from "effect"

export const createTodo = (
  command: CreateTodoCommand
): Effect.Effect<
  Todo,
  // Application Layer может содержать СМЕСЬ доменных и application ошибок
  InvalidTodoTitle | DuplicateTodoTitle | UnauthorizedAccess | InputValidationError,
  TodoRepository | AuthorizationService
> =>
  pipe(
    // Валидация входных данных (Application concern)
    validateInput(command),
    // Авторизация (Application concern)
    Effect.flatMap(() => checkAuth(command.userId)),
    // Доменная логика (Domain concern)
    Effect.flatMap(() => createTodoEntity(command)),
    // Сохранение через порт
    Effect.flatMap(todo =>
      TodoRepository.pipe(
        Effect.flatMap(repo => repo.save(todo)),
        Effect.map(() => todo),
      )
    ),
  )

Граница: Application → Presentation (HTTP Adapter)

HTTP-адаптер трансформирует доменные и application ошибки в HTTP-ответы:

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

import { Effect, pipe } from "effect"
import { HttpServerResponse } from "@effect/platform"

export const mapErrorToHttp = <A>(
  effect: Effect.Effect<A, TodoDomainError | ApplicationError>
): Effect.Effect<HttpServerResponse.HttpServerResponse> =>
  pipe(
    effect,
    Effect.map(data => HttpServerResponse.json(data)),
    Effect.catchTags({
      // Domain Errors → HTTP
      TodoNotFound: (error) =>
        Effect.succeed(HttpServerResponse.json(
          { error: "TodoNotFound", todoId: error.todoId },
          { status: 404 },
        )),

      InvalidStatusTransition: (error) =>
        Effect.succeed(HttpServerResponse.json(
          {
            error: "InvalidStatusTransition",
            current: error.currentStatus,
            target: error.targetStatus,
            allowed: error.allowedTransitions,
          },
          { status: 409 }, // Conflict
        )),

      DuplicateTodoTitle: (error) =>
        Effect.succeed(HttpServerResponse.json(
          { error: "DuplicateTodoTitle", title: error.title },
          { status: 409 },
        )),

      TodoListFull: (error) =>
        Effect.succeed(HttpServerResponse.json(
          { error: "TodoListFull", max: error.maxSize },
          { status: 422 }, // Unprocessable Entity
        )),

      InvalidTodoTitle: (error) =>
        Effect.succeed(HttpServerResponse.json(
          { error: "InvalidTodoTitle", reason: error.reason },
          { status: 400 },
        )),

      InvalidDueDate: (error) =>
        Effect.succeed(HttpServerResponse.json(
          { error: "InvalidDueDate", reason: error.reason },
          { status: 400 },
        )),

      // Application Errors → HTTP
      UnauthorizedAccess: (error) =>
        Effect.succeed(HttpServerResponse.json(
          { error: "Unauthorized", resource: error.resource },
          { status: 403 },
        )),

      InputValidationError: (error) =>
        Effect.succeed(HttpServerResponse.json(
          { error: "ValidationError", field: error.field, message: error.message },
          { status: 400 },
        )),

      ConcurrencyConflict: (error) =>
        Effect.succeed(HttpServerResponse.json(
          { error: "ConcurrencyConflict", retry: true },
          { status: 409 },
        )),

      RateLimitExceeded: (error) =>
        Effect.succeed(HttpServerResponse.json(
          { error: "RateLimitExceeded", retryAfter: error.retryAfterSeconds },
          { status: 429 },
        )),
    }),
  )

Поток ошибки через все слои

Рассмотрим полный путь ошибки при попытке завершить несуществующую задачу:

┌──────────────────────────────────────────────────────────────────────┐
│ 1. HTTP POST /todos/abc-123/complete                                 │
│    HTTP Adapter получает запрос                                      │
└──────────────────────┬───────────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 2. Application Layer: completeTodo({ todoId: "abc-123", userId })    │
│    Проверяет авторизацию → OK                                        │
│    Вызывает todoRepository.findById("abc-123")                       │
└──────────────────────┬───────────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 3. Adapter (SQLite): SELECT * FROM todos WHERE id = 'abc-123'        │
│    Результат: пустой набор строк                                     │
│                                                                      │
│    ╔════════════════════════════════════════════════════════╗         │
│    ║ ТРАНСФОРМАЦИЯ: пустой результат → TodoNotFound       ║         │
│    ║ Effect.fail(new TodoNotFound({ todoId: "abc-123" }))  ║         │
│    ╚════════════════════════════════════════════════════════╝         │
└──────────────────────┬───────────────────────────────────────────────┘
                       │ TodoNotFound всплывает
                       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 4. Application Layer: получает TodoNotFound                          │
│    Может обработать или пропустить наверх                             │
│    В данном случае — пропускает (доменная ошибка в контракте)         │
└──────────────────────┬───────────────────────────────────────────────┘
                       │ TodoNotFound всплывает
                       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 5. HTTP Adapter: mapErrorToHttp(TodoNotFound)                        │
│                                                                      │
│    ╔════════════════════════════════════════════════════════╗         │
│    ║ ТРАНСФОРМАЦИЯ: TodoNotFound → HTTP 404                ║         │
│    ║ Response: { error: "TodoNotFound", todoId: "abc-123" }║         │
│    ╚════════════════════════════════════════════════════════╝         │
└──────────────────────────────────────────────────────────────────────┘

Код полного потока

// 1. HTTP Adapter (Driving Adapter)
const completeTodoRoute = HttpRouter.post(
  "/todos/:id/complete",
  pipe(
    HttpServerRequest.schemaParams(Schema.Struct({ id: TodoIdSchema })),
    Effect.flatMap(({ id }) =>
      // 2. Application Layer
      completeTodo({ todoId: id, userId: currentUserId })
    ),
    // 5. Маппинг ошибок в HTTP
    mapErrorToHttp,
  )
)

// 2-4. Application Layer (Use Case)
const completeTodo = (command: CompleteTodoCommand) =>
  pipe(
    // 2a. Authorization
    checkAuth(command.userId, command.todoId),
    // 3. Вызов порта (адаптер трансформирует ошибки внутри)
    Effect.flatMap(() =>
      TodoRepository.pipe(
        Effect.flatMap(repo => repo.findById(command.todoId))
      )
    ),
    // 4. Domain logic
    Effect.flatMap(todo => transitionStatus(todo, "Completed")),
    // 4a. Сохранение
    Effect.flatMap(todo =>
      TodoRepository.pipe(
        Effect.flatMap(repo => repo.save(todo)),
        Effect.map(() => todo),
      )
    ),
  )

Паттерны трансформации ошибок

Паттерн 1: mapError — один к одному

// Прямая замена одной ошибки на другую
pipe(
  dbQuery(`SELECT * FROM todos WHERE id = ?`, [id]),
  Effect.mapError(() => new TodoNotFound({ todoId: id })),
)

Паттерн 2: catchTag — обработка конкретного тега

// Обработка одного конкретного типа ошибки
pipe(
  saveTodo(todo),
  Effect.catchTag("UniqueConstraintViolation", (error) =>
    Effect.fail(new DuplicateTodoTitle({
      title: todo.title,
      existingTodoId: Option.none(),
    }))
  ),
)

Паттерн 3: catchTags — обработка нескольких тегов

// Обработка нескольких типов одновременно
pipe(
  infrastructureOperation(),
  Effect.catchTags({
    DatabaseError: (error) =>
      Effect.fail(new TodoNotFound({ todoId: id })),
    TimeoutError: (error) =>
      Effect.die(error), // Таймаут = дефект
    UniqueConstraintViolation: (error) =>
      Effect.fail(new DuplicateTodoTitle({ ... })),
  }),
)

Паттерн 4: catchAll — ловить все ошибки

// Все ошибки трансформируются в один тип
pipe(
  complexOperation(),
  Effect.catchAll((error) => {
    switch (error._tag) {
      case "DatabaseError":
        return Effect.fail(new TodoNotFound({ todoId: id }))
      case "NetworkError":
        return Effect.die(error)
      default:
        return Effect.die(error) // Неизвестные — дефекты
    }
  }),
)

Паттерн 5: mapBoth — трансформация и значения, и ошибки

// Одновременный маппинг успеха и ошибки
pipe(
  dbQuery(`SELECT * FROM todos WHERE id = ?`, [id]),
  Effect.mapBoth({
    onFailure: () => new TodoNotFound({ todoId: id }),
    onSuccess: rowToTodo,
  }),
)

Паттерн 6: orDie — неожиданная ошибка = дефект

// Если ошибка невозможна в нормальных условиях — это баг
pipe(
  dbQuery(`SELECT * FROM todos ORDER BY created_at`),
  Effect.orDie, // DatabaseError → Defect (не отражается в E)
)

Паттерн 7: tapError — побочный эффект при ошибке (без трансформации)

// Логирование ошибки без изменения типа
pipe(
  completeTodo(id),
  Effect.tapError((error) =>
    Effect.logWarning("Operation failed", {
      error: error._tag,
      todoId: id,
    })
  ),
)

Error Boundary: изоляция слоёв через типы

Контракт порта как Error Boundary

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

import { Effect, Context } from "effect"

// Контракт порта: ТОЛЬКО доменные ошибки в E-канале
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>>
  }
>() {}

Если адаптер попытается вернуть инфраструктурную ошибку — компилятор не позволит:

// ❌ Ошибка компиляции: DatabaseError не assignable to TodoNotFound
const brokenAdapter = {
  findById: (id: TodoId): Effect.Effect<Todo, DatabaseError> => // ← Type Error!
    dbQuery(`SELECT * FROM todos WHERE id = ?`, [id]),
}

Компилятор TypeScript автоматически проверяет, что Error-канал адаптера соответствует контракту порта. Это и есть Error Boundary на уровне типов.


Defects: ошибки, которые не должны быть в контракте

Когда использовать Effect.die

Effect.die создаёт Defect — ошибку, которая не отражается в E-канале. Используйте для ситуаций, которые означают баг в коде:

// Инвариант: массив статусов не может быть пустым
const getDefaultStatus = (): Effect.Effect<TodoStatus> => {
  const statuses = getAllStatuses()
  return statuses.length === 0
    ? Effect.die(new Error("BUG: Status list is empty"))
    : Effect.succeed(statuses[0]!)
}
// Тип: Effect<TodoStatus, never>
//                          ^^^^^ — дефект не виден в типе

Когда использовать Effect.orDie

Effect.orDie преобразует все Expected ошибки в Defects. Используйте когда ошибка невозможна в данном контексте:

// findAll не должен завершаться ошибкой в нормальных условиях
// Если БД недоступна — это дефект системы
const getAllTodos = (): Effect.Effect<ReadonlyArray<Todo>> =>
  pipe(
    dbQuery(`SELECT * FROM todos`),
    Effect.map(rows => rows.map(rowToTodo)),
    Effect.orDie, // DatabaseError → Defect
  )
// Тип: Effect<ReadonlyArray<Todo>, never>

Таблица решений: fail vs die

СитуацияExpected (Effect.fail)Defect (Effect.die)
Задача не найдена✅❌
Невозможный переход состояния✅❌
Дублирующийся заголовок✅❌
Пользователь не авторизован✅❌
Нарушение инварианта (баг)❌✅
Null pointer access❌✅
Не найден конфиг при старте✅❌
Массив статусов пуст❌✅
Таймаут БД при findAll⚠️ Зависит от контекста⚠️
Parse ошибка в JSON-ответе✅ (на границе)✅ (внутри)

Организация файлов ошибок

Рекомендуемая структура

src/
├── domain/
│   ├── errors/
│   │   ├── index.ts            # Re-export всех доменных ошибок
│   │   ├── todo-errors.ts      # Ошибки связанные с Todo Entity
│   │   ├── todo-list-errors.ts # Ошибки связанные с TodoList Aggregate
│   │   └── shared-errors.ts    # Общие доменные ошибки
│   ├── entities/
│   ├── value-objects/
│   └── ...
├── application/
│   ├── errors/
│   │   ├── index.ts            # Re-export всех application ошибок
│   │   ├── auth-errors.ts      # Ошибки авторизации
│   │   ├── validation-errors.ts # Ошибки валидации
│   │   └── use-case-errors.ts  # Ошибки Use Cases
│   ├── use-cases/
│   └── ...
└── infrastructure/
    ├── errors/
    │   ├── index.ts            # Re-export всех инфраструктурных ошибок
    │   ├── database-errors.ts  # Ошибки БД
    │   ├── network-errors.ts   # Сетевые ошибки
    │   └── fs-errors.ts        # Ошибки файловой системы
    ├── adapters/
    └── ...

Index файлы (barrel exports)

// domain/errors/index.ts
export { TodoNotFound, InvalidStatusTransition, InvalidTodoTitle, InvalidDueDate } from "./todo-errors"
export { DuplicateTodoTitle, TodoListFull } from "./todo-list-errors"
export { PriorityConflict } from "./shared-errors"

// Union type
export type TodoDomainError =
  | TodoNotFound
  | InvalidStatusTransition
  | InvalidTodoTitle
  | InvalidDueDate
  | DuplicateTodoTitle
  | TodoListFull
  | PriorityConflict

Антипаттерны

Антипаттерн 1: Единый Error для всех слоёв

// ❌ Один тип для всех слоёв — нарушает Dependency Rule
class AppError extends Data.TaggedError("AppError")<{
  readonly code: string
  readonly message: string
  readonly layer: "domain" | "application" | "infrastructure"
}> {}

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

// ❌ Порт знает о SQLite
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound | SqliteError>
    //                                                                     ^^^^^^^^^^^
  }
>() {}

Антипаттерн 3: Ошибки без трансформации

// ❌ Пропуск инфраструктурной ошибки «как есть»
const findById = (id: TodoId) =>
  dbQuery(`SELECT * FROM todos WHERE id = ?`, [id])
  // Возвращает Effect<Row, DatabaseError> — DatabaseError утечёт!

Антипаттерн 4: Проглатывание ошибок

// ❌ Ошибка молча игнорируется
const findById = (id: TodoId) =>
  pipe(
    dbQuery(`SELECT * FROM todos WHERE id = ?`, [id]),
    Effect.orElseSucceed(() => null), // Ошибка заглушена!
  )

Резюме

СлойПримеры ошибокКто трансформирует
DomainTodoNotFound, InvalidStatusTransitionСоздаются доменной логикой
ApplicationUnauthorizedAccess, ValidationErrorСоздаются Application Services
InfrastructureDatabaseError, NetworkErrorАдаптер трансформирует → Domain Error
PresentationHTTP 404, HTTP 409HTTP Adapter трансформирует → HTTP Response
ПринципОписание
Dependency RuleИнфраструктурные ошибки НЕ проникают в домен
Error BoundaryПорт определяет допустимые ошибки через типы
ТрансформацияКаждая граница — точка трансформации ошибок
Defect vs ExpectedБизнес-ошибки = Expected, баги = Defect
Компилятор проверяетTypeScript не даст нарушить контракт порта