Error Handling Architecture in NestJS

A pragmatic error handling architecture for NestJS: domain errors mapped to HTTP, a stable error code catalog, a standard envelope, and the Sentry and GraphQL bits that catch the messy edges.

Late on a Tuesday at the combat-sports tournament platform I CTO’d in London, the federation tech lead pinged me about a busted “create tournament” flow. The API was returning a 500 with the string "Internal server error" and a stack trace in the body. It was actually a perfectly reasonable validation failure (a tournament without a weight class), but our frontend had no way to know that. The mobile app showed a red banner that said “Something went wrong.” which is the universal sound of a backend team avoiding work.

That was the day I stopped treating error handling as an afterthought and started treating it like a contract.

This is the shape of the error handling I now reach for first in any NestJS service. Domain errors, a stable code catalog, one envelope, one filter, and observability that actually helps the on-call.

Why a generic filter is not enough

NestJS gives you HttpException and a built-in ExceptionsFilter. That is fine for tiny services. It falls over the moment two things are true: your domain layer is allowed to throw, and your callers (web, mobile, partner integrations) need to react differently to different failures.

You’ll see this play out as: the domain throws OrderAlreadyPaidError, somewhere up the stack it gets caught and rethrown as new BadRequestException('order already paid'), and a year later the mobile client is doing string matching on the message to decide whether to show a refund button. That is not an API. That’s a tire fire with a package.json.

The fix is to separate three concerns that get smushed together in most codebases. Domain errors live in the domain. HTTP status codes live in a mapping layer. The wire format the client sees lives in an envelope. The exception filter is the only place those three meet.

Domain errors that carry intent

Start with a base class in the domain layer. No NestJS imports here. The domain should not know it is being served over HTTP.

// src/domain/errors/domain-error.ts
export type ErrorCategory =
  | 'validation'
  | 'not_found'
  | 'conflict'
  | 'forbidden'
  | 'unauthenticated'
  | 'rate_limited'
  | 'unavailable'
  | 'internal';

export abstract class DomainError extends Error {
  abstract readonly code: string;
  abstract readonly category: ErrorCategory;
  readonly details?: Record<string, unknown>;

  constructor(message: string, details?: Record<string, unknown>) {
    super(message);
    this.name = this.constructor.name;
    this.details = details;
  }
}

export class OrderAlreadyPaidError extends DomainError {
  readonly code = 'ORDER_ALREADY_PAID';
  readonly category: ErrorCategory = 'conflict';
}

export class TournamentMissingWeightClassError extends DomainError {
  readonly code = 'TOURNAMENT_MISSING_WEIGHT_CLASS';
  readonly category: ErrorCategory = 'validation';
}

The thing the domain owns is code and category. Not status codes, not user-facing messages, not Sentry tags. Just intent.

A stable error code catalog

The code catalog is the thing your mobile team is going to depend on. Treat it like a public API. New code, new entry, semver bump on the SDK if you ship one. Removing a code is a breaking change.

I keep the catalog as a plain TS file checked into the repo, and I generate JSON from it for the mobile and frontend repos. That generation step is what stops “well it’s just a string” drift.

// src/errors/catalog.ts
export const ERROR_CATALOG = {
  ORDER_ALREADY_PAID: {
    httpStatus: 409,
    category: 'conflict',
    retryable: false,
  },
  TOURNAMENT_MISSING_WEIGHT_CLASS: {
    httpStatus: 422,
    category: 'validation',
    retryable: false,
  },
  PAYMENT_PROVIDER_TIMEOUT: {
    httpStatus: 504,
    category: 'unavailable',
    retryable: true,
  },
  UNEXPECTED: {
    httpStatus: 500,
    category: 'internal',
    retryable: false,
  },
} as const satisfies Record<string, ErrorCatalogEntry>;

export type ErrorCode = keyof typeof ERROR_CATALOG;

export interface ErrorCatalogEntry {
  httpStatus: number;
  category: ErrorCategory;
  retryable: boolean;
}

retryable is the unsung hero here. The client doesn’t have to guess. If the catalog says retryable, the SDK retries with backoff. If it doesn’t, the SDK surfaces it. No more clients hammering a 409 forever because someone wrote ambiguous handling.

One standard envelope across transports

One shape. Every error. Including the unhandled ones.

// src/errors/envelope.ts
export interface ErrorEnvelope {
  error: {
    code: string;
    message: string;
    category: ErrorCategory;
    details?: Record<string, unknown>;
    requestId: string;
    retryable: boolean;
  };
}

requestId is non-negotiable. It is the single thing that turns a customer support ticket into a Datadog query in under a minute. Generate it in a middleware, stamp it on the response header, log it on every line, and put it in the envelope.

The exception filter doing the actual mapping

Here is where the three concerns meet. Catch domain errors, look them up in the catalog, build the envelope, return the right HTTP status. Anything we don’t recognize falls through as UNEXPECTED and gets logged loudly.

