1|500
EX
Logical Exception
nestjs • code • status

NestJS LogicalException and internal error codes

Standardize NestJS logic errors with message codes, LogicalException, HttpException, and internal error codes for faster debugging.

9 min read16/06/2026

Small backends can survive for a while with free-form exceptions:

  • one place throws new BadRequestException('Invalid data')
  • another throws new Error('User not found')
  • another returns a completely different error object

But once the system grows, this quickly turns into a maintenance problem:

  • logs are hard to group
  • error messages drift over time
  • the same logical error gets thrown in different formats
  • frontend or other services cannot reliably map failures

A more practical pattern is to standardize logical errors with an internal error code, then wrap everything inside a LogicalException built on top of HttpException.

Quick conclusion

If you want a compact pattern:

  1. define an errorCode in a format like 1|500
  2. 1 is the internal logical error code
  3. 500 is the HttpStatus
  4. pass that code into new LogicalException(errorCode, message)
  5. make every logical error follow the same shape

This is not complexity for its own sake. It makes logs, debugging, and monitoring much easier.

When is this pattern actually useful?

It helps most when:

  • you have repeated business logic errors across multiple services
  • you want a stable code that frontend or other services can rely on
  • you want log output in one predictable format
  • you do not want every developer inventing a different error message style

NestJS is a good fit because it already gives you HttpException, so you only need a thin wrapper.

The problem with free-form exceptions

Example:

throw new BadRequestException('Email already exists');
throw new BadRequestException('User duplicated');
throw new ConflictException('Duplicated email');

These may represent the same logical issue, but:

  • the message changes
  • the status may change
  • logs are harder to aggregate

In production, you usually want to know:

  • what kind of error this is
  • whether it is a known logical error
  • what HTTP status it maps to
  • where in the codebase that error pattern lives

An internal errorCode gives you a stable anchor for all of that.

A simple format: 1|500

The idea:

  • the part before | is the internal logical code
  • the part after | is the HTTP status

Example:

1|500

Meaning:

  • 1: system-level or internal logical error code
  • 500: HTTP response status

You can scale it later:

  • 1001|400
  • 2003|404
  • 3001|409

The important part is that the format stays fixed.

Building a LogicalException around HttpException

A minimal version:

import { HttpException, HttpStatus } from '@nestjs/common';

export class LogicalException extends HttpException {
  public readonly errorCode: string;
  public readonly logicalCode: number;
  public readonly httpCode: number;

  constructor(errorCode: string, message = 'Logical exception') {
    const [logicalCodeRaw, httpCodeRaw] = errorCode.split('|');

    const logicalCode = Number(logicalCodeRaw);
    const httpCode = Number(httpCodeRaw);

    super(
      {
        message,
        errorCode,
        logicalCode,
        statusCode: httpCode,
      },
      httpCode || HttpStatus.INTERNAL_SERVER_ERROR,
    );

    this.errorCode = errorCode;
    this.logicalCode = logicalCode;
    this.httpCode = httpCode || HttpStatus.INTERNAL_SERVER_ERROR;
  }
}

This gives you one standard place to carry:

  • message
  • errorCode
  • logicalCode
  • HTTP status

If you want an even clearer convention, treat 1|500 as the default system logical error:

export const ERROR_CODES = {
  SYSTEM_LOGIC_ERROR: '1|500',
  EMAIL_EXISTS: '1001|409',
  USER_NOT_FOUND: '1002|404',
  INVALID_USER_STATUS: '1003|400',
} as const;

Here:

  • 1 represents a generic system logical error group
  • 500 is the HTTP status returned outside
  • codes like 1001, 1002 are for specific business cases

Using it in a service

Example:

import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  async createUser(email: string) {
    const existed = true;

    if (existed) {
      throw new LogicalException(ERROR_CODES.EMAIL_EXISTS, 'Email already exists');
    }

    return { ok: true };
  }
}

Now you know more than just "409 conflict". You also know this is specifically logical error 1001.

