Skip to content

Error Handling ​

CleanSlice uses a centralized error handling system. You define domain-specific error classes in each slice, throw them from services and gateways, and a global interceptor catches everything and returns standardized HTTP responses. Controllers never need try/catch blocks.

How Errors Flow ​

Controller                        (no try/catch needed)
    │
    ▼
Service (domain layer)            throws UserNotFoundError, etc.
    │
    ▼
Gateway (data layer)              catches Prisma errors, throws domain errors
    │
    ▼
ErrorHandlingInterceptor          catches all errors globally
    │
    ▼
Standardized JSON response        { code, statusCode, message, timestamp, path }

Errors bubble up through the layers. The ErrorHandlingInterceptor (registered globally in main.ts) catches them all and formats a consistent response.

The Setup: Base Error Infrastructure ​

The setup/error slice provides the foundation that all other slices build on.

BaseError Class ​

Every domain error extends BaseError, which itself extends NestJS HttpException:

typescript
import { HttpException } from '@nestjs/common';
import { ErrorCodes } from './error.types';

export abstract class BaseError extends HttpException {
  public code: ErrorCodes = ErrorCodes.UNEXPECTED_ERROR;
  public override cause: Error;

  constructor(
    message: string,
    statusCode: number = 500,
    options?: { cause?: Error },
  ) {
    super(message, statusCode, { cause: options?.cause });
    this.cause = options?.cause || new Error(message);
    this.name = this.constructor.name;

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }

  getStatus(): number {
    return super.getStatus();
  }

  getCode(): ErrorCodes {
    return this.code;
  }

  getCause(): Error {
    return this.cause || new Error('No cause provided');
  }
}

ErrorCodes Enum ​

Define all error codes in a central enum. Generic codes live in the setup slice; domain-specific codes can be added as your application grows:

typescript
export enum ErrorCodes {
  // Generic errors
  UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
  VALIDATION_ERROR = 'VALIDATION_ERROR',
  NOT_FOUND = 'NOT_FOUND',
  UNAUTHORIZED = 'UNAUTHORIZED',
  FORBIDDEN = 'FORBIDDEN',
  CONFLICT = 'CONFLICT',
  BAD_REQUEST = 'BAD_REQUEST',

  // Domain-specific errors (add yours here)
  USER_NOT_FOUND = 'USER_NOT_FOUND',
  USER_NOT_AUTHORIZED = 'USER_NOT_AUTHORIZED',
  USER_EXISTS = 'USER_EXISTS',
  USER_BANNED = 'USER_BANNED',
}

export interface IErrorResponse {
  code: string;
  statusCode: number;
  message: string;
  timestamp: string;
  path?: string;
  details?: unknown;
}

Error Handling Interceptor ​

This interceptor is registered globally and catches every thrown error:

typescript
import {
  CallHandler, ExecutionContext, Injectable, NestInterceptor,
  HttpException, HttpStatus, Logger,
} from '@nestjs/common';
import { catchError, Observable, throwError } from 'rxjs';
import { Response } from 'express';
import { BaseError } from '../domain/base.error';
import { IErrorResponse } from '../domain/error.types';

@Injectable()
export class ErrorHandlingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(ErrorHandlingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError((error) => {
        const ctx = context.switchToHttp();
        const response = ctx.getResponse<Response>();
        const request = ctx.getRequest();

        const statusCode =
          error instanceof HttpException || error instanceof BaseError
            ? error.getStatus()
            : HttpStatus.INTERNAL_SERVER_ERROR;

        const message =
          error.response?.message || error.message || 'An unexpected error occurred';

        const code =
          error instanceof BaseError
            ? error.getCode()
            : error.code || 'UNEXPECTED_ERROR';

        const errorResponse: IErrorResponse = {
          code,
          statusCode,
          message,
          timestamp: new Date().toISOString(),
          path: request.url,
        };

        if (error.details) {
          errorResponse.details = error.details;
        }

        this.logger.error(
          `[${code}] ${message}`,
          error.stack,
          JSON.stringify({ path: request.url, method: request.method, statusCode }),
        );

        response.status(statusCode).json(errorResponse);
        return throwError(() => error);
      }),
    );
  }
}

Creating Domain Errors ​

Each slice defines its own errors in domain/errors/. Every error extends BaseError and sets an appropriate HTTP status code and error code.

Error with Dynamic Message ​

typescript
import { BaseError } from '#/setup/error';
import { ErrorCodes } from '#/setup/error';
import { HttpStatus } from '@nestjs/common';

export class UserNotFoundError extends BaseError {
  public readonly code = ErrorCodes.USER_NOT_FOUND;

  constructor(userId: string, options?: { cause: Error }) {
    super(
      `User with ID '${userId}' was not found.`,
      HttpStatus.NOT_FOUND,
      options,
    );
  }
}

Error with Default Message ​

typescript
import { BaseError } from '#/setup/error';
import { ErrorCodes } from '#/setup/error';
import { HttpStatus } from '@nestjs/common';

export class UserNotAuthorizedError extends BaseError {
  public readonly code = ErrorCodes.USER_NOT_AUTHORIZED;

  constructor(message?: string, options?: { cause: Error }) {
    super(
      message ?? 'Username or password was incorrect.',
      HttpStatus.BAD_REQUEST,
      options,
    );
  }
}

Error with Details ​

For validation errors that carry structured detail data:

typescript
import { BaseError } from '#/setup/error';
import { ErrorCodes } from '#/setup/error';
import { HttpStatus } from '@nestjs/common';

export class ValidationError extends BaseError {
  public readonly code = ErrorCodes.VALIDATION_ERROR;
  public readonly details: unknown;

  constructor(message: string, details?: unknown, options?: { cause: Error }) {
    super(message, HttpStatus.BAD_REQUEST, options);
    this.details = details;
  }
}

Error File Structure ​

slices/user/
├── domain/
│   ├── user.types.ts
│   ├── user.service.ts
│   ├── user.gateway.ts
│   └── errors/
│       ├── index.ts
│       ├── userNotFound.error.ts
│       ├── userNotAuthorized.error.ts
│       ├── userExists.error.ts
│       └── userBanned.error.ts
└── data/
    └── user.gateway.ts

Naming: File is {errorName}.error.ts (camelCase). Class is {ErrorName}Error (PascalCase). Error codes are SCREAMING_SNAKE_CASE.

Throwing Errors in Services ​

Services throw domain errors for business rule violations. This is the primary place errors originate:

typescript
import { Injectable } from '@nestjs/common';
import { IUserGateway } from './user.gateway';
import { IUserData } from './user.types';
import { UserNotFoundError } from './errors/userNotFound.error';
import { UserAlreadyExistsError } from './errors/userExists.error';

@Injectable()
export class UserService {
  constructor(private readonly userGateway: IUserGateway) {}

  async getUserById(id: string): Promise<IUserData> {
    const user = await this.userGateway.getUser(id);
    if (!user) {
      throw new UserNotFoundError(id);
    }
    return user;
  }

  async createUser(data: ICreateUserData): Promise<IUserData> {
    const existing = await this.userGateway.getUserByEmail(data.email);
    if (existing) {
      throw new UserAlreadyExistsError(data.email);
    }
    return this.userGateway.createUser(data);
  }
}

Throwing Errors in Gateways ​

Gateways catch raw database/external errors and convert them to domain errors. This prevents Prisma-specific errors from leaking into the domain layer:

typescript
import { Injectable } from '@nestjs/common';
import { PrismaService } from '#/setup/prisma';
import { UserNotFoundError } from '../domain/errors/userNotFound.error';
import { UserMapper } from './user.mapper';

@Injectable()
export class UserGateway extends IUserGateway {
  constructor(
    private prisma: PrismaService,
    private mapper: UserMapper,
  ) {
    super();
  }

  async deleteUser(id: string): Promise<boolean> {
    try {
      await this.prisma.user.delete({ where: { id } });
      return true;
    } catch (error) {
      if (error.code === 'P2025') {
        // Prisma "Record not found" error
        throw new UserNotFoundError(id, { cause: error as Error });
      }
      throw error;
    }
  }
}

TIP

Pass the original error as cause when converting errors. This preserves the stack trace for debugging while presenting a clean domain error to the consumer.

Controllers: No Error Handling ​

Controllers do not catch errors. The interceptor handles everything:

typescript
@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get(':id')
  @ApiOperation({ operationId: 'getUserById' })
  async getUser(@Param('id') id: string) {
    // If user not found, UserNotFoundError propagates
    // to the ErrorHandlingInterceptor automatically
    return this.userService.getUserById(id);
  }
}

Response Format ​

All errors produce the same JSON structure:

json
{
  "code": "USER_NOT_FOUND",
  "statusCode": 404,
  "message": "User with ID 'usr_123' was not found.",
  "timestamp": "2025-01-15T10:30:00.000Z",
  "path": "/users/usr_123"
}

HTTP Status Code Reference ​

StatusWhen to UseExample Errors
400Bad request / validation failureValidationError, UserNotAuthorizedError
401Missing or invalid authenticationUnauthorizedError
403Authenticated but not permittedUserBannedError, ForbiddenError
404Resource not foundUserNotFoundError
409Conflict (duplicate resource)UserAlreadyExistsError
500Unexpected server errorDatabaseError, unhandled exceptions

Module Registration ​

Register the interceptor globally, either in main.ts or through a module:

typescript
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ErrorHandlingInterceptor } from './interceptors/error-handling.interceptor';

@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: ErrorHandlingInterceptor,
    },
  ],
})
export class ErrorModule {}

Then import it in app.module.ts:

typescript
@Module({
  imports: [
    ErrorModule,
    // ... other modules
  ],
})
export class AppModule {}

Barrel Export ​

typescript
export * from './userNotFound.error';
export * from './userNotAuthorized.error';
export * from './userExists.error';
export * from './userBanned.error';

What's Next? ​

  • Services -- Where most domain errors are thrown
  • Gateways -- Where database errors are caught and converted
  • Controllers -- The layer that lets errors propagate
  • Slice Structure -- Where error files fit in the slice layout

Built with CleanSlice