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:
- define an
errorCodein a format like1|500 1is the internal logical error code500is theHttpStatus- pass that code into
new LogicalException(errorCode, message) - 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 code500: HTTP response status
You can scale it later:
1001|4002003|4043001|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:
1represents a generic system logical error group500is the HTTP status returned outside- codes like
1001,1002are 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 statusis for the HTTP/client layerlogical codeis for business logic, logging, and monitoring
Two errors can both be 400, but still mean very different things:
1002|400: missing required field1003|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|409always means duplicate email2001|403always means permission error3001|500always 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|500or 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:
- one
error-codes.ts - one
LogicalExceptionclass - 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:
- standardize error codes as
logical|http - wrap them in
LogicalException - use constants instead of scattered hardcoded strings
- 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.