Опирается на правила: R-ERR-WHERE-1R-ERR-WHERE-3 и R-ERR-WHERE-X1R-ERR-WHERE-X3 из Error Handling Style Guide → раздел 2. Где throw, где catch.

Важно знать

  • Throw — везде где нужно. Domain бросает DomainError, pipe/валидатор — InputValidationError, out-adapter — IntegrationError.
  • Catch — ровно в трёх местах:
    1. Exception Filters — REST edge. Превращают исключение в HTTP-response.
    2. Out-adapter — integration boundary. Ловит axios-ошибки, бросает port-specific.
    3. Резильянс-обёртка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/serviceR-ERR-WHERE-X1Убрать catch — пусть летит до фильтра
catch (e) { throw new Error(String(e)); }R-ERR-WHERE-X2throw new PaymentGatewayError(msg, { cause: e })
catch (e) { return null; } / return [] / return undefinedR-ERR-WHERE-X3Убрать catch — пусть летит до фильтра
try/catch в handle() UseCase HandlerR-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.