Иерархия ошибок: Domain → Application → Infrastructure
Три слоя ошибок в гексагональной архитектуре. Правила пересечения границ. Трансформация ошибок на границах (mapError, catchTag, catchTags, catchAll, orDie). Error Boundary через типы. Полный поток ошибки через все слои. Defects и когда их использовать. Организация файлов ошибок.
Введение: зачем нужна иерархия ошибок
В гексагональной архитектуре каждый слой имеет свою зону ответственности. Ошибки — не исключение. Когда ошибки «протекают» из одного слоя в другой, нарушается принцип изоляции, и архитектура теряет свои преимущества.
Представьте: 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 │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Правила пересечения границ
- Domain Layer → наружу: Доменные ошибки могут пересекать границу Application Layer (Application Layer знает о домене)
- Infrastructure → Domain: Запрещено! Инфраструктурные ошибки трансформируются в доменные на границе адаптера
- Application → Presentation: Application ошибки трансформируются в presentation-ошибки (HTTP коды, UI сообщения)
- 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,
),
})
Ключевые моменты:
Effect.mapError— прямая трансформация одной ошибки в другуюEffect.catchTag— обработка конкретной инфраструктурной ошибкиEffect.die/Effect.orDie— преобразование неожиданных ошибок в дефекты- Никогда не пропускайте
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), // Ошибка заглушена!
)
Резюме
| Слой | Примеры ошибок | Кто трансформирует |
|---|---|---|
| Domain | TodoNotFound, InvalidStatusTransition | Создаются доменной логикой |
| Application | UnauthorizedAccess, ValidationError | Создаются Application Services |
| Infrastructure | DatabaseError, NetworkError | Адаптер трансформирует → Domain Error |
| Presentation | HTTP 404, HTTP 409 | HTTP Adapter трансформирует → HTTP Response |
| Принцип | Описание |
|---|---|
| Dependency Rule | Инфраструктурные ошибки НЕ проникают в домен |
| Error Boundary | Порт определяет допустимые ошибки через типы |
| Трансформация | Каждая граница — точка трансформации ошибок |
| Defect vs Expected | Бизнес-ошибки = Expected, баги = Defect |
| Компилятор проверяет | TypeScript не даст нарушить контракт порта |