Hình dung chỗ xử lý ngoại lệ như một bàn điều phối cấp cứu: mọi sự cố trong ứng dụng dồn về một chỗ, và mỗi cái được gán một mã chuẩn mà phía nhận hiểu ngay. Bài này về cái bàn đó, và về cái bẫy làm hỏng nó.

Mặc định đã khá tốt

  GET /loi (ném IllegalStateException, không có handler)

  {"timestamp":"2026-08-13T07:10:02.555+00:00","status":500,
   "error":"Internal Server Error","path":"/loi"}

Spring Boot đã trả JSON có cấu trúc, và không lộ dấu vết ngăn xếp — thông tin chi tiết chỉ vào log.

Đó là mặc định an toàn. Nhưng bạn muốn kiểm soát mã trạng thái theo loại lỗi.

@RestControllerAdvice

@RestControllerAdvice
class XuLyLoi {
    record LoiKQ(String thongDiep, List<String> chiTiet) {}

    @ExceptionHandler(KhongTimThay.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    LoiKQ khongTim(KhongTimThay e) {
        return new LoiKQ(e.getMessage(), List.of());
    }
}
  HTTP/1.1 404
  {"thongDiep":"không tìm thấy DH-404","chiTiet":[]}

Một lớp, áp dụng cho mọi controller — một bàn điều phối duy nhất. Mỗi @ExceptionHandler bắt một loại ngoại lệ.

Khi có nhiều handler khớp, Spring chọn cái cụ thể nhất theo cây kế thừa.

Gom lỗi validation

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
LoiKQ validation(MethodArgumentNotValidException e) {
    var ct = e.getBindingResult().getFieldErrors().stream()
            .map(f -> f.getField() + ": " + f.getDefaultMessage())
            .sorted().toList();
    return new LoiKQ("dữ liệu không hợp lệ", ct);
}
  {"thongDiep":"dữ liệu không hợp lệ",
   "chiTiet":["email: must be a well-formed email address","ma: must not be blank",...]}

Không có handler này, Spring vẫn trả 400 nhưng với định dạng mặc định khá dài. Gom lại cho client dễ dùng.

Nhớ từ bài 13: validation ở tầng service ném ConstraintViolationException, cần handler riêng.

Cái bẫy: bắt Exception

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
LoiKQ tatCa(Exception e) { return new LoiKQ("lỗi máy chủ", List.of()); }

Trông hợp lý, và nó phá hỏng mọi ánh xạ mã lỗi của Spring — ông điều phối lười gán mọi cuộc gọi thành "khẩn cấp chung", vứt sạch mọi phân biệt mà người gọi đã nói.

Bài 12 đã đo: sai Content-Type trả 415, sai kiểu tham số trả 400. Với handler trên, cả hai thành 500.

Bài 60 sê-ri Java đo được năm trường hợp bị đôn nhầm: id không tồn tại (404), JSON hỏng (400), ngày sai định dạng (400), thiếu Content-Type (415), chuỗi dài quá cột (400).

Cách chữa: kế thừa ResponseEntityExceptionHandler. Nó đã xử lý sẵn mọi ngoại lệ chuẩn của Spring MVC với mã đúng, và bạn chỉ override phần muốn đổi.
@RestControllerAdvice
class XuLyLoi extends ResponseEntityExceptionHandler {

