1|500
EX
Logical Exception
nestjs • code • status

NestJS xử lý lỗi logic bằng error code nội bộ và LogicalException

Cách chuẩn hoá lỗi logic trong NestJS bằng message code, LogicalException, HttpException và error code để debug nhanh hơn.

9 phút đọc16/06/2026

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:

  1. đặt một errorCode theo format như 1|500
  2. 1 là mã lỗi nội bộ của hệ thống
  3. 500HttpStatus
  4. truyền errorCode đó vào new LogicalException(errorCode, message)
  5. để 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|400
  • 2003|404
  • 3001|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 chung
  • 500 là status để trả ra ngoài
  • các mã như 1001, 1002 dù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 codehttp 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ộc
  • 1003|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|409 luôn là lỗi email trùng
  • 2001|403 luôn là lỗi permission
  • 3001|500 luô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|500 trong 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:

  1. một file error-codes.ts
  2. một class LogicalException
  3. 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à:

  1. chuẩn hoá errorCode dạng logical|http
  2. wrap vào LogicalException
  3. dùng constant thay vì hardcode
  4. 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.