Java — Từ Zero đến Senior/Exception Handling/Custom exception — thiết kế hierarchy exception nghiệp vụ
4/5
~16 phútException Handling

Custom exception — thiết kế hierarchy exception nghiệp vụ

Khi nào tạo exception riêng, cấu trúc class (constructor với cause, field context), hierarchy theo domain, global exception handler pattern, và cách chọn giữa exception và return value.

Bài 1–3 dạy cơ chế exception. Bài này dạy cách thiết kế exception cho dự án — khi nào tạo, cách đặt tên, hierarchy thế nào, mang data gì, và khi nào đừng dùng exception mà dùng return value.

Đây là phần "kiến trúc" nhiều người bỏ qua — kết quả: codebase có throw new RuntimeException("something wrong") khắp nơi, log không thể parse, error handling thành if/else lồng nhau phức tạp.

1. Khi nào tạo custom exception?

Đừng tạo exception riêng chỉ vì muốn "đặt tên đẹp". Tạo khi có ít nhất 1 trong các lý do:

Caller cần phân biệt case này với case khác để xử lý khác nhau.

// Xau — chung chung
catch (RuntimeException e) {
    if (e.getMessage().contains("not found")) { /* retry */ }
    else if (e.getMessage().contains("permission")) { /* login again */ }
    else { throw e; }
}

// Dep — phan biet bang type
catch (OrderNotFoundException e) { /* retry */ }
catch (PermissionDeniedException e) { /* login again */ }

Match exception bằng message.contains(...) là anti-pattern — message có thể đổi, i18n, hoặc chính bạn typo.

Cần mang data context: orderId, userId, limit, actualValue — để log có cấu trúc và metric.

Exception là concept nghiệp vụ, không phải lỗi kỹ thuật: InsufficientBalanceException, OrderAlreadyPaidException. Reader đọc catch hiểu ngay business rule.

Không tạo khi:

  • Exception chỉ để "đẹp", không có caller phân biệt.
  • Exception không bao giờ bị catch riêng — chỉ bị global handler xử lý chung.
  • Đã có exception JDK phù hợp: IllegalArgumentException, IllegalStateException, UnsupportedOperationException.

2. Template class custom exception

public class OrderNotFoundException extends RuntimeException {

    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("Order not found: " + orderId);
        this.orderId = orderId;
    }

    public OrderNotFoundException(long orderId, Throwable cause) {
        super("Order not found: " + orderId, cause);
        this.orderId = orderId;
    }

    public long getOrderId() {
        return orderId;
    }
}

Điểm quan trọng:

  • extends RuntimeException — unchecked (mặc định cho domain code).
  • Constructor (message) — case đơn giản.
  • Constructor (message, Throwable cause) — wrap exception gốc không mất stack trace.
  • Field orderId (final) — data context, dễ log có cấu trúc:
    log.error("order_id={} not_found", e.getOrderId(), e);
    

2.1 Serializable — có cần?

Java exception extends Throwable → ngầm implement Serializable. Nếu exception đi qua boundary serialize (RMI, session replication, cache), compiler warning về serialVersionUID. Fix:

public class OrderNotFoundException extends RuntimeException {
    private static final long serialVersionUID = 1L;
    ...
}

90% app web modern không cần — chỉ thêm nếu có warning hoặc dự án yêu cầu.

3. Hierarchy exception theo domain

Dự án nhỏ: mỗi exception extends RuntimeException thẳng. Dự án lớn hơn: tạo base abstract class cho domain rồi subclass:

// Base abstract
public abstract class OrderException extends RuntimeException {
    protected OrderException(String msg) { super(msg); }
    protected OrderException(String msg, Throwable cause) { super(msg, cause); }
}

// Concrete
public class OrderNotFoundException extends OrderException { ... }
public class OrderAlreadyPaidException extends OrderException { ... }
public class OrderExpiredException extends OrderException { ... }

// Payment domain rieng
public abstract class PaymentException extends RuntimeException { ... }
public class InsufficientBalanceException extends PaymentException { ... }
public class CardDeclinedException extends PaymentException { ... }

Lợi ích:

  • Caller chọn granularity: catch (OrderException e) bắt mọi lỗi order; hoặc catch (OrderNotFoundException e) bắt riêng.
  • Global handler có thể match base class: @ExceptionHandler(OrderException.class).
  • Reader thấy cây hierarchy hiểu ngay lỗi thuộc domain nào.

Đừng tạo quá sâu — 2 tầng (base → concrete) đủ cho hầu hết dự án.

4. Dùng exception JDK có sẵn — trước khi tạo mới

Trước khi viết class exception mới, check exception JDK có sẵn:

JDK ExceptionKhi nào dùng
IllegalArgumentExceptionArgument sai format/range: age < 0, email không hợp lệ.
IllegalStateExceptionObject ở state sai cho operation: close() object đã closed; next() khi không còn element.
NullPointerExceptionArgument null bắt buộc non-null. Dùng Objects.requireNonNull(arg) để ném rõ ràng.
UnsupportedOperationExceptionOperation không support (vd Collections.unmodifiableList.add).
IndexOutOfBoundsExceptionIndex ngoài giới hạn của array/list/string.
ConcurrentModificationExceptionModify collection khi đang iterate.
NumberFormatExceptionParse số từ string lỗi.
ArithmeticExceptionChia 0, overflow với Math.addExact.

Dùng exception JDK khi case chính là lỗi kỹ thuật phổ quát, không cần semantics nghiệp vụ riêng.

public void setAge(int age) {
    if (age < 0) throw new IllegalArgumentException("age must be non-negative, got " + age);
    this.age = age;
}

public void charge(BigDecimal amount) {
    Objects.requireNonNull(amount, "amount must not be null");
    if (amount.signum() <= 0) throw new IllegalArgumentException("amount must be positive");
    // ...
}

5. Global exception handler — pattern Spring / Web

Modern web app không try/catch trong mỗi controller. Thay vào đó, global handler tập trung map exception → HTTP response:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException e) {
        log.warn("Order not found: {}", e.getOrderId());
        return ResponseEntity.status(404)
            .body(new ErrorResponse("ORDER_NOT_FOUND", e.getMessage()));
    }

    @ExceptionHandler(PaymentException.class)
    public ResponseEntity<ErrorResponse> handlePayment(PaymentException e) {
        log.error("Payment error", e);
        return ResponseEntity.status(402)
            .body(new ErrorResponse("PAYMENT_FAILED", e.getMessage()));
    }

    @ExceptionHandler(Exception.class)   // fallback
    public ResponseEntity<ErrorResponse> handleGeneric(Exception e) {
        log.error("Unhandled", e);
        return ResponseEntity.status(500)
            .body(new ErrorResponse("INTERNAL", "Internal server error"));
    }
}

Controller chỉ throw, handler map sang response. Controller code sạch — chỉ business logic.

Pattern này là lý do cộng đồng Java modern nghiêng về unchecked exception: không ép caller try/catch, thay vào đó global handler lo hết.

6. Exception vs return value — khi nào chọn cái nào?

Không phải mọi "lỗi" đều nên là exception. Một số case dùng return value tốt hơn:

✅ Dùng exception khi:

  • Lỗi bất thường, hiếm, không phải flow chính.
  • Caller thường không biết phương án xử lý — để exception propagate lên.
  • Xảy ra sâu trong call stack, cần "nhảy ra" nhiều tầng.

✅ Dùng return value (Optional, Result type) khi:

  • "Không tìm thấy" là flow bình thường, caller thường check ngay.
  • Hot path — exception có overhead (fill stack trace).
  • Caller luôn có phương án xử lý ngay.

Ví dụ:

// Tot — throw cho case that bat thuong
User getUser(long id) {
    return repo.findById(id)
        .orElseThrow(() -> new UserNotFoundException(id));
}

// Tot — Optional cho case "co the khong co"
Optional<User> findUser(long id) {
    return repo.findById(id);
}

// Caller chon tuy ngu canh:
User u = getUser(id);   // muon ensure co user, exception neu khong
Optional<User> ou = findUser(id);
if (ou.isPresent()) { ... } else { ... }

JDK convention: method findX trả Optional; method getX / requireX ném exception khi không có.

6.1 Exception KHÔNG phải flow control

Đừng dùng exception để control flow bình thường:

// XAU — dung exception de exit loop
try {
    while (true) iterator.next();
} catch (NoSuchElementException e) { /* done */ }

// DUNG — check truoc
while (iterator.hasNext()) iterator.next();

Exception có overhead — fill stack trace là operation tốn. Trong hot loop, dùng exception thay loop control làm chậm code hàng chục lần.

7. Pitfall tổng hợp

Nhầm 1: throw new RuntimeException("generic error") khắp nơi.

throw new RuntimeException("not found");
throw new RuntimeException("invalid");

✅ Tạo exception cụ thể hoặc dùng IllegalArgumentException, IllegalStateException.

Nhầm 2: Quên constructor (message, cause).

public class MyException extends RuntimeException {
    public MyException(String msg) { super(msg); }
    // thieu constructor nhan cause -> khong wrap exception goc duoc
}

✅ Luôn thêm constructor (String, Throwable).

Nhầm 3: Match exception bằng message.contains(...).

catch (Exception e) {
    if (e.getMessage().contains("not found")) { ... }
}

✅ Match bằng type. Không match bằng message.

Nhầm 4: Exception extends Exception (checked) cho domain code.

public class OrderException extends Exception { }   // ep moi caller try/catch

✅ Default extends RuntimeException.

Nhầm 5: Throw exception trong hot path thay vì check pre-condition.

int safeGet(int[] arr, int i) {
    try { return arr[i]; }
    catch (IndexOutOfBoundsException e) { return 0; }   // overhead cao neu i out range thuong xuyen
}

✅ Check trước: return i < arr.length ? arr[i] : 0;.

8. 📚 Deep Dive Oracle

ℹ️ 📚 Deep Dive Oracle (optional)

Spec / reference chính thức:

  • Throwable javadoc — 4 constructor recommended cho exception class: (), (String), (String, Throwable), (Throwable).
  • Spring @ExceptionHandler — global handler.
  • Effective Java Item 70: "Use checked exceptions for recoverable conditions and runtime exceptions for programming errors"; Item 72: "Favor the use of standard exceptions"; Item 73: "Throw exceptions appropriate to the abstraction"; Item 75: "Include failure-capture information in detail messages".
  • Clean Code (Robert Martin): chương 7 "Error Handling" — lập luận cho unchecked + Special Case Object pattern.

Ghi chú: Item 75 của Effective Java khuyên: message exception phải kèm mọi giá trị gây lỗi: "age must be non-negative, got -5" thay vì "bad age". Reader log từ production phải debug được từ một dòng message.

9. Tóm tắt

  • Tạo custom exception khi caller cần phân biệt case, hoặc mang data context nghiệp vụ. Không vì "tên đẹp".
  • Class template: extends RuntimeException + constructor (message) + (message, cause) + field context final.
  • Hierarchy: abstract base per domain (OrderException) → concrete subclass. 2 tầng đủ.
  • Dùng exception JDK (IllegalArgumentException, IllegalStateException, NullPointerException) cho lỗi kỹ thuật phổ quát trước khi viết class mới.
  • Global exception handler (Spring @RestControllerAdvice) map exception → HTTP response. Controller chỉ throw.
  • Exception vs Optional: Optional cho "có thể không có"; exception cho "bất thường".
  • Không dùng exception làm flow control — overhead cao, semantics sai.
  • Message phải kèm giá trị gây lỗi để debug từ log.
  • Luôn wrap exception với cause (super(msg, cause)) khi rethrow.

10. Tự kiểm tra

Tự kiểm tra
Q1
Khi nào không cần tạo custom exception?

3 trường hợp không đáng tạo:

  • Đã có exception JDK phù hợp: validate argument → IllegalArgumentException; state sai → IllegalStateException; null argument → NullPointerException qua Objects.requireNonNull. Tạo InvalidAgeException để làm gì khi IllegalArgumentException đủ?
  • Không ai catch riêng: nếu mọi caller chỉ để exception propagate lên global handler xử chung với "500 Internal Server Error", tạo class mới chỉ thêm noise. Exception chung là đủ.
  • Không mang data context: nếu MyException chỉ có message không khác RuntimeException, việc tạo class là rỗng ngữ nghĩa.

Litmus test: "Tôi sẽ catch exception này riêng (không phải chung với Exception)?" hoặc "Exception này cần mang data riêng ngoài message?". Không cho cả 2 → dùng exception có sẵn.

Q2
Đoạn sau có lỗi thiết kế gì?
try {
    orderService.place(order);
} catch (RuntimeException e) {
    if (e.getMessage().contains("not found")) {
        log.info("Order not found, retrying");
        return retry();
    } else if (e.getMessage().contains("insufficient")) {
        throw new PaymentException("Insufficient balance");
    } else {
        throw e;
    }
}

Bug: match exception bằng message.contains. Vấn đề:

  • Dễ vỡ khi message đổi: "not found" → "not_found" → "record not found" tuỳ phiên bản. Một refactor đơn giản vỡ toàn bộ error handling.
  • Không i18n-friendly: nếu message có ngôn ngữ khác, match fail.
  • Typo khó phát hiện: "not fould" compile OK nhưng runtime không bao giờ match.
  • Không type-safe: IDE không kiểm tra, refactor không theo.

Fix — match bằng type:

try {
    orderService.place(order);
} catch (OrderNotFoundException e) {
    log.info("Order not found, retrying");
    return retry();
} catch (InsufficientBalanceException e) {
    throw new PaymentException("Insufficient balance", e);
}

Service throw exception cụ thể; caller catch theo type. Rõ intent, type-safe, refactor-friendly. Đây là lý do tạo custom exception có giá trị — phân biệt type.

Q3
Viết class InsufficientBalanceException đủ chuẩn (có field context, constructor chain với cause).
public class InsufficientBalanceException extends RuntimeException {

    private final long accountId;
    private final BigDecimal required;
    private final BigDecimal available;

    public InsufficientBalanceException(long accountId, BigDecimal required, BigDecimal available) {
        super(String.format("Account %d: required %s, available %s",
            accountId, required, available));
        this.accountId = accountId;
        this.required = required;
        this.available = available;
    }

    public InsufficientBalanceException(long accountId, BigDecimal required,
                                         BigDecimal available, Throwable cause) {
        super(String.format("Account %d: required %s, available %s",
            accountId, required, available), cause);
        this.accountId = accountId;
        this.required = required;
        this.available = available;
    }

    public long getAccountId() { return accountId; }
    public BigDecimal getRequired() { return required; }
    public BigDecimal getAvailable() { return available; }
}

Điểm đáng nhớ:

  • Extends RuntimeException — unchecked, phù hợp domain code.
  • Message đủ context: accountId + required + available. Debug từ log không cần thêm query.
  • 2 constructor: với và không cause. Case có cause là khi lỗi gốc từ DB/network được wrap.
  • Field final + getter: caller / global handler có thể access data structured:
@ExceptionHandler(InsufficientBalanceException.class)
public ResponseEntity<ErrorResponse> handle(InsufficientBalanceException e) {
    return ResponseEntity.status(402).body(new ErrorResponse(
        "INSUFFICIENT_BALANCE",
        e.getMessage(),
        Map.of(
            "account_id", e.getAccountId(),
            "required", e.getRequired(),
            "available", e.getAvailable()
        )
    ));
}
Q4
Khi nào nên trả Optional thay vì throw exception?

Quy tắc:

  • Optional: "không có" là flow bình thường, caller thường check ngay. Vd findUserByEmail(email) — email có thể chưa đăng ký, không phải lỗi.
  • Exception: "không có" là bất thường, caller không kỳ vọng. Vd getUserById(authenticatedUserId) — đã authenticate mà không tìm được user là corrupted state, throw exception hợp lý.

Convention JDK:

  • find* / *Opt trả Optional.
  • get* / require* throw exception khi vắng.

Ví dụ: Map.get(key) trả null (trước Optional); Map.getOrDefault(key, def) cho default; Optional.get() throw nếu empty.

Đừng dùng exception làm control flow: try { return cache.get(key); } catch (NotFoundException e) { return loadFromDb(key); } — tệ. Dùng cache.getOrLoad(key, this::loadFromDb) hoặc cache.find(key).orElseGet(() -> loadFromDb(key)).

Q5
Global exception handler (Spring) giúp gì? Đặt exception ở layer nào?

Global handler (@RestControllerAdvice) tập trung map exception → HTTP response. Controller chỉ throw, handler lo format + status code + logging.

Lợi ích:

  • Controller sạch: không try/catch rải rác, chỉ logic. 1 controller 10 dòng thay vì 50.
  • Response format nhất quán: mọi lỗi trả cùng schema ({code, message, details}).
  • Logging tập trung: một chỗ log error với context đủ.
  • Hỗ trợ migration: đổi format response chỉ sửa handler, không đụng controller.

Đặt exception ở layer nào?

  • Domain layer: exception nghiệp vụ (OrderNotFoundException, InsufficientBalanceException). Không phụ thuộc framework.
  • Repository layer: wrap SQLException / IOException thành domain exception (DataAccessException). Service không thấy low-level exception.
  • Controller / handler layer: không định nghĩa exception mới. Bắt domain exception và map thành HTTP response.

Chiến lược: exception "chảy" từ sâu ra ngoài; handler chặn ở biên API. Service/domain code thuần nghiệp vụ, không biết về HTTP/REST.


Bài tiếp theo: Mini-challenge: Validator chain với exception