Skip to content
NestJS best practices 5 min read

Error Handling Best Practices

Errors are part of your API’s contract just as much as the happy path. When every failure returns a predictable shape — a stable machine-readable code, a human message, and a correlation ID — clients can branch reliably and your on-call engineers can trace a failure across services in seconds. This page shows how to centralize error handling in NestJS with domain exceptions, a single global filter, and a consistent payload that never leaks stack traces or SQL to the outside world.

Throw domain exceptions, not strings

The first habit is to make failures explicit and typed. NestJS ships HTTP exceptions like NotFoundException and ConflictException, and those are perfect at the controller edge. But business rules deeper in the domain shouldn’t know about HTTP. Define a small base exception that carries a stable code alongside the message, then derive specific ones per failure mode.

// common/exceptions/domain.exception.ts
export class DomainException extends Error {
  constructor(
    readonly code: string,
    message: string,
    readonly status = 400,
  ) {
    super(message);
    this.name = new.target.name;
  }
}

export class OrderNotFoundException extends DomainException {
  constructor(id: string) {
    super('ORDER_NOT_FOUND', `Order ${id} does not exist`, 404);
  }
}

export class InsufficientStockException extends DomainException {
  constructor(sku: string) {
    super('INSUFFICIENT_STOCK', `Out of stock for ${sku}`, 409);
  }
}

Services throw these without importing anything HTTP-related, keeping the domain layer pure:

// modules/orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { OrdersRepository } from './orders.repository';
import { OrderNotFoundException } from '../../common/exceptions/domain.exception';

@Injectable()
export class OrdersService {
  constructor(private readonly repo: OrdersRepository) {}

  async findOne(id: string) {
    const order = await this.repo.findById(id);
    if (!order) throw new OrderNotFoundException(id);
    return order;
  }
}

Define one consistent error shape

Every error — validation, domain, or unexpected — should serialize to the same envelope. Codes let clients switch behavior without parsing English; the correlation ID ties the response to your logs.

FieldTypePurpose
codestringStable machine-readable identifier
messagestringSafe, human-readable summary
statusCodenumberHTTP status mirrored in the body
correlationIdstringRequest ID for log correlation
timestampstringISO-8601 time the error was produced
pathstringRequest path that failed

Catch everything in one global filter

A single global filter is the seam where domain exceptions, framework HttpExceptions, and truly unexpected errors all converge into that one shape. It is the only place allowed to decide what the client sees, which guarantees consistency and prevents leaks.

// common/filters/all-exceptions.filter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { DomainException } from '../exceptions/domain.exception';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();
    const req = ctx.getRequest<Request>();
    const correlationId =
      (req.headers['x-correlation-id'] as string) ?? crypto.randomUUID();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let code = 'INTERNAL_ERROR';
    let message = 'An unexpected error occurred';

    if (exception instanceof DomainException) {
      status = exception.status;
      code = exception.code;
      message = exception.message;
    } else if (exception instanceof HttpException) {
      status = exception.getStatus();
      code = HttpStatus[status] ?? 'HTTP_ERROR';
      const body = exception.getResponse();
      message = typeof body === 'string' ? body : (body as any).message;
    } else {
      // Unknown error: log the detail, but never expose it.
      this.logger.error(
        `[${correlationId}] Unhandled exception`,
        (exception as Error)?.stack,
      );
    }

    res.status(status).json({
      code,
      message,
      statusCode: status,
      correlationId,
      timestamp: new Date().toISOString(),
      path: req.url,
    });
  }
}

Register it once at bootstrap so it covers the entire application:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';

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

Output:

HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "code": "ORDER_NOT_FOUND",
  "message": "Order 42 does not exist",
  "statusCode": 404,
  "correlationId": "8f3c1d9e-2a44-4b7e-9c1a-7e2b6d5f4a01",
  "timestamp": "2026-06-14T10:22:41.118Z",
  "path": "/orders/42"
}

Never put exception.message or a stack trace into the response for the 500 branch. Internal messages frequently contain table names, file paths, or connection strings — log them server-side keyed by correlationId and return only the generic text.

Propagate the correlation ID

The correlation ID is only useful if it spans the whole request. Attach it in middleware so logs, downstream HTTP calls, and the error body all share one value.

// common/middleware/correlation-id.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const id = (req.headers['x-correlation-id'] as string) ?? randomUUID();
    req.headers['x-correlation-id'] = id;
    res.setHeader('x-correlation-id', id);
    next();
  }
}

Because the header is now guaranteed, the filter can trust it and downstream services receive it when you forward the header on outbound requests.

Best Practices

  • Throw typed domain exceptions carrying a stable code rather than raw Errors or string messages, so the cause survives across layers.
  • Centralize formatting in a single @Catch() global filter — it is the only component allowed to shape the client-facing body.
  • Return one consistent envelope (code, message, statusCode, correlationId, timestamp, path) for every error, validation and unexpected alike.
  • Never leak stack traces, SQL, or internal messages on 500 responses; log the detail server-side keyed by the correlation ID and return generic text.
  • Generate and propagate a correlation ID in middleware so logs, responses, and downstream calls can all be stitched together.
  • Map domain failures to accurate HTTP statuses (404, 409, 422) rather than collapsing everything into 400 or 500.
  • Treat error codes as a versioned API contract — document them and avoid renaming codes clients already depend on.
Last updated June 14, 2026
Was this helpful?