Một hệ thống backend nhỏ có thể sống ổn với kiểu throw lỗi tự do:
- chỗ thì
throw new BadRequestException('Invalid data') - chỗ thì
throw new Error('User not found') - chỗ khác lại return string hoặc object lỗi khác format
Nhưng khi hệ thống bắt đầu có nhiều service, nhiều flow logic và nhiều dev cùng sửa, cách này rất nhanh dẫn tới một vấn đề khó chịu:
- nhìn log không biết lỗi thuộc nhóm nào
- message lỗi thay đổi liên tục nên khó monitor
- cùng một lỗi logic nhưng mỗi nơi throw một kiểu
- code client hoặc service khác rất khó map lỗi ổn định
Một cách thực dụng hơn là chuẩn hoá lỗi logic bằng error code nội bộ, rồi bọc nó trong một LogicalException kế thừa hoặc wrap lại HttpException.
Kết luận nhanh
Nếu bạn muốn một pattern gọn và đủ dùng:
- đặt một
errorCodetheo format như1|500 1là mã lỗi nội bộ của hệ thống500làHttpStatus- truyền
errorCodeđó vàonew LogicalException(errorCode, message) - để mọi nơi throw lỗi logic theo cùng một format
Cách này không làm hệ thống “xịn” một cách hình thức, nhưng nó giúp log, debug và trace lỗi rõ hơn rất nhiều.
Khi nào pattern này thực sự hữu ích?
Pattern này hợp khi:
- bạn có nhiều lỗi business logic lặp lại ở nhiều service
- cần một mã lỗi ổn định để frontend hoặc service khác nhận diện
- muốn log ra lỗi có format thống nhất
- không muốn mỗi chỗ tự đặt một message khác nhau
Nó đặc biệt hữu ích với NestJS vì Nest đã có sẵn HttpException, nên bạn chỉ cần thêm một lớp mỏng để chuẩn hoá.
Vấn đề của cách throw lỗi tự do
Ví dụ:
throw new BadRequestException('Email already exists');
throw new BadRequestException('User duplicated');
throw new ConflictException('Duplicated email');
Ba dòng trên có thể cùng chỉ một loại lỗi logic, nhưng:
- message khác nhau
- status có thể khác nhau
- log aggregate rất khó nhóm
Khi có bug production, bạn thường muốn biết:
- đây là lỗi loại gì
- có phải lỗi logic đã biết không
- HTTP status tương ứng là gì
- chỗ nào trong code đang throw loại lỗi đó
errorCode nội bộ giúp gom các thứ đó lại thành một format thống nhất hơn.
Một format đơn giản: 1|500
Ý tưởng:
- phần trước dấu
|là mã lỗi nội bộ - phần sau dấu
|là HTTP status
Ví dụ:
1|500
Hiểu là:
1: mã lỗi hệ thống hoặc mã logic nội bộ500: response HTTP trả về
Bạn có thể mở rộng sau này:
1001|4002003|4043001|409
Điểm quan trọng là format phải cố định.
Cách viết LogicalException bọc HttpException
Một version tối giản:
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;
}
}
Với class này, bạn đã có một chỗ chuẩn để nhét:
- message
- errorCode
- logicalCode
- http status
Nếu muốn rõ hơn nữa, bạn có thể xem 1|500 là mã mặc định cho lỗi logic hệ thống:
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;
Trong đó:
1đại diện cho một nhóm lỗi logic hệ thống chung500là status để trả ra ngoài- các mã như
1001,1002dùng cho từng case business cụ thể
Cách dùng trong service
Ví dụ:
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 };
}
}
Lúc này thay vì chỉ biết “409 conflict”, bạn còn biết đây là đúng lỗi 1001.
Vì sao nên tách logical code và http status?
Vì 2 thứ này phục vụ 2 mục tiêu khác nhau:
http statusđể client hoặc HTTP layer hiểu kết quảlogical codeđể business layer và monitoring hiểu đây là loại lỗi gì
Ví dụ 2 lỗi cùng là 400, nhưng bản chất khác hẳn:
1002|400: request thiếu field bắt buộc1003|400: trạng thái entity không hợp lệ cho action hiện tại
Nếu chỉ nhìn HTTP status, bạn mất rất nhiều ngữ cảnh.
Nên trả response ra ngoài như thế nào?
Một response lỗi có thể là:
{
"statusCode": 409,
"message": "Email already exists",
"errorCode": "1001|409",
"logicalCode": 1001
}
Điểm mạnh của format này:
- client có thể đọc
statusCode - frontend có thể map theo
logicalCode - log dễ group hơn
Có nên parse errorCode kiểu string như vậy không?
Có, nếu bạn muốn một pattern đơn giản, dễ nhìn và dễ log.
Ví dụ khi nhìn log:
LogicalException: 1001|409 - Email already exists
bạn đọc ngay được:
- lỗi nội bộ là
1001 - HTTP trả
409
Nếu team lớn hơn, bạn có thể nâng cấp sang enum hoặc object constants.
Nên đặt error code ở đâu?
Đừng hardcode rải rác khắp nơi.
Nên gom lại:
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;
Khi dùng:
throw new LogicalException(ERROR_CODES.EMAIL_EXISTS, 'Email already exists');
throw new LogicalException(ERROR_CODES.SYSTEM_LOGIC_ERROR, 'Unexpected logical error');
Lợi ích:
- tránh typo
- đổi mã lỗi ở một chỗ
- dễ search ngược trong codebase
Nếu muốn xác định nhanh vị trí trong code thì làm sao?
errorCode không thay thế hoàn toàn stack trace, nhưng nó giúp khoanh vùng rất nhanh.
Ví dụ:
1001|409luôn là lỗi email trùng2001|403luôn là lỗi permission3001|500luôn là lỗi logic ở payment flow
Khi đó chỉ cần thấy 3001|500 trong log là bạn biết ngay:
- lỗi thuộc nhóm payment
- không phải lỗi random
- có thể grep thẳng
3001|500trong codebase
Đây là lý do pattern này thực dụng hơn việc chỉ dựa vào message text.
Có nên thêm exception filter riêng không?
Nên, nếu bạn muốn response ra ngoài luôn đồng đều.
Ví dụ filter đơn giản:
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);
}
}
Nếu toàn bộ lỗi logic đã được wrap bằng LogicalException, output sẽ dễ giữ một format thống nhất hơn.
Những lỗi dễ gặp khi dùng pattern này
1. Chỉ có error code nhưng message quá mơ hồ
Ví dụ:
throw new LogicalException('1|500', 'Error');
Code có ích, nhưng message quá nghèo. Nên giữ message đủ rõ để đọc log nhanh.
2. Mỗi dev tự đặt một format errorCode khác nhau
Ví dụ chỗ thì:
1001|409
chỗ khác lại:
USER_001
điều đó sẽ phá consistency ngay từ đầu. Đã chọn format nào thì nên dùng thống nhất.
3. Dùng HTTP status sai với bản chất lỗi
Ví dụ lỗi duplicate mà lại trả 500. Khi đó logicalCode đúng nhưng HTTP layer lại gây hiểu nhầm cho client.
4. Hardcode error code rải rác
Nếu không gom constant, một lúc nào đó bạn sẽ không biết 1007|400 đang được dùng ở đâu.
Một version thực dụng hơn cho team vừa và nhỏ
Nếu chưa muốn design quá nhiều, chỉ cần:
- một file
error-codes.ts - một class
LogicalException - một convention chung cho toàn team
Ví dụ:
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');
Chỉ cần vậy là log, debug và trace lỗi đã dễ hơn rất nhiều so với throw tự do.
Kết luận
Nếu hệ thống NestJS của bạn bắt đầu có nhiều lỗi logic và message lỗi đang bị rối, pattern LogicalException + errorCode là một bước nâng cấp rất đáng làm.
Flow gọn nhất là:
- chuẩn hoá
errorCodedạnglogical|http - wrap vào
LogicalException - dùng constant thay vì hardcode
- giữ response lỗi cùng một format
Với cách này, bạn không chỉ biết “lỗi gì”, mà còn biết “đây là loại lỗi logic nào của hệ thống” nhanh hơn rất nhiều.