Backend

99 Ngày Java — Ngày 43: Custom exception & best practices

SSite Admin
8 tháng 09, 2026 9 phút đọc 0 lượt xem
99 Ngày Java — Ngày 43: Custom exception & best practices

Hai hôm nay ta học cách xử lý exception; hôm nay chuyển sang phía thiết kế. Và câu hỏi đầu tiên không phải "viết class exception thế nào", mà là có thật sự cần một class mới không. Sau đó là ba thứ quyết định chất lượng: mang theo dữ liệu có cấu trúc chứ không chỉ một dòng chữ, viết message cho người sửa bug lúc hai giờ sáng, và dựng một cây phân cấp vừa đủ để xử lý chung ở tầng biên.

Sketchnote Ngày 43: Custom exception — khi nào đáng tạo class mới, mang dữ liệu có cấu trúc thay vì chỉ message, ba nguyên tắc viết message, cây exception nghiệp vụ với mã lỗi, và fail fast ở biên

Khi nào đáng tạo một exception mới

Sai lầm phổ biến nhất là tạo class exception cho mọi thứ, tới mức dự án có hàng trăm loại mà không ai bắt riêng loại nào. Tiêu chí quyết định chỉ gói trong một câu hỏi: có ai đó sẽ bắt riêng loại này để làm việc khác không?

// Câu hỏi đầu tiên KHÔNG phải "viết class exception thế nào",
// mà là "có thật sự cần một class mới không?".

// ❌ ĐỪNG tạo mới khi JDK đã có sẵn đúng ý nghĩa:
class ThamSoSaiException extends RuntimeException { }   // đã có IllegalArgumentException
class KhongTimThayException extends RuntimeException { } // đã có NoSuchElementException
// Dùng lại loại chuẩn thì mọi lập trình viên Java đọc code đều hiểu ngay.

// ✅ TẠO MỚI khi nó mang MỘT KHÁI NIỆM NGHIỆP VỤ mà JDK không có:
class SoDuKhongDuException extends RuntimeException { }
class DonHangDaHuyException extends RuntimeException { }
// Hai cái tên này nói lên NGHIỆP VỤ, và tầng trên có thể xử lý KHÁC NHAU
// cho từng loại — đó chính là lý do chúng đáng tồn tại.

// TIÊU CHÍ QUYẾT ĐỊNH, chỉ một câu:
// "Có ai đó sẽ CATCH riêng loại này để làm việc khác không?"
//   Có  → đáng tạo class mới
//   Không → dùng loại chuẩn kèm message rõ ràng là đủ

// Bao nhiêu class là đủ? Đừng tạo mỗi lỗi một class. Một hệ thống lành mạnh
// thường chỉ có 5–15 loại exception nghiệp vụ, không phải 200.
  • Dùng lại loại chuẩn của JDK khi ý nghĩa đã trùng — IllegalArgumentExceptionIllegalStateException phủ được rất nhiều trường hợp.

  • Class mới xứng đáng khi nó mang một khái niệm nghiệp vụ mà JDK không có từ nào diễn tả.

  • Một hệ thống lành mạnh thường chỉ có năm tới mười lăm loại exception nghiệp vụ. Hai trăm loại là dấu hiệu chưa ai đặt câu hỏi ở trên.

Thiết kế: mang dữ liệu, không chỉ mang chữ

Đây là điểm khác biệt lớn nhất giữa một exception tốt và một exception xoàng. Nếu tầng trên phải bóc tách chuỗi message để lấy số tiền còn thiếu, thì exception của bạn đang thiếu đúng thứ quan trọng nhất.

// Thiết kế một exception nghiệp vụ tử tế — nhiều hơn "extends RuntimeException":
public class SoDuKhongDuException extends RuntimeException {

    private final String soTaiKhoan;          // ❗ DỮ LIỆU, không chỉ có chữ
    private final BigDecimal thieu;

    public SoDuKhongDuException(String soTaiKhoan, BigDecimal thieu) {
        super("Tài khoản %s thiếu %s để hoàn tất giao dịch"
                .formatted(soTaiKhoan, thieu));
        this.soTaiKhoan = soTaiKhoan;
        this.thieu = thieu;
    }
    public String getSoTaiKhoan() { return soTaiKhoan; }
    public BigDecimal getThieu()  { return thieu; }
}
// Mang theo DỮ LIỆU CÓ CẤU TRÚC là điểm khác biệt lớn nhất giữa exception tốt
// và exception xoàng: tầng trên hiển thị được số tiền còn thiếu mà không phải
// đi bóc tách chuỗi message.

// LUÔN có constructor nhận cause khi exception có thể bọc lỗi khác:
public KhoDuLieuException(String message, Throwable cause) { super(message, cause); }

// CHECKED hay UNCHECKED? Trong ứng dụng web hiện đại, gần như luôn là UNCHECKED:
//   - controller/service không có gì "phục hồi" được từ số dư không đủ
//   - checked làm bẩn chữ ký của cả chuỗi gọi, và người ta sẽ bọc bừa để cho qua
// Spring cũng chọn đúng hướng đó: toàn bộ DataAccessException là unchecked.
  • Field có kiểu rõ ràng cho phép tầng biên dựng phản hồi tử tế mà không phải đọc chuỗi.

  • Luôn có constructor nhận cause nếu exception này có thể bọc một lỗi khác — bài học Ngày 42.

  • Trong ứng dụng web, gần như luôn chọn unchecked: không ai "phục hồi" được từ số dư không đủ, và checked chỉ làm bẩn chữ ký cả chuỗi gọi.

Viết message cho người sửa bug

Message là thứ duy nhất còn lại khi mọi người đã về nhà và hệ thống đang chạy lúc nửa đêm. "Có lỗi xảy ra" đúng ngữ pháp, nhưng nó không giúp được ai — kể cả chính bạn ba tháng sau.

// Viết message: người đọc là NGƯỜI SỬA BUG LÚC 2 GIỜ SÁNG, không phải bạn hôm nay.

// ❌ Vô dụng — đúng ngữ pháp nhưng không nói gì:
throw new AppException("Có lỗi xảy ra");
throw new AppException("Lỗi");
throw new AppException("Xử lý thất bại");

// ✅ Message tốt trả lời ba câu: chuyện gì, với DỮ LIỆU nào, và ở đâu:
throw new DonHangDaHuyException(
    "Không thể thêm sản phẩm vào đơn hàng %d đã hủy lúc %s"
            .formatted(donHangId, thoiDiemHuy));

// BA NGUYÊN TẮC:
// 1. Có ĐỊNH DANH cụ thể (id, mã đơn, đường dẫn) — không có thì log vô nghĩa
// 2. Nói cái ĐÃ XẢY RA, đừng đổ lỗi mơ hồ ("dữ liệu không hợp lệ" là gì?)
// 3. TUYỆT ĐỐI không đưa dữ liệu nhạy cảm vào message:
throw new AuthException("Sai mật khẩu: " + matKhau);   // ❌ mật khẩu vào log!
throw new AuthException("Đăng nhập thất bại cho " + email);   // ✅

// Hai bản dành cho hai đối tượng khác nhau, đừng trộn:
//   message của exception → cho DEV, chi tiết, có id, vào log
//   thông báo cho người dùng → dịch ở tầng controller (Ngày 26 — Problem Detail),
//                              không lộ nội bộ, không lộ stack trace

// Đặt tên class: kết thúc bằng "Exception", tả TÌNH HUỐNG chứ không tả nơi nổ.
class SoDuKhongDuException { }      // ✅ tình huống nghiệp vụ
class ServiceException { }          // ❌ nói lên điều gì? Không gì cả.
  • Luôn có định danh cụ thể: id đơn hàng, đường dẫn file, mã giao dịch. Không có nó thì dòng log chỉ là tiếng ồn.

  • Tuyệt đối không đưa dữ liệu nhạy cảm vào message — mật khẩu, token, số thẻ đi vào log là một sự cố bảo mật.

  • Hai đối tượng, hai bản văn: message của exception dành cho dev, còn thông báo cho người dùng thì dịch ở tầng controller.

  • Tên class tả tình huống chứ không tả nơi nổ — ServiceException thực chất không nói lên điều gì.

Cây phân cấp, mã lỗi và fail fast

Một exception gốc cho toàn ứng dụng giúp tầng biên xử lý gọn mà vẫn giữ được khả năng bắt riêng. Và nếu API của bạn có client là ứng dụng khác, thì mã lỗi mới là giao diện, chứ không phải câu chữ.

// Nên có một exception GỐC cho ứng dụng? Rất nên — nhưng đừng lạm dụng.
public abstract class NghiepVuException extends RuntimeException { ... }

public class SoDuKhongDuException extends NghiepVuException { ... }
public class DonHangDaHuyException extends NghiepVuException { ... }

// Lợi ích: xử lý chung một chỗ ở tầng biên, mà vẫn bắt riêng được khi cần:
@ExceptionHandler(NghiepVuException.class)      // gom hết lỗi nghiệp vụ
ProblemDetail xuLy(NghiepVuException e) { ... }

@ExceptionHandler(SoDuKhongDuException.class)   // trường hợp riêng vẫn ưu tiên
ProblemDetail xuLyRieng(SoDuKhongDuException e) { ... }

// GẮN MÃ LỖI khi có client là ứng dụng khác — chuỗi tiếng Việt không phải giao diện API:
public enum MaLoi { SO_DU_KHONG_DU, DON_HANG_DA_HUY, KHO_HET_HANG }
// Client so theo MÃ, còn message chỉ để người đọc. Đổi lời văn không làm vỡ client.

// FAIL FAST — kiểm tra ở BIÊN, đừng để dữ liệu sai đi sâu vào hệ thống:
public DonHang(Long id, BigDecimal tong) {
    if (tong.signum() < 0)
        throw new IllegalArgumentException("Tổng đơn hàng âm: " + tong);
    ...
}
// Objects.requireNonNull ngay trong constructor cũng là fail fast —
// nổ tại nơi dữ liệu sai được TẠO RA, thay vì mười tầng sau đó.
  • Một lớp gốc như NghiepVuException cho phép xử lý chung một chỗ mà vẫn ưu tiên handler cụ thể khi có.

  • Mã lỗi là hợp đồng, message là lời giải thích. Đổi câu chữ không được phép làm vỡ client.

  • Fail fast: kiểm tra ngay trong constructor để lỗi nổ tại nơi dữ liệu sai được tạo ra, thay vì mười tầng sau đó.

Bài tập nhỏ

  • Mở dự án của bạn, đếm số class exception tự viết, và với từng cái hãy hỏi: có ai bắt riêng nó không?

  • Viết SoDuKhongDuException mang hai field số tài khoản và số tiền thiếu, rồi dùng chúng để dựng phản hồi API.

  • Tìm trong code một message kiểu "Có lỗi xảy ra" và viết lại kèm định danh cụ thể.

  • Dựng lớp gốc NghiepVuException với hai lớp con, rồi viết hai @ExceptionHandler và xác nhận cái cụ thể được ưu tiên.

  • Thêm Objects.requireNonNull vào constructor một entity, rồi cố tình truyền null để thấy lỗi nổ sớm hơn bao nhiêu.

Kết lại

Bốn ý gói lại hôm nay: chỉ tạo exception mới khi có người sẽ bắt riêng nó, còn lại dùng loại chuẩn của JDK; exception tốt mang dữ liệu có cấu trúc chứ không bắt tầng trên bóc chuỗi, và trong web thì gần như luôn là unchecked; message viết cho người sửa bug lúc hai giờ sáng — có định danh, không có dữ liệu nhạy cảm, và tách khỏi thông báo cho người dùng; còn một lớp gốc cộng mã lỗi giúp xử lý gọn ở tầng biên mà không khóa chặt câu chữ vào hợp đồng API. Ngày 44 ta quay lại cú pháp với thứ đã hứa từ Ngày 42: try-with-resources — rò rỉ tài nguyên, AutoCloseable, và suppressed exception. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 43: User từ database

UserDetailsService chỉ đòi một method, luồng đăng nhập qua DaoAuthenticationProvider và PasswordEncoder, entity người dùng cùng chuyện tiền tố ROLE_ — và bốn cái bẫy, mở đầu là thông báo lỗi tiết lộ quá nhiều.

8 thg 9, 20267 phút0
99 Ngày Spring — Ngày 42: Cấu hình SecurityFilterChain

Cấu hình bean và lambda của Security 6, luật khớp theo thứ tự khai báo với anyRequest cuối cùng, BCryptPasswordEncoder và salt tự sinh — cùng hai chỗ hay làm sai nhất là CSRF và CORS.

7 thg 9, 20266 phút5
99 Ngày Java — Ngày 42: try-catch-finally đúng cách

Thứ tự catch từ con tới cha và multi-catch, ba cái bẫy của finally — trong đó return làm exception biến mất không dấu vết — cùng chuyện ném lại hay bọc lại và cách xử lý InterruptedException.

7 thg 9, 20267 phút7