Опирается на правила:
R-ERR-WHERE-1…R-ERR-WHERE-3иR-ERR-WHERE-X1…R-ERR-WHERE-X3из Error Handling Style Guide → раздел 2. Где throw, где catch.
Важно знать
- Throw — везде где нужно. Domain бросает
DomainError, pipe/валидатор —InputValidationError, out-adapter —IntegrationError.- Catch — ровно в трёх местах:
- Exception Filters — REST edge. Превращают исключение в HTTP-response.
- Out-adapter — integration boundary. Ловит axios-ошибки, бросает port-specific.
- Резильянс-обёртка —
cockatiel(retry / circuitBreaker / bulkhead). Формальный catch через конфиг.- В UseCase Handler / Domain Service / Aggregate — ноль try/catch. Исключения проходят насквозь до edge.
catch (e) { this.logger.error(e); }без re-throw — главный антипаттерн всего гайда. Глушит ошибку, возвращает «успех» вызывающему.catch (e) { throw new Error(String(e)); }— тип иcauseтеряются, фильтр не различит.catch (e) { return null; }/return []— то же, что силент-фейл, только без логирования.
Главный принцип: исключение — это часть контракта, не неожиданность. Domain-метод явно выражает, какие исключения он бросает через типы в иерархии. Вызывающий не пытается их «обработать» в каждом месте — это задача edge-слоя. Чем меньше try/catch в коде, тем чётче поток управления и тем меньше мест, где ошибка может потеряться. Раскрытие правил R-ERR-WHERE-* ниже.
Throw — без церемоний
R-ERR-WHERE-1: бросаем исключение там, где обнаружили проблему.
// core/domain/order.aggregate.ts
export class Order {
cancel(reason: CancellationReason): void {
if (this.status === OrderStatus.SHIPPED) {
throw new OrderAlreadyShippedError(this.id);
}
this.status = OrderStatus.CANCELLED;
this.events.push(new OrderCancelledEvent(this.id, reason));
}
addItem(productId: string, qty: number, stock: number): void {
if (qty > stock) {
throw new ProductOutOfStockError(productId, qty, stock);
}
this.items.push({ productId, qty });
}
}
// adapters/out/payment/payment.client.adapter.ts
async charge(cmd: ChargeCommand): Promise<ChargeResult> {
try {
const { data } = await this.http.axiosRef.post('/charge', toApi(cmd));
return toDomain(data);
} catch (e) {
if (axios.isAxiosError(e)) {
if (e.response && e.response.status < 500) {
throw new InvalidPaymentRequestError(cmd.orderId, e.response.data?.message ?? '');
}
throw new PaymentGatewayError('payment 5xx/timeout', { cause: e });
}
throw e;
}
}
Не изобретаем neverthrow.Result везде ради избежания throw (см. Result-types vs exceptions). TypeScript исключения и иерархия типов — достаточный контракт обработки.
Точка 1 — Exception Filters
R-ERR-WHERE-2-a: per-type ExceptionFilter-классы, зарегистрированные глобально через APP_FILTER.
// edge/filters/domain-error.filter.ts
@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(DomainErrorFilter.name);
catch(err: DomainError, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
this.logger.warn(`domain rule violated: ${err.message}`);
appErrorsTotal.inc({ type: 'domain', exception: err.name });
sendProblem(res, 422, 'Operation cannot be completed', err.message, {
type: `https://api.example.com/errors/${toKebabCase(err.name)}`,
});
}
}
// edge/filters/integration-error.filter.ts
@Catch(IntegrationError)
export class IntegrationErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(IntegrationErrorFilter.name);
catch(err: IntegrationError, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
this.logger.warn(`integration error: ${err.message}`);
appErrorsTotal.inc({ type: 'integration', exception: err.name });
sendProblem(res, 502, 'External system temporarily unavailable',
`Service error, please retry. traceId: ${getTraceId()}`);
}
}
// edge/filters/unexpected.filter.ts
@Catch()
export class UnexpectedFilter implements ExceptionFilter {
private readonly logger = new Logger(UnexpectedFilter.name);
catch(err: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
const stack = err instanceof Error ? err.stack : String(err);
this.logger.error('unexpected error', stack);
appErrorsTotal.inc({ type: 'unexpected', exception: (err as Error)?.name ?? 'Unknown' });
sendProblem(res, 500, 'Internal Server Error', `internal error, traceId: ${getTraceId()}`);
}
}
Порядок регистрации в app.module.ts важен — most-specific фильтр выигрывает, но NestJS применяет их в обратном порядке регистрации через APP_FILTER. Catch-all регистрируется первым (выполняется последним):
// app.module.ts
providers: [
{ provide: APP_FILTER, useClass: UnexpectedFilter }, // @Catch() — регистрируется первым
{ provide: APP_FILTER, useClass: IntegrationErrorFilter },
{ provide: APP_FILTER, useClass: DomainErrorFilter }, // самый специфичный — последним
{ provide: APP_FILTER, useClass: ValidationFilter },
],
Что важно:
- Catch-all (
@Catch()) — не глушит, не возвращает 200. Логирует ERROR + полный стек, отдаёт 500. - Per-type фильтр — разные статусы, разные log-level, разные ProblemDetails. Подробности — в Mapping в ProblemDetails.
- Фильтры живут в edge-слое, не в
core/— HTTP-обработка ошибок это деталь in-adapter.
Точка 2 — out-adapter (integration boundary)
R-ERR-WHERE-2-b: out-adapter ловит низкоуровневые axios-ошибки и бросает port-specific.
// adapters/out/payment/payment.client.adapter.ts
@Injectable()
export class PaymentClientAdapter implements PaymentPort {
constructor(private readonly http: HttpService) {}
async register(cmd: RegisterCommand): Promise<RegisterResult> {
try {
const { data } = await this.http.axiosRef.post('/payments/register', toApi(cmd), {
headers: { 'Idempotency-Key': cmd.idempotencyKey },
});
return toDomain(data);
} catch (e) {
if (axios.isAxiosError(e)) {
const status = e.response?.status;
if (status !== undefined && status < 500) {
throw new InvalidPaymentRequestError(cmd.orderId, e.response?.data?.message ?? '');
}
if (e.code === 'ECONNABORTED' || e.code === 'ETIMEDOUT') {
throw new PaymentGatewayError('payment timeout', { cause: e });
}
throw new PaymentGatewayError(`payment ${status ?? 'network'} error`, { cause: e });
}
throw e;
}
}
}
// adapters/out/catalog/catalog.client.adapter.ts
@Injectable()
export class CatalogClientAdapter implements CatalogPort {
constructor(private readonly http: HttpService) {}
async getProduct(productId: string): Promise<Product> {
try {
const { data } = await this.http.axiosRef.get(`/products/${productId}`);
return toDomainProduct(data);
} catch (e) {
if (axios.isAxiosError(e)) {
if (e.response?.status === 404) {
throw new ProductNotFoundError(productId);
}
throw new CatalogPortError(`catalog error for product ${productId}`, { cause: e });
}
throw e;
}
}
}
Что важно:
- Out-adapter — единственное место, где мы знаем про axios,
AxiosError, HTTP-статусы внешней системы. Эти типы — детали инфраструктуры, не должны утекать вcore/. - 4xx vs 5xx — разная семантика. 4xx превращаем в
DomainError-наследник (InvalidPaymentRequestError), 5xx — вIntegrationError-наследник (PaymentGatewayError). Это влияет на retry — см. Retry-семантика. { cause: e }— обязательно. Оригинальный axios error сохраняется в цепочке, stacktrace доступен в фильтре.
Точка 3 — резильянс-обёртка (cockatiel)
R-ERR-WHERE-2-c: cockatiel — retry / circuitBreaker / bulkhead — это формальный catch через конфиг.
// adapters/out/payment/payment.resilient.adapter.ts
import { retry, handleType, ExponentialBackoff, circuitBreaker, ConsecutiveBreaker } from 'cockatiel';
@Injectable()
export class PaymentResilientAdapter implements PaymentPort {
private readonly retryPolicy = retry(
handleType(PaymentGatewayError),
{ maxAttempts: 3, backoff: new ExponentialBackoff({ initialDelay: 200, maxDelay: 2000 }) },
);
private readonly cbPolicy = circuitBreaker(
handleType(PaymentGatewayError),
{ halfOpenAfter: 10_000, breaker: new ConsecutiveBreaker(5) },
);
constructor(private readonly inner: PaymentClientAdapter) {
this.cbPolicy.onBreak(() => {
logger.error('PaymentGateway circuit breaker opened');
appErrorsTotal.inc({ type: 'integration', exception: 'PaymentGatewayUnavailableError' });
});
}
async register(cmd: RegisterCommand): Promise<RegisterResult> {
try {
return await this.cbPolicy.execute(() =>
this.retryPolicy.execute(() => this.inner.register(cmd)),
);
} catch (e) {
if (e instanceof BrokenCircuitError) {
throw new PaymentGatewayUnavailableError('circuit breaker open', { cause: e });
}
throw e;
}
}
}
Что важно:
handleType(PaymentGatewayError)— retry/CB срабатывает только наPaymentGatewayError, не наInvalidPaymentRequestError(4xx) и не наDomainError.BrokenCircuitError(cockatiel) оборачивается вPaymentGatewayUnavailableError— port-specific, edge решит.- Не пишем свой try/catch поверх для тех же типов — cockatiel уже их поймал.
Нигде больше — никакого try/catch
R-ERR-WHERE-3: в UseCase Handler / Domain Service / Aggregate — ноль try/catch.
// use-cases/cancel-order/cancel-order.handler.ts
@Injectable()
export class CancelOrderHandler {
constructor(
private readonly orders: OrderRepository,
private readonly notifications: NotificationPort,
private readonly payment: PaymentPort,
) {}
async handle(cmd: CancelOrderCommand): Promise<Order> {
const order = await this.orders.findById(cmd.orderId);
if (!order) throw new OrderNotFoundError(cmd.orderId);
order.cancel(cmd.reason);
await this.orders.save(order);
await this.notifications.notifyCancellation(order);
return order;
}
}
Что важно:
- Никаких
try { order.cancel(...) } catch (...). Если правило нарушено —OrderAlreadyShippedErrorдолетит доDomainErrorFilter→ 422. - Никаких
try { notifications.notify(...) } catch (Exception). Если провайдер сломан —IntegrationErrorдолетит доIntegrationErrorFilter→ 502. - Логирование — на edge в фильтрах, один раз. Не на каждом уровне. См. Логирование исключений.
Handler тонкий — только оркестрирует. Логика — в Order.cancel(...), обработка ошибок — в фильтрах.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
catch (e) { this.logger.error(e); } без re-throw в handler/service | R-ERR-WHERE-X1 | Убрать catch — пусть летит до фильтра |
catch (e) { throw new Error(String(e)); } | R-ERR-WHERE-X2 | throw new PaymentGatewayError(msg, { cause: e }) |
catch (e) { return null; } / return [] / return undefined | R-ERR-WHERE-X3 | Убрать catch — пусть летит до фильтра |
try/catch в handle() UseCase Handler | R-ERR-WHERE-3 | Никаких try/catch в handler, только в фильтрах и адаптерах |
catch (e) { throw e; } бесполезный catch | — | Убрать catch целиком |
Почему catch (e) { logger.error(e); } катастрофичен
// ПЛОХО
async handle(cmd: CreateOrderCommand): Promise<Order> {
try {
const order = await this.orders.create(cmd);
await this.payment.charge(chargeCmd);
return order;
} catch (e) {
this.logger.error('Failed to create order', e);
return null as unknown as Order; // возвращает «успех» с null
}
}
Что не так:
- Возвращает успех вызывающему. Контроллер думает, что заказ создан — пытается строить ответ из
null. ВозможенTypeError: Cannot read properties of nullчерез 5 строк, в совершенно другом месте. - Не виден в метриках.
app_errors_totalне увеличится — исключение «не случилось». - Не виден в трейсе. Span не помечается как ERROR.
- Двойное логирование при re-throw. Если в аналогичном месте логируют и пробрасывают — та же ошибка появится в логах дважды.
Каждый раз, когда видишь такой catch — это место скрытой ошибки.
Куда дальше
- Иерархия исключений — какие типы бросать.
- Mapping в ProblemDetails — что делают Exception Filters с пойманными.
- Логирование исключений — уровни лога и правило «один раз».
- Retry-семантика — какие исключения retry-safe.
- Result-types vs exceptions — когда
neverthrow.Resultуместен. - Observability — метрики и трейсинг.
- Resilience Style Guide → R-RES-* — про cockatiel retry, CB, bulkhead.