Ошибки — часть домена, а не инфраструктуры
Философский фундамент доменных ошибок. Три категории ошибок (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. Вызывающий код не имеет ни малейшего представления о том, какие ошибки возможны — ни на уровне типов, ни на уровне документации.
Философский фундамент: ошибки как факты домена
Ошибка — это бизнес-событие
Доменная ошибка — это не сбой программы. Это факт, который произошёл в контексте бизнес-процесса. «Задача не найдена» — это факт. «Невозможный переход состояния» — это факт. «Дублирующийся заголовок» — это факт. Каждый из этих фактов имеет:
- Имя — однозначно описывающее ситуацию на языке домена
- Контекст — данные, необходимые для понимания произошедшего
- Значение для бизнеса — каждая ошибка требует определённой реакции
Когда бизнес-аналитик говорит: «Если пользователь пытается завершить уже архивированную задачу, система должна уведомить его, что задача архивирована и предложить её восстановить» — он описывает бизнес-правило, включающее доменную ошибку. Эта ошибка — такой же элемент домена, как 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 │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Правила:
-
Домен знает только о доменных ошибках. Никаких
SqliteError,HttpErrorилиNetworkTimeoutв доменном слое. -
Application Layer знает о доменных и application ошибках. Он может ловить и трансформировать доменные ошибки, добавлять свои, но не работает с инфраструктурными напрямую.
-
Адаптеры трансформируют инфраструктурные ошибки в доменные. Если SQLite-адаптер не может найти запись — он возвращает
TodoNotFound, а неSQLITE_NOTFOUND. -
Инфраструктурные ошибки НЕ пересекают границу порта. Порт определяет контракт — включая ошибки. И этот контракт выражен в доменных терминах.
// ❌ Нарушение: инфраструктурная ошибка протекает в домен
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 проверяет, что:
- Все возможные ошибки объявлены в типе
- Все возможные ошибки обработаны вызывающим кодом
- При композиции эффектов ошибки корректно объединяются
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/catch | Result<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 |
|---|---|---|
| Задача не найдена по ID | Expected | Effect.fail(new TodoNotFound(...)) |
| Пользователь не авторизован | Expected | Effect.fail(new Unauthorized(...)) |
| Null pointer при обращении к полю | Defect | Effect.die(new Error("BUG: ...")) |
| Нарушение инварианта (пустой массив) | Defect | Effect.die(...) или throw |
| Соединение с БД разорвано | Expected* | Effect.fail(new DatabaseError(...)) |
| Деление на ноль в бизнес-логике | Defect | Effect.die(new Error("BUG: ...")) |
| Невалидный email от пользователя | Expected | Effect.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
}
>() {}
Обратите внимание:
findByIdможет вернутьTodoNotFound— это доменная ошибка в контрактеsaveможет вернутьDuplicateTodoTitle— бизнес-правило о уникальностиdeleteможет вернутьTodoNotFound— нельзя удалить несуществующее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.