← назад к разделу

NESTJS · ERROR CONTRACT Фаза 7. Exception filters

Exception filters: обработка ошибок

TypeScript · NestJS

Единый фильтр, доменные исключения, HTTP-статусы и нейтральный 500.

1. Фильтр централизует преобразование ошибок

1 / 5

ExceptionFilter перехватывает исключение и формирует HTTP-ответ. Контроллеры не содержат одинаковые try/catch.

  • Правило маппинга одно на приложение.
  • Фильтр вызывается после исключения.
  • Клиент получает стабильный JSON.
Controllerthrows error ExceptionFiltercatch(error)map response HTTP errorconsistent JSON

2. Доменная ошибка получает смысл и статус

2 / 5

NotFoundException - 404, конфликт состояния - 409, неверный вход - 400. Тип ошибки выбирается по операции.

  • 400 - нарушенный контракт входа.
  • 409 - текущее состояние запрещает действие.
  • 500 - неожиданный технический сбой.
NotFound → 404 InvalidState → 409 BadRequest → 400 Problem JSONtype + title + code Client

3. Problem JSON стабилен для клиента

3 / 5

type, title, status и code позволяют ветвиться программно. detail и traceId помогают диагностике без раскрытия внутренностей.

  • Код ошибки не меняется без версии.
  • Стектрейс не попадает наружу.
  • Trace ID связывает логи.
application/problem+json type: /errors/order-not-found title: Order not found status: 404 code: ORDER_NOT_FOUND traceId: 7f3a...

4. Логирование происходит один раз

4 / 5

Ожидаемый отказ клиента краток, неожиданная 500 получает ERROR, cause и trace ID. Log and throw не дублирует запись по слоям.

404 / 409 expectedshort WARN 500 unexpectedERROR + cause + traceId Одна главная запись о сбое

5. Fallback возвращает нейтральный 500

5 / 5

Неизвестное исключение получает тот же формат без деталей реализации. Специфичные фильтры обрабатывают известные доменные случаи.

  • Specific mapping идёт первым.
  • Ошибка фильтра не зацикливает ответ.
  • Тест проверяет статус и отсутствие утечек.
Exception resolution Specific exception400 / 404 / 409 Unexpected exceptionsafe 500 JSON

← назад к разделу

Когда в приложении что-то идёт не так, клиент получает ответ с ошибкой. Вопрос: кто превращает внутреннее исключение в понятный HTTP-ответ, и как сделать так, чтобы этот процесс был единообразным по всему API? В NestJS за это отвечают exception filters.

Что происходит без фильтров

Представьте: где-то в коде бросается необработанное исключение. Без настройки NestJS ответит 500 с телом {"statusCode": 500, "message": "Internal server error"}, а стек запишет в лог — наружу он не утекает. Беда в другом: клиент не узнаёт, что именно случилось. Доменная ошибка «товар не найден», брошенная из сервиса, без фильтра превратится в такой же безликий 500, хотя должна была стать 404.

Плюс разные части приложения могут отвечать по-разному: один маршрут возвращает { message: "not found" }, другой — { error: "NotFound" }, третий — вообще строку. Клиент не может на это нормально реагировать.

Exception filter — это класс, который перехватывает исключение и формирует из него правильный HTTP-ответ. Один фильтр можно подключить глобально — тогда все ошибки пройдут через него.

HttpException: встроенный способ вернуть ошибку

Самый простой путь — бросить HttpException или один из его готовых наследников. NestJS умеет их обрабатывать сам, без дополнительных фильтров.

import { NotFoundException } from '@nestjs/common';

@Get(':id')
async getOne(@Param('id', ParseIntPipe) id: number) {
  const product = await this.repo.find(id);
  if (!product) {
    throw new NotFoundException(`product ${id} not found`);
  }
  return product;
}

NotFoundException — это HttpException с кодом 404. В NestJS есть готовые классы для самых частых случаев: BadRequestException (400), UnauthorizedException (401), ForbiddenException (403), NotFoundException (404), ConflictException (409), InternalServerErrorException (500) и другие.

