Bài này về chỗ mọi lỗi của API đi qua, 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ỗ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.

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.

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
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

Thử 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.

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.