Why split logical code and HTTP status?

Because they serve different purposes:

  • http status is for the HTTP/client layer
  • logical code is for business logic, logging, and monitoring

Two errors can both be 400, but still mean very different things:

  • 1002|400: missing required field
  • 1003|400: invalid entity state for this action

If you only look at HTTP status, you lose too much context.

What should the response look like?

A consistent response can look like:

{
  "statusCode": 409,
  "message": "Email already exists",
  "errorCode": "1001|409",
  "logicalCode": 1001
}

Benefits:

  • clients understand statusCode
  • frontend can switch on logicalCode
  • logs are much easier to group

Is string parsing like 1|500 a good idea?

Yes, if your goal is something simple, readable, and easy to grep in logs.

Example log line:

LogicalException: 1001|409 - Email already exists

You can immediately read:

  • internal logical error 1001
  • HTTP status 409

For larger teams, you can later evolve this into enums or structured constants.

Where should error codes live?

Do not hardcode them everywhere.

Put them in one place:

export const ERROR_CODES = {
  SYSTEM_LOGIC_ERROR: '1|500',
  EMAIL_EXISTS: '1001|409',
  USER_NOT_FOUND: '1002|404',
  INVALID_USER_STATUS: '1003|400',
} as const;

Then use them like this:

throw new LogicalException(ERROR_CODES.EMAIL_EXISTS, 'Email already exists');
throw new LogicalException(ERROR_CODES.SYSTEM_LOGIC_ERROR, 'Unexpected logical error');

Benefits:

  • fewer typos
  • one place to update codes
  • easier codebase search

How does this help identify code locations faster?

The errorCode does not replace the stack trace, but it shortens the path to the right area of the code.

Example:

  • 1001|409 always means duplicate email
  • 2001|403 always means permission error
  • 3001|500 always means a payment-flow logic issue

If you see 3001|500 in logs, you already know:

  • the error belongs to payment logic
  • it is not random noise
  • you can grep directly for 3001|500 or its constant name

That is much more practical than relying on free-form message text.

Should you add a custom exception filter too?

Yes, if you want all outgoing error responses to stay consistent.

Simple example:

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const status = exception.getStatus();
    const body = exception.getResponse();

    response.status(status).json(body);
  }
}

If your logical errors all go through LogicalException, the output becomes much easier to keep stable.

Common mistakes with this pattern

1. Having a code but a useless message

Example:

throw new LogicalException('1|500', 'Error');

The code helps, but the message is too weak for fast debugging. The message should still be readable.

2. Letting every developer invent a different error code format

Example:

1001|409

in one place, but:

USER_001

somewhere else. That breaks consistency immediately.

3. Using the wrong HTTP status for the logical error

For example, a duplicate-resource error returning 500. The internal code may be correct, but the HTTP layer now misleads the client.

4. Hardcoding codes everywhere

Without central constants, one day you will not know where 1007|400 is used.

A practical version for a small or mid-sized team

If you do not want to over-design this yet, just start with:

  1. one error-codes.ts
  2. one LogicalException class
  3. one shared convention

Example:

export const ERROR_CODES = {
  DEFAULT: '1|500',
  EMAIL_EXISTS: '1001|409',
  USER_NOT_FOUND: '1002|404',
};
throw new LogicalException(ERROR_CODES.SYSTEM_LOGIC_ERROR, 'Unexpected logical error');
throw new LogicalException(ERROR_CODES.USER_NOT_FOUND, 'User not found');

Even that simple setup is already far better than ad hoc exceptions everywhere.

Conclusion

If your NestJS system is starting to accumulate messy logical errors, the LogicalException + errorCode pattern is a very practical upgrade.

The compact flow is:

  1. standardize error codes as logical|http
  2. wrap them in LogicalException
  3. use constants instead of scattered hardcoded strings
  4. keep all error responses in one predictable format

That way, you do not just know that "an error happened". You know which logical error class the system is dealing with much faster.