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

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

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

Представьте: где-то в коде бросается необработанное исключение. Без настройки NestJS вернёт 500 Internal Server Error, а в теле может оказаться стек-трейс — то есть внутренние детали реализации уйдут клиенту. Это плохо: клиент видит бесполезный мусор, а не понятное сообщение об ошибке.

Плюс разные части приложения могут отвечать по-разному: один маршрут возвращает { 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);
}

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

@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.
  • Единый формат ошибок — клиент всегда знает структуру ответа; необработанные исключения не утекают деталями реализации.

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

  • Pipes и валидация — как NestJS отсекает некорректные входные данные ещё до контроллера.
  • Guards: авторизация запросов — как запрещать доступ до выполнения хендлера.
  • Observability в NestJS — логирование, метрики и трассировка, включая ошибки.