Опирается на правила: R-ERR-HIER-1R-ERR-HIER-5 и R-ERR-HIER-X1R-ERR-HIER-X2 из Error Handling Style Guide → раздел 1. Иерархия исключений.

Важно знать

  • Четыре базовых типа в проекте: DomainError, InputValidationError, IntegrationError, TechnicalError. Всё остальное — наследники.
  • Все четыре наследуют AppError, не «голый» Error и не HttpException из NestJS — HTTP-семантика принадлежит edge-слою, не домену.
  • Имена доменных исключений — по бизнес-смыслу: OrderAlreadyShippedError, InsufficientFundsError. Не BusinessError, не DomainException.
  • IntegrationError-наследники с префиксом системы: PaymentGatewayError, CatalogPortError. Edge-фильтр различает «у платёжки» vs «у каталога».
  • Конструктор фиксирует контекст: new InsufficientFundsError(customerId, requested, available), не пустой конструктор.
  • throw new Error("...") запрещён — тип теряется, Exception Filter не различит domain от технической ошибки.
  • throw new TypeError(...) в доменном коде как бизнес-правило — запрещён. TypeError семантически «программистская ошибка», ловится unit-тестом, не endpoint'ом.

Иерархия исключений — это не «как удобнее в коде», это контракт обработки. Каждое исключение в публичной сигнатуре имеет тип, документированный смысл и однозначный Exception Filter в edge-слое. Без типизированной иерархии все ошибки сливаются в Error → 500 → logger.error('Failed', err) — никакой осмысленной реакции. Раскрытие правил R-ERR-HIER-* ниже.

Четыре типа

R-ERR-HIER-1: всё разнообразие ошибок в типичном сервисе делится на четыре непересекающиеся категории.

ТипКогда бросаетсяHTTP-статусRetry-safe
DomainErrorНарушено бизнес-правило (нельзя отменить отгруженный заказ, недостаточно средств)409 / 422❌ Нет
InputValidationErrorНевалидный input на edge (поле пустое, формат не тот)400❌ Нет
IntegrationErrorВнешняя система отвечает неожиданно (5xx, timeout, malformed)502 / 503 / 504✅ Обычно (при идемпотентности)
TechnicalErrorНаша внутренняя проблема (БД unreachable, OOM, прокси)500✅ Возможно

Где они живут физически:

  • DomainError и наследники — в core/errors.ts. Это часть домена — бизнес-правила формулируются здесь.
  • InputValidationError — на edge (in-adapter). Это про невалидный HTTP-input, не про домен.
  • IntegrationError — в каждом adapters/out/<system>/errors.ts. Наследник в своём пакете: adapters/out/payment/ имеет PaymentGatewayError, и т.д.
  • TechnicalError — в core/errors.ts, но используется редко. 90% технических ошибок ловит catch-all @Catch() фильтр без явного объявления.

Базовая иерархия

R-ERR-HIER-2: все четыре наследуют один корневой AppError, а не «голый» Error и не HttpException. HTTP-семантика — на edge, не в домене.

// core/errors.ts
export abstract class AppError extends Error {
  constructor(message: string, options?: { cause?: unknown }) {
    super(message, options);
    this.name = new.target.name;
  }
}

export abstract class DomainError extends AppError {}

// Нарушение текущего состояния (конфликт) → 409. Отличается от инвариантного нарушения → 422.
export abstract class ConflictDomainError extends DomainError {}

export class InputValidationError extends AppError {
  constructor(
    readonly errors: ValidationErrorItem[],
    message = 'Validation failed',
  ) {
    super(message);
  }
}
export abstract class IntegrationError extends AppError {}
export class TechnicalError extends AppError {}

Почему не HttpException:

  • HttpException — деталь HTTP-транспорта. Домен не знает, что запрос пришёл через HTTP. Завтра появится gRPC-транспорт — HttpException здесь будет мусором.
  • Фильтры различают типы через instanceof. @Catch(DomainError) поймает только то, что наследует DomainError. HttpException — параллельная иерархия, один фильтр не поймает оба.
  • NestJS сам обрабатывает HttpException встроенным механизмом. Если домен бросает HttpException — edge теряет контроль над форматом ответа.

Имена — по бизнес-смыслу

R-ERR-HIER-3: имя исключения должно отвечать на вопрос «что нарушено», не «как упало».

// PREFER
export class OrderAlreadyShippedError extends ConflictDomainError {}
export class InsufficientFundsError extends DomainError { /* ... */ }
export class CustomerNotEligibleForRefundError extends DomainError {}
export class ProductOutOfStockError extends DomainError { /* ... */ }

// AVOID
export class BusinessError extends DomainError {}         // без контекста
export class DomainException extends DomainError {}       // избыточное «Exception»
export class IllegalStateError extends DomainError {}     // техническое имя

Что даёт правильное имя:

  • Stack trace читается как история. Видишь InsufficientFundsError в логе — сразу понятно. С BusinessError пришлось бы лезть в err.message.
  • Exception Filter различает. Можно сделать специфичный @Catch(InsufficientFundsError) с уточнённым ответом и type-URL в ProblemDetails (https://api.example.com/errors/insufficient-funds).
  • Метрики разделимы. app_errors_total{exception="InsufficientFundsError"} — отдельная серия в Prometheus. Можно алёртить на рост по конкретному коду бизнес-правила.

Имя — это публичный контракт. Менять его — breaking change, как переименование поля API.

Префикс системы для IntegrationError

R-ERR-HIER-4: наследники IntegrationError имеют префикс системы.

// adapters/out/payment/errors.ts
export class PaymentGatewayError extends IntegrationError {}
export class PaymentGatewayUnavailableError extends PaymentGatewayError {}
export class InvalidPaymentRequestError extends DomainError {
  constructor(readonly orderId: string, readonly reason: string) {
    super(`Invalid payment request for order ${orderId}: ${reason}`);
  }
}

// adapters/out/catalog/errors.ts
export class CatalogPortError extends IntegrationError {}
export class CatalogUnavailableError extends CatalogPortError {}

// adapters/out/sms/errors.ts
export class SmsProviderError extends IntegrationError {}

Что это даёт Exception Filter'у:

// edge/filters/integration-error.filter.ts
@Catch(PaymentGatewayError)
export class PaymentGatewayErrorFilter implements ExceptionFilter {
  catch(err: PaymentGatewayError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    sendProblem(res, 502, 'Payment provider temporarily unavailable',
      `Payment system error, traceId: ${getTraceId()}`);
  }
}

@Catch(CatalogPortError)
export class CatalogErrorFilter implements ExceptionFilter {
  catch(err: CatalogPortError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    sendProblem(res, 503, 'Product catalog temporarily unavailable',
      `Catalog system error, traceId: ${getTraceId()}`);
  }
}

Сообщение клиенту разное — «платёжка недоступна» vs «каталог недоступен». Это про наблюдаемость: оператор по логам сразу видит, что упало — Sber, каталог или SMS-провайдер.

Конструктор фиксирует контекст

R-ERR-HIER-5: не голый конструктор без аргументов, а с полным контекстом.

// core/errors/order.errors.ts
export class InsufficientFundsError extends DomainError {
  constructor(
    readonly customerId: string,
    readonly requested: bigint,
    readonly available: bigint,
  ) {
    super(
      `Insufficient funds: customer=${customerId}, requested=${requested}, available=${available}`,
    );
  }
}

export class OrderAlreadyShippedError extends ConflictDomainError {
  constructor(readonly orderId: string) {
    super(`Order ${orderId} is already shipped and cannot be modified`);
  }
}

export class ProductOutOfStockError extends DomainError {
  constructor(
    readonly productId: string,
    readonly requested: number,
    readonly available: number,
  ) {
    super(`Product ${productId} out of stock: requested=${requested}, available=${available}`);
  }
}

Что даёт фиксация в конструкторе:

  • err.message содержит полный контекст сразу. Лог-строка пишется в момент throw, без дополнительного logger.warn('customer=%s, requested=%s', ...).
  • Exception Filter достаёт поля для ProblemDetails. err.customerId, err.requested — типизированные readonly-поля, доступны для extension-полей ответа.
  • Stacktrace + структура в одной точке. Контекст не теряется при прохождении через несколько уровней call stack — он уже в объекте ошибки.

Деньги — bigint (минорные единицы, например копейки), не number. number не удерживает точность для больших сумм.

Что запрещено

R-ERR-HIER-X1: голый throw new Error("Что-то сломалось").

// ПЛОХО
if (order.status === OrderStatus.SHIPPED) {
  throw new Error('Order already shipped');   // тип теряется
}

@Catch(DomainError) фильтр это не поймает — полетит в catch-all @Catch() → 500. Метрики покажут «много Error», что бесполезно. Stacktrace будет, но type в ProblemDetails будет about:blank, и никакой специализированной обработки.

Правильно — throw new OrderAlreadyShippedError(order.id).

R-ERR-HIER-X2: throw new TypeError(...) в доменном коде как бизнес-правило.

// ПЛОХО
public cancel(): void {
  if (this.status === OrderStatus.SHIPPED) {
    throw new TypeError('Cannot cancel shipped order');   // техническое имя
  }
}

TypeError в TypeScript семантически — «программа в неверном состоянии из-за ошибки программиста». Если бизнес-сценарий допускает попытку отменить отгруженный заказ (пользователь нажал кнопку), это не баг, это бизнес-правило — нужен DomainError-наследник.

// PREFER
public cancel(): void {
  if (this.status === OrderStatus.SHIPPED) {
    throw new OrderAlreadyShippedError(this.id);
  }
  this.status = OrderStatus.CANCELLED;
  this.events.push(new OrderCancelledEvent(this.id));
}

TypeError / RangeError остаются для настоящих invariant violation — таких, которые ловит unit-тест, а не endpoint.

АнтипаттернПравилоЧто взамен
throw new Error('...') в доменном кодеR-ERR-HIER-X1Конкретный наследник DomainError / TechnicalError
throw new TypeError(...) как бизнес-правилоR-ERR-HIER-X2DomainError-наследник с именем по бизнес-смыслу
class PaymentError extends HttpException {} в доменеR-ERR-HIER-2IntegrationError-наследник, HTTP-семантика — на edge
class BusinessError extends DomainError {} без специализацииR-ERR-HIER-3Конкретное имя по бизнес-правилу: InsufficientFundsError
new InsufficientFundsError() без аргументовR-ERR-HIER-5new InsufficientFundsError(customerId, requested, available)
Деньги в number в полях исключенияR-ERR-HIER-5bigint (минорные единицы) или decimal-библиотека

Куда дальше

  • Где throw, где catch — что куда летит после throw.
  • Mapping в ProblemDetails — как доменное исключение становится HTTP-ответом.
  • Логирование исключений — какой уровень лога для каждого типа.
  • Retry-семантика — что retry-safe, что нет.
  • Result-types vs exceptions — когда neverthrow.Result уместен.
  • Observability — метрики и трейсинг по типам ошибок.
  • Error Handling Style Guide (Node) — нормативный индекс правил.