Типобезопасный домен: Гексагональная архитектура на базе 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 не даст нарушить контракт порта