Опирается на правила:
R-ERR-HIER-1…R-ERR-HIER-5иR-ERR-HIER-X1…R-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-X2 | DomainError-наследник с именем по бизнес-смыслу |
class PaymentError extends HttpException {} в домене | R-ERR-HIER-2 | IntegrationError-наследник, HTTP-семантика — на edge |
class BusinessError extends DomainError {} без специализации | R-ERR-HIER-3 | Конкретное имя по бизнес-правилу: InsufficientFundsError |
new InsufficientFundsError() без аргументов | R-ERR-HIER-5 | new InsufficientFundsError(customerId, requested, available) |
Деньги в number в полях исключения | R-ERR-HIER-5 | bigint (минорные единицы) или decimal-библиотека |
Куда дальше
- Где throw, где catch — что куда летит после
throw. - Mapping в ProblemDetails — как доменное исключение становится HTTP-ответом.
- Логирование исключений — какой уровень лога для каждого типа.
- Retry-семантика — что retry-safe, что нет.
- Result-types vs exceptions — когда
neverthrow.Resultуместен. - Observability — метрики и трейсинг по типам ошибок.
- Error Handling Style Guide (Node) — нормативный индекс правил.