// src/errors/domain-exception.filter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { DomainError } from '../domain/errors/domain-error';
import { ERROR_CATALOG, ErrorCode } from './catalog';
import * as Sentry from '@sentry/node';

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

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

    if (exception instanceof DomainError) {
      const entry =
        ERROR_CATALOG[exception.code as ErrorCode] ?? ERROR_CATALOG.UNEXPECTED;

      this.logger.warn({
        msg: 'domain_error',
        code: exception.code,
        requestId,
        path: req.url,
        details: exception.details,
      });

      return res.status(entry.httpStatus).json({
        error: {
          code: exception.code,
          message: exception.message,
          category: entry.category,
          details: exception.details,
          requestId,
          retryable: entry.retryable,
        },
      });
    }

    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      const body = exception.getResponse();
      return res.status(status).json({
        error: {
          code: status >= 500 ? 'UNEXPECTED' : 'HTTP_ERROR',
          message: typeof body === 'string' ? body : (body as any).message,
          category: status >= 500 ? 'internal' : 'validation',
          requestId,
          retryable: status >= 500,
        },
      });
    }

    Sentry.captureException(exception, (scope) => {
      scope.setTag('request_id', requestId);
      scope.setTag('path', req.url);
      scope.setLevel('error');
      return scope;
    });

    this.logger.error({
      msg: 'unhandled_exception',
      requestId,
      path: req.url,
      err: exception,
    });

    return res.status(500).json({
      error: {
        code: 'UNEXPECTED',
        message: 'An unexpected error occurred.',
        category: 'internal',
        requestId,
        retryable: false,
      },
    });
  }
}

A couple of details that look small but aren’t. The Sentry capture only fires on the unknown path. Domain errors are not Sentry-worthy by default. If you fire Sentry on every 409, the on-call mutes Sentry, and that is the day a real bug goes unseen.

What this catches that the default filter misses

A war story that taught me to add the read-after-write idea to error handling specifically. At the creator-tools company where I spent the last few years, the branded-mobile-app pipeline had a backend endpoint that submitted releases to Apple. Apple’s Connect API returned 200 OK but sometimes the submission was silently dropped on their side. Our pipeline had basic retry on 5xx. We extended it to retry on a “stuck” state. That made everything worse, because Apple started seeing duplicates and creators ended up with two competing review records.

The real fix was a domain error called UpstreamSubmissionUnconfirmedError with retryable: false in the catalog, plus a separate verification step that did a GET against the App Store Connect resource and only marked the submission successful after that. The point: the catalog gave us a place to say “this error means do not blindly retry,” and the filter ensured every consumer of the API saw the same retryability flag. Before that, retry behavior lived in three different clients, each making it up.

Sentry context that actually helps debugging

The minimal Sentry context I attach to every unhandled error: request_id, path, tenantId, userId, the route handler name, and a redacted version of the request body. The redaction is the part teams skip and then regret.

// src/errors/sentry-context.ts
import { Request } from 'express';

const SENSITIVE_KEYS = new Set([
  'password', 'token', 'authorization', 'cookie', 'secret', 'apiKey',
]);

export function redact(value: unknown, depth = 0): unknown {
  if (depth > 4 || value === null || typeof value !== 'object') return value;
  if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));

  return Object.fromEntries(
    Object.entries(value as Record<string, unknown>).map(([k, v]) => [
      k,
      SENSITIVE_KEYS.has(k.toLowerCase()) ? '[redacted]' : redact(v, depth + 1),
    ]),
  );
}

export function buildSentryContext(req: Request) {
  return {
    requestId: req.headers['x-request-id'],
    path: req.url,
    method: req.method,
    tenantId: (req as any).tenantId,
    userId: (req as any).user?.id,
    body: redact(req.body),
  };
}

Run that through your filter, send it on the unhandled path only. Your inbox will thank you in six months.

GraphQL error extensions done right

If you have a GraphQL surface, the same catalog plugs into the GraphQL error formatter cleanly. The trick is to put code, category, retryable, and requestId into extensions so the Apollo Client side can read them without inspecting message.

// src/graphql/format-error.ts
import { GraphQLError, GraphQLFormattedError } from 'graphql';
import { ERROR_CATALOG, ErrorCode } from '../errors/catalog';
import { DomainError } from '../domain/errors/domain-error';

export function formatGraphQLError(err: GraphQLError): GraphQLFormattedError {
  const original = err.originalError;
  if (original instanceof DomainError) {
    const entry =
      ERROR_CATALOG[original.code as ErrorCode] ?? ERROR_CATALOG.UNEXPECTED;
    return {
      message: original.message,
      path: err.path,
      extensions: {
        code: original.code,
        category: entry.category,
        retryable: entry.retryable,
        requestId: (err.extensions?.requestId as string) ?? 'unknown',
      },
    };
  }
  return {
    message: 'Unexpected error',
    extensions: { code: 'UNEXPECTED', category: 'internal', retryable: false },
  };
}

Wire it via the formatError option on the Apollo driver. The frontend gets a stable contract regardless of transport. REST or GraphQL, same codes, same retryable semantics.

Takeaways

  • Keep domain errors in the domain layer with a code and a category. No HTTP, no Sentry, no logger imports there.
  • Maintain a single error code catalog with HTTP status, category, and a retryable flag. Treat it like a public API.
  • Ship one envelope across REST and GraphQL. Include requestId everywhere.
  • The exception filter is the only place catalog, status, and envelope meet. One filter, not five.
  • Send Sentry on the unknown path, not on domain errors. Add redacted context.
  • The retryable flag is the difference between “the client recovers” and “the client makes things worse.”

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.