Ошибки сервиса должны превращаться в предсказуемые ответы, а не в случайные 500 со стек-трейсом. В NestJS за это отвечают exception filters — единое место, где исключение становится HTTP-ответом. Это позволяет Handler-ам и домену бросать осмысленные исключения, ничего не зная про HTTP, а перевод в протокол держать в одном месте.

HttpException и встроенные

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

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, BadRequestException, ForbiddenException и другие — готовые наследники с нужным кодом. NestJS сам превратит их в ответ. Для прикладного кода в контроллере этого часто достаточно.

Доменное исключение → HTTP

Но Handler и домен не должны знать про HTTP — они бросают доменные исключения. Перевести их в коды — задача кастомного filter-а, помеченного @Catch.

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,
    });
  }
}

Теперь Handler пишет throw new ProductNotFound(id) — чистое доменное выражение, — а filter переводит это в 404 с единым телом. Контроллеры и Handler-ы остаются свободными от кодов HTTP.

Единый формат ошибок

Чтобы всё API отвечало об ошибках одинаково, ставят глобальный filter, ловящий базовый тип и приводящий ответ к одной форме.

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;
    response.status(status).json({
      error: exception instanceof HttpException ? exception.message : 'internal_error',
      statusCode: status,
    });
  }
}

Подключают глобально — app.useGlobalFilters(new AllExceptionsFilter()) или провайдером APP_FILTER. Так клиент всегда получает тело одной структуры, а необработанные ошибки не утекают стек-трейсом наружу.

Граница: где чьё исключение

Раскладка простая и её стоит держать: домен и Handler бросают доменные исключения (ProductNotFound, OrderAlreadyPaid) — они про бизнес, не про HTTP; контроллер для тривиальных случаев может бросить HttpException напрямую; filter переводит доменные исключения в коды и формат. Не тащи HttpException в домен — он привяжет бизнес-логику к протоколу.

Это тот же приём, что @ControllerAdvice/@ExceptionHandler в Spring-биндинге: доменные ошибки выражаются доменными типами, а перевод в HTTP — в одном месте. Единый, предсказуемый формат ошибок и отсутствие утечек — часть того, что делает сервис пригодным к эксплуатации; то, что прошло через filter, видно в наблюдаемости, и продукт-инженер понимает, что и почему вернулось клиенту.