    @ExceptionHandler(KhongTimThay.class)
    ResponseEntity<Object> khongTim(KhongTimThay e, WebRequest r) { ... }

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(
            Exception ex, Object body, HttpHeaders h, HttpStatusCode s, WebRequest r) {
        // đổi THÂN sang định dạng của bạn, GIỮ NGUYÊN mã trạng thái
        return super.handleExceptionInternal(ex, new LoiKQ(...), h, s, r);
    }
}

Vá từng ngoại lệ một sẽ không bao giờ hết — danh sách ngoại lệ chuẩn của Spring MVC có hơn hai chục cái. Đừng viết lại cuốn sổ mã; hãy dùng cuốn có sẵn và chỉ thêm mã cho ca nó chưa có.

ProblemDetail: chuẩn RFC 7807

Từ Spring 6, có kiểu chuẩn cho lỗi API:

@ExceptionHandler(KhongTimThay.class)
ProblemDetail khongTim(KhongTimThay e) {
    var p = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    p.setTitle("Không tìm thấy đơn hàng");
    p.setProperty("maDon", e.ma);
    return p;
}
{"type":"about:blank","title":"Không tìm thấy đơn hàng","status":404,
 "detail":"không tìm thấy DH-404","instance":"/don/DH-404","maDon":"DH-404"}

Bật cho toàn bộ ngoại lệ chuẩn bằng một dòng:

spring:
  mvc:
    problemdetails:
      enabled: true

Với API mới, tôi khuyên dùng ProblemDetail — nó là chuẩn, client có thư viện đọc sẵn, và nó có chỗ cho trường tuỳ biến.

Ba nguyên tắc

Đừng lộ nội tình. Thông điệp của DataIntegrityViolationException chứa nguyên câu SQL và tên ràng buộc. Chi tiết vào log, ra ngoài chỉ thông điệp chung.

Phân biệt lỗi nghiệp vụ với lỗi hệ thống. Đơn hàng không tồn tại là kết quả bình thường — log ở mức DEBUG, không cảnh báo. Mất kết nối CSDL là sự cố — log ERROR. Trộn hai loại làm bảng cảnh báo vô dụng, đúng như bài 28 sê-ri Go.

Log một lần, ở tầng ngoài cùng. @RestControllerAdvice là tầng đó. Đừng vừa log vừa ném lại ở tầng dưới.

Ánh xạ mã HTTP

Bảng tôi dùng, và năm dòng này phủ gần hết API:

Tình huống Mã
Dữ liệu đầu vào sai 400
Chưa đăng nhập 401
Không có quyền 403
Không tìm thấy 404
Xung đột trạng thái (đơn đã huỷ) 409
Đúng cú pháp nhưng vi phạm nghiệp vụ 422
Lỗi hệ thống 500
Dịch vụ phụ thuộc hỏng 502 / 503 / 504

Nếu chỉ soi một thứ sau bài này, tìm xem có ông điều phối lười nào đang gán tất cả thành một mã không, trong ba mươi giây:

grep -rn 'ExceptionHandler(Exception.class)\|ExceptionHandler(Throwable.class)' --include='*.java' src/

Có kết quả nghĩa là mọi ngoại lệ chuẩn của Spring đang bị đôn thành mã của bạn. Kiểm bằng:

curl -si -X POST localhost:8080/don -d "abc" | head -1

Ra 500 thay vì 415 là xác nhận.

Mẫu số chung

Xử-lý-lỗi-thành-phản-hồi là một mối quan tâm cắt ngang, và cắt ngang thì nên gom về một ranh giới duy nhất thay vì rải try/catch khắp các endpoint: một advice của Spring, error middleware của Express, rescue_from của Rails, exception middleware của ASP.NET — cùng một ý. Việc thật sự của cái bàn ấy là phiên dịch: ánh xạ các kiểu ngoại lệ của miền bạn sang bộ từ vựng mã của giao thức (mã HTTP) — quyết theo kiểu chứ không theo chuỗi thông điệp — để chi tiết nội bộ ở lại trong log còn client nhận một câu trả lời sạch, đúng hình dạng chuẩn (ProblemDetail/RFC 7807 là hình dạng đó, và chuẩn nghĩa là mọi client đã có sẵn bộ đọc).

Điều thứ hai: hãy mở rộng cái xử lý sẵn-có-đầy-đủ của framework, đừng viết lại nó. Spring đã ánh xạ hơn hai chục ngoại lệ chuẩn sang đúng mã; một cú catch Exception rộng tay lặng lẽ xoá sạch tất cả và dồn mọi thứ về 500 — vá từng ngoại lệ là cuộc đua thua chắc với một danh sách bạn không sở hữu. Nước đi bền vững là móc vào chính cơ chế đúng-sẵn (kế thừa ResponseEntityExceptionHandler, chỉ override cái thân) và chỉ đổi đúng phần khác biệt của mình — đúng tinh thần đóng-để-sửa-mở-để-mở-rộng, và là một ca cụ thể của bài học lặp đi lặp lại: một cú bắt quá rộng phá huỷ thông tin mà tầng dưới đã tính ra. Với xuống tay bắt tất, hãy tìm cái khe framework đã chừa sẵn trước.

Ngày mai: Jackson trong Spring — cấu hình mặc định và cái bẫy vòng lặp tham chiếu.