Для простых случаев, когда ошибка очевидна прямо в контроллере, этого достаточно.

Свой фильтр: доменное исключение → HTTP

Проблема возникает, когда бизнес-логика находится не в контроллере, а глубже — в сервисах, обработчиках команд. Им не стоит знать о HTTP: они должны бросать исключения на языке домена, а не протокола.

Например, сервис знает, что товар не найден, и бросает ProductNotFound. Кто должен превратить это в 404? Не сервис — он про бизнес. Не контроллер — ему придётся оборачивать каждый вызов в try/catch. Для этого и нужен exception filter.

import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';

export class ProductNotFound extends Error {
  constructor(public readonly productId: number) {
    super(`product ${productId} not found`);
  }
}

@Catch(ProductNotFound)
export class ProductNotFoundFilter implements ExceptionFilter {
  catch(exception: ProductNotFound, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse();
    response.status(HttpStatus.NOT_FOUND).json({
      error: 'product_not_found',
      productId: exception.productId,
    });
  }
}

@Catch(ProductNotFound) говорит NestJS: «этот фильтр перехватывает только исключения типа ProductNotFound». Внутри метода catch — формируем ответ: статус 404 и тело с понятным кодом ошибки.

Теперь сервис пишет throw new ProductNotFound(id) — чисто, без знания о HTTP. Фильтр делает перевод в протокол.

Глобальный фильтр: единый формат для всего API

Хорошая практика — сделать так, чтобы все ошибки в API выглядели одинаково. Клиент заранее знает структуру ответа об ошибке и может её обрабатывать автоматически.

Для этого создают глобальный фильтр, который ловит всё подряд (@Catch() без аргументов):

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse();
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;
    const message =
      exception instanceof HttpException ? exception.message : 'internal_error';

    response.status(status).json({
      statusCode: status,
      error: message,
    });
  }
}

Подключают в main.ts:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new AllExceptionsFilter());
  await app.listen(3000);
}

Или через провайдер в модуле — тогда фильтр может использовать зависимости (например, логгер):

import { APP_FILTER } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: AllExceptionsFilter,
    },
  ],
})
export class AppModule {}

После этого клиент всегда получает одинаковую структуру, а необработанные ошибки не утекают стек-трейсом наружу.

Где какое исключение бросать

Простое правило, которого стоит придерживаться:

  • Сервисы и бизнес-логика бросают доменные исключения (ProductNotFound, OrderAlreadyPaid). Они описывают бизнес-ситуацию, ничего не зная о HTTP.
  • Контроллер может бросить HttpException напрямую — для простых случаев, которые очевидны уже на уровне входных данных.
  • Exception filter переводит доменные исключения в коды HTTP и формирует единое тело ответа.

Главное: не тащите HttpException в сервисы и домен. Иначе бизнес-логика начнёт зависеть от протокола, и её станет труднее тестировать и переиспользовать.

Коротко

  • Exception filter — класс с @Catch(), который перехватывает исключение и формирует HTTP-ответ.
  • @Catch(SomeError) ловит только конкретный тип; @Catch() без аргументов — всё подряд.
  • HttpException и его наследники (NotFoundException, BadRequestException и др.) NestJS обрабатывает сам.
  • Сервисы бросают доменные исключения; фильтр переводит их в HTTP — это разделение ответственности.
  • Глобальный фильтр подключается через app.useGlobalFilters() или провайдер APP_FILTER.
  • Единый формат ошибок — клиент всегда знает структуру ответа; необработанные исключения не утекают деталями реализации.

Что пощупать

Один фильтр @Catch() в практикуме remodov/marketplace-system-nodejs переводит ошибки домена в статусы: NotFoundError в 404, OutOfStockError и ConflictError в 409, InvalidError в 400, HttpException сохраняет свой статус, всё остальное становится 500 и уходит в лог со стеком. Тело всегда application/problem+json, так что клиент разбирает один формат.

Код: problem.ts, product.errors.ts.

Сделаем сами

Ветка step-03-commands-and-errors - фильтр уже на месте, нужно родить правильные ошибки из класса товара.

Что почитать дальше