Java OO & Functional
15/38
Bài 15 / 38~12 phútException HandlingLộ trình · chặng 04/21Miễn phí… lượt xem

Custom exception — thiết kế phân cấp exception nghiệp vụ

Khi nào tạo exception riêng, cấu trúc class mang context dữ liệu, phân cấp domain hierarchy và tích hợp Global Exception Handler trong ứng dụng.

TL;DR: Không tạo custom exception chỉ để "đặt tên đẹp". Ngoại lệ tùy biến chỉ thực sự cần thiết khi tầng gọi (caller) cần phân biệt các tình huống nghiệp vụ khác nhau để xử lý riêng biệt, hoặc khi ngoại lệ cần mang theo dữ liệu ngữ cảnh (payload) để ghi log và theo dõi chỉ số. Thiết kế phân cấp exception theo domain dạng 2 tầng (Base Domain Exception -> Concrete Business Exceptions) kết hợp cùng Global Exception Handler là mô hình chuẩn mực trong các hệ thống Java hiện đại.

Ở bài trước, bạn đã học cách bảo vệ tài nguyên hệ thống bằng try-with-resources. Bài học này sẽ chuyển trọng tâm từ tầng kỹ thuật sang tầng kiến trúc nghiệp vụ: làm thế nào để thông báo các vi phạm quy tắc kinh doanh một cách trong sáng và có cấu trúc.

Trong nhiều dự án thực tế, người ta thường thấy các lập trình viên lạm dụng throw new RuntimeException("Có lỗi xảy ra") rải rác khắp nơi. Hậu quả là tầng Controller không thể biết mã lỗi HTTP tương ứng là 400, 404 hay 409, còn hệ thống ghi log thì tràn ngập những dòng chữ mơ hồ không thể bóc tách dữ liệu để truy vết.

1. Khi nào bạn thực sự nên tạo Custom Exception?

Đừng vội vàng tạo một lớp ngoại lệ mới nếu không có lý do kiến trúc rõ ràng. Bạn chỉ nên tạo custom exception khi thỏa mãn ít nhất một trong ba tiêu chí sau:

Tiêu chí 1: Tầng gọi cần phân biệt tình huống để xử lý khác nhau

So sánh hai cách viết sau trong hệ thống quản lý mượn trả:

Java
// PHONG CACH XAU: Bat ngoai le chung va so sanh chuoi message (Anti-pattern)
try {
    loanService.borrowItem(memberId, itemId);
} catch (RuntimeException e) {
    if (e.getMessage().contains("khong tim thay")) {
        return ResponseEntity.status(404).build();
    } else if (e.getMessage().contains("da duoc muon")) {
        return ResponseEntity.status(409).build();
    }
}

// PHONG CACH CHUAN MUC: Phan biet ro rang qua kieu du lieu (Type-safe)
try {
    loanService.borrowItem(memberId, itemId);
} catch (ItemNotFoundException e) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.toErrorDto());
} catch (ItemAlreadyLoanedException e) {
    return ResponseEntity.status(HttpStatus.CONFLICT).body(e.toErrorDto());
}

Bắt lỗi bằng chuỗi message.contains(...) rất dễ gãy khi ai đó sửa lại câu chữ thông điệp hoặc hỗ trợ đa ngôn ngữ (i18n).

Tiêu chí 2: Cần đính kèm dữ liệu ngữ cảnh (Context Payload)

Ngoại lệ chuẩn của JDK chỉ chứa một chuỗi String message. Trong khi đó, một sự cố nghiệp vụ cần mang theo các định danh cụ thể: itemId, memberId, maxAllowedDays để đẩy vào hệ thống Structured Logging (ElasticSearch/Loki) hoặc trả về JSON chi tiết cho client.

Tiêu chí 3: Ngoại lệ đại diện cho một khái niệm nghiệp vụ cốt lõi

Ví dụ: MemberSuspendedException, MaxBorrowLimitExceededException. Tên lớp thể hiện trực tiếp luật kinh doanh của hệ thống.

2. Cấu trúc chuẩn mực của một lớp Custom Exception

Một lớp custom exception chuyên nghiệp trong domain thư viện:

Java
public class ItemNotFoundException extends RuntimeException {
    private final String itemId;
    private final Instant timestamp;

    public ItemNotFoundException(String itemId) {
        super(String.format("Khong tim thay an pham voi ma ID: '%s'", itemId));
        this.itemId = itemId;
        this.timestamp = Instant.now();
    }

    public ItemNotFoundException(String itemId, Throwable cause) {
        super(String.format("Khong tim thay an pham voi ma ID: '%s'", itemId), cause);
        this.itemId = itemId;
        this.timestamp = Instant.now();
    }

    public String getItemId() { return itemId; }
    public Instant getTimestamp() { return timestamp; }
}

Các quy tắc thiết kế:

  • Kế thừa từ RuntimeException (Unchecked Exception) để không làm ô nhiễm chữ ký hàm ở các tầng trên.
  • Khai báo các trường dữ liệu ngữ cảnh là final để đảm bảo tính bất biến.
  • Cung cấp ít nhất hai constructor: một nhận thông tin tạo mới, một nhận kèm Throwable cause để bảo toàn chuỗi lỗi gốc.

3. Thiết kế phân cấp ngoại lệ theo Domain (Domain Hierarchy)

Đừng tạo các exception rời rạc. Hãy xây dựng một cây phân cấp nông (không quá 2 tầng) để gom nhóm logic:

Cây phân cấp ngoại lệ domain nông tối đa hai tầng kế thừa từ LibraryDomainException

Hiện thực mã nguồn:

Java
// 1. Lop cha truu tuong dai dien cho toan bo domain thu vien
public abstract class LibraryDomainException extends RuntimeException {
    private final String errorCode;

    protected LibraryDomainException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    protected LibraryDomainException(String errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public String getErrorCode() { return errorCode; }
}

// 2. Cac lop con the hien tung tinh huong vi pham cu the
public class MemberSuspendedException extends LibraryDomainException {
    private final String memberId;
    private final String reason;

    public MemberSuspendedException(String memberId, String reason) {
        super("MEMBER_SUSPENDED", String.format("The ban doc '%s' dang bi khoa vi: %s", memberId, reason));
        this.memberId = memberId;
        this.reason = reason;
    }

    public String getMemberId() { return memberId; }
    public String getReason() { return reason; }
}

Lợi ích kiến trúc:

  • Client có thể bắt bao quát: catch (LibraryDomainException e) để xử lý chung mọi lỗi thuộc thư viện.
  • Client có thể bắt riêng: catch (MemberSuspendedException e) khi cần đưa ra hướng dẫn nộp phạt cụ thể.

4. Tích hợp cùng Controller Advice / Global Exception Handler

Trong các ứng dụng Web (như Spring Boot), phân cấp exception kết hợp hoàn hảo với tầng xử lý lỗi tập trung:

Java
public record ErrorResponse(String errorCode, String message, Instant timestamp) { }

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ItemNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ItemNotFoundException ex) {
        ErrorResponse body = new ErrorResponse(
            "ITEM_NOT_FOUND",
            ex.getMessage(),
            ex.getTimestamp()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    @ExceptionHandler(LibraryDomainException.class)
    public ResponseEntity<ErrorResponse> handleDomainError(LibraryDomainException ex) {
        ErrorResponse body = new ErrorResponse(
            ex.getErrorCode(),
            ex.getMessage(),
            Instant.now()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
    }
}

5. Những cạm bẫy thường gặp khi thiết kế Custom Exception

❌ Bẫy 1: Tái tạo lại những gì JDK đã có sẵn Không tạo MyIllegalArgumentException hoặc InvalidInputException khi lớp java.lang.IllegalArgumentException đã truyền tải trọn vẹn ngữ nghĩa đó.

❌ Bẫy 2: Cây phân cấp kế thừa quá sâu Tạo 4-5 tầng kế thừa (A extends B extends C extends D) khiến người đọc mã bị lạc lối trong cấu trúc và gây khó khăn cho việc quản lý các khối catch.

❌ Bẫy 3: Quên cung cấp constructor nhận Throwable cause Nếu một custom exception được sinh ra từ việc bắt một lỗi cơ sở dữ liệu hoặc lỗi mạng mà không có constructor nhận cause, bạn sẽ vô tình cắt đứt chuỗi vết tích lỗi của hệ thống.

6. Deep Dive Oracle & Tài liệu tham khảo

Tài liệu tham khảo đặc tả
  • Effective Java (Joshua Bloch):
    • Item 72: Favor the use of standard exceptions (Ưu tiên tái sử dụng các ngoại lệ chuẩn của JDK).
    • Item 73: Throw exceptions appropriate to the abstraction (Ném ngoại lệ phù hợp với tầng trừu tượng).
    • Item 75: Include failure-capture information in detail messages (Đóng gói thông tin chẩn đoán lỗi vào ngoại lệ).

7. Tự kiểm tra kiến thức

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao việc so khớp ngoại lệ bằng e.getMessage().contains(...) lại bị coi là một anti-pattern nghiêm trọng?
  2. Q2
    Lợi ích lớn nhất của việc bổ sung các trường dữ liệu (fields) vào trong một lớp custom exception là gì?
  3. Q3
    Cây phân cấp Custom Exception trong một dự án phần mềm nên sâu bao nhiêu tầng?
  4. Q4
    Khi nào thì bạn NÊN dùng các ngoại lệ chuẩn của JDK thay vì tự tạo một lớp Exception mới?
  5. Q5
    Tại sao Custom Exception trong tầng nghiệp vụ hiện đại hầu như luôn kế thừa từ RuntimeException thay vì Exception?

Bài tiếp theo: Mini-challenge: Xây dựng bộ validator mượn sách với custom exception

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 4/21 · Exception handling — bài 6/7 của chặng

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Mini-challenge: Bộ kiểm thực mượn ấn phẩm với custom exception