Exception handling: @RestControllerAdvice và ProblemDetail
Tiếp đi: exception handling. Giờ shop-app có chỗ trả 500 kèm nguyên stack trace, chỗ trả {"error":"not found"}, chỗ trả text. Gom về một kiểu thế nào?
Đây là việc của @RestControllerAdvice. Spring 6 còn có sẵn format chuẩn là ProblemDetail, nên không cần tự nghĩ ra JSON lỗi.
1. Bài toán
Các lỗi đang xảy ra ở OrderController:
GET /api/orders/999 → OrderNotFoundException → đang ra 500
POST /api/orders/42/cancel → OrderNotCancellableException (đã giao) → 500
POST /api/orders (body sai) → MethodArgumentNotValidException → 400 nhưng body khó đọc
POST /api/orders (hết hàng) → OutOfStockException → 500
bug NPE → 500 kèm "trace": "java.lang.NullPointerException at ..."
2. Vấn đề
- Lỗi nghiệp vụ (client sai) mà trả 500 → monitoring báo động giả, client không biết sửa gì.
- Try-catch trong từng controller method: lặp code, dễ sót.
- Lộ stack trace, tên class, câu SQL → thông tin cho attacker.
- Không có id để client báo lỗi và dev tìm log tương ứng.
3. Giải pháp
- Business exception có nghĩa rõ:
OrderNotFoundException,OrderNotCancellableException… - @RestControllerAdvice + @ExceptionHandler: một class trung tâm map exception → status + body.
- ProblemDetail (RFC 7807, cập nhật bởi RFC 9457): body chuẩn
type, title, status, detail, instance+ field mở rộng, media typeapplication/problem+json. - Kế thừa ResponseEntityExceptionHandler để các exception chuẩn của Spring MVC (400, 405, 415…) cũng ra ProblemDetail.
- Handler cuối cho
Exception: log đầy đủ + traceId, trả 500 chung chung.
4. Code từng bước
Bước 1: dependency. spring-boot-starter-web (Spring 6+). Thêm micrometer-tracing-bridge-otel + actuator nếu muốn traceId tự động (bài 4.6.8).
Bước 2: config.
spring:
mvc:
problemdetails:
enabled: true # Spring tự trả ProblemDetail cho exception chuẩn
server:
error:
include-stacktrace: never
include-message: never # không echo message nội bộ ra ngoài
Bước 3: code Java.
// Business exception: không phụ thuộc HTTP
public abstract class BusinessException extends RuntimeException {
private final String code;
protected BusinessException(String code, String message) { super(message); this.code = code; }
public String code() { return code; }
}
public class OrderNotFoundException extends BusinessException {
public OrderNotFoundException(long id) { super("ORDER_NOT_FOUND", "Order " + id + " not found"); }
}
public class OrderNotCancellableException extends BusinessException {
public OrderNotCancellableException(long id, OrderStatus s) {
super("ORDER_NOT_CANCELLABLE", "Order " + id + " is " + s + " and cannot be cancelled");
}
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail notFound(OrderNotFoundException ex) {
return problem(HttpStatus.NOT_FOUND, ex);
}
@ExceptionHandler({OrderNotCancellableException.class, OutOfStockException.class})
ProblemDetail conflict(BusinessException ex) {
return problem(HttpStatus.CONFLICT, ex); // 409: trạng thái không cho phép
}
// Lỗi validation @Valid: override hook của ResponseEntityExceptionHandler
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ex.getBody(); // đã có status 400
pd.setDetail("Validation failed");
pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage()))
.toList());
return ResponseEntity.badRequest().body(pd);
}
@ExceptionHandler(ConstraintViolationException.class) // từ @Validated ở service
ProblemDetail constraint(ConstraintViolationException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setProperty("errors", ex.getConstraintViolations().stream()
.map(v -> Map.of("field", v.getPropertyPath().toString(), "message", v.getMessage()))
.toList());
return pd;
}
@ExceptionHandler(Exception.class) // lưới cuối
ProblemDetail unexpected(Exception ex, HttpServletRequest req) {
String traceId = MDC.get("traceId");
log.error("Unhandled error on {} {} traceId={}", req.getMethod(), req.getRequestURI(), traceId, ex);
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error. Please contact support with the traceId.");
pd.setProperty("traceId", traceId);
return pd; // KHÔNG có ex.getMessage(), stack trace
}
private ProblemDetail problem(HttpStatus status, BusinessException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, ex.getMessage());
pd.setType(URI.create("https://api.shop.example/errors/" + ex.code().toLowerCase()));
pd.setProperty("code", ex.code());
pd.setProperty("traceId", MDC.get("traceId"));
return pd;
}
}
Bước 4: test bằng curl.
curl -i localhost:8080/api/orders/999
HTTP/1.1 404
Content-Type: application/problem+json
{"type":"https://api.shop.example/errors/order_not_found","title":"Not Found","status":404,
"detail":"Order 999 not found","instance":"/api/orders/999",
"code":"ORDER_NOT_FOUND","traceId":"6f1c2a9e4b7d0c31"}
curl -i -X POST localhost:8080/api/orders/42/cancel
HTTP/1.1 409
{"title":"Conflict","status":409,"detail":"Order 42 is SHIPPED and cannot be cancelled","code":"ORDER_NOT_CANCELLABLE",...}
curl -i -X DELETE localhost:8080/api/orders
HTTP/1.1 405 (ProblemDetail, nhờ ResponseEntityExceptionHandler)
5. Cách hoạt động
controller / service ném exception
│ bay ngược ra DispatcherServlet.processHandlerException
▼
HandlerExceptionResolver chain (theo thứ tự):
1. ExceptionHandlerExceptionResolver
tìm @ExceptionHandler trong chính controller
rồi trong các @ControllerAdvice (theo @Order)
chọn handler có kiểu exception GẦN NHẤT (OrderNotFound > BusinessException > Exception)
2. ResponseStatusExceptionResolver (@ResponseStatus, ResponseStatusException)
3. DefaultHandlerExceptionResolver (exception chuẩn của MVC → 4xx)
│
├─ có resolver xử lý → ghi response (ProblemDetail → application/problem+json)
└─ không ai xử lý → ném lên container → /error (BasicErrorController) → 500
Lưu ý: exception ném trong Filter (trước DispatcherServlet) KHÔNG đi qua @ControllerAdvice.
6. So sánh các cách
| Cách | Ưu | Nhược |
|---|---|---|
| try-catch trong controller | Rõ tại chỗ | Lặp, dễ sót, trộn logic |
@ResponseStatus trên exception class | Ngắn | Exception dính HTTP, khó thêm body |
throw new ResponseStatusException(404) | Nhanh cho chỗ lẻ | Service phụ thuộc HTTP |
@RestControllerAdvice + ProblemDetail | Tập trung, chuẩn RFC, service sạch | Phải thiết kế mã lỗi |
| Loại lỗi | Status |
|---|---|
| Body/param sai, validation | 400 |
| Chưa đăng nhập / không có quyền | 401 / 403 (Spring Security xử lý ở filter) |
| Không tìm thấy resource | 404 |
| Trạng thái không cho phép, trùng, optimistic lock | 409 |
| Đúng cú pháp nhưng vi phạm rule (vd. vượt hạn mức) | 422 (tùy team, nhiều team dùng 400) |
| Bug, DB chết | 500 / 503 |
7. Khi nào nên dùng / không nên dùng
- Mọi REST API nên có một
@RestControllerAdvicevới lướiException.classcuối cùng. - Log mức ERROR chỉ cho 5xx. 4xx log WARN/INFO, không kèm stack trace, không thì log ngập.
- Không dùng exception cho luồng bình thường (vd. "không có kết quả" khi search → trả list rỗng, không phải 404).
- Không trả
ex.getMessage()của exception lạ (SQL, đường dẫn file) ra client.
MDC, nhưng ai đặt nó vào MDC cho mỗi request? Đó là logic cắt ngang mọi request, cần Filter, Interceptor hoặc AOP.Mình có hai @RestControllerAdvice, một của module order, một chung. Cùng bắt Exception thì cái nào chạy?
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: Spring không chọn "handler cụ thể nhất trên toàn bộ các advice". Nó duyệt từng advice theo thứ tự (@Order), advice đầu tiên có handler khớp là thắng, kể cả khi advice sau có handler cụ thể hơn.
@RestControllerAdvice(basePackages = "com.shop.order")
@Order(Ordered.HIGHEST_PRECEDENCE)
class OrderExceptionHandler { ... }
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
class GlobalExceptionHandler { ... @ExceptionHandler(Exception.class) ... }
Quy tắc an toàn: chỉ một advice có handler Exception.class, đặt thứ tự thấp nhất. Advice riêng thì giới hạn bằng basePackages hoặc assignableTypes.
JWT sai thì mình ném exception trong filter, sao @RestControllerAdvice không bắt được?
Đúng rồi, vì filter chạy trước DispatcherServlet, còn @ControllerAdvice là cơ chế của DispatcherServlet.
Filter (Security, logging…) ← exception ở đây không tới advice
│
DispatcherServlet → HandlerExceptionResolver → @RestControllerAdvice
Cách xử lý:
- Lỗi bảo mật: dùng
AuthenticationEntryPoint(401) vàAccessDeniedHandler(403) của Spring Security, ghi ProblemDetail ở đó (bài 4.4.1). - Filter tự viết: try-catch trong filter và ghi response, hoặc inject
HandlerExceptionResolverrồi gọiresolver.resolveException(...)để tái dùng advice.
Service của mình có nên ném ResponseStatusException(HttpStatus.NOT_FOUND) cho nhanh không?
Chạy được, nhưng service lúc đó biết về HTTP. Khi OrderService được gọi từ Kafka consumer hay batch job, "404" không còn nghĩa gì. Nên để service ném business exception (OrderNotFoundException), và chỉ tầng web (advice) quyết định status code. Mã lỗi code dạng ORDER_NOT_FOUND giúp client xử lý theo mã, không phải parse chuỗi detail.
Phỏng vấn hỏi về exception handling trong Spring MVC thì trả lời sao?
Q1. Xử lý exception tập trung trong Spring MVC thế nào?
🇻🇳 Tiếng Việt:
Em dùng một class @RestControllerAdvice với các @ExceptionHandler, thường kế thừa ResponseEntityExceptionHandler để các lỗi chuẩn của Spring MVC cũng ra cùng format. Service ném business exception không phụ thuộc HTTP, advice map sang status: không tìm thấy là 404, sai trạng thái là 409, validation là 400. Cuối cùng có handler cho Exception trả 500 chung chung kèm traceId.
🇬🇧 English:
I use a single @RestControllerAdvice with @ExceptionHandler methods, usually extending ResponseEntityExceptionHandler so Spring MVC's standard errors share the same format. Services throw HTTP-agnostic business exceptions and the advice maps them to status codes: not found is 404, an invalid state is 409, validation is 400. A final handler for Exception returns a generic 500 with a traceId.
Q2. ProblemDetail là gì?
🇻🇳 Tiếng Việt:
Là chuẩn RFC 7807, nay là RFC 9457, cho body lỗi của HTTP API, media type application/problem+json, gồm type, title, status, detail, instance và cho phép thêm field như code, errors, traceId. Spring 6 có class ProblemDetail và các exception của MVC đã hỗ trợ sẵn, nên team không phải tự định nghĩa format lỗi.
🇬🇧 English:
It is the RFC 7807 standard, now RFC 9457, for HTTP API error bodies, with the application/problem+json media type and the fields type, title, status, detail and instance, plus custom extensions like code, errors or traceId. Spring 6 ships a ProblemDetail class and the MVC exceptions support it out of the box, so teams do not need to invent their own error format.
Q3. Làm sao không lộ thông tin nhạy cảm khi lỗi 500?
🇻🇳 Tiếng Việt:
Handler cuối không trả message hay stack trace của exception, chỉ trả thông báo chung và traceId. Toàn bộ chi tiết được log ở server kèm traceId để tra cứu. Em cũng tắt server.error.include-stacktrace và include-message.
🇬🇧 English:
The fallback handler never returns the exception message or stack trace, only a generic message and a traceId. The full details are logged on the server with that traceId so we can look them up. I also disable server.error.include-stacktrace and include-message.
Cách nhớ ngắn gọn
- Service ném business exception; advice map sang HTTP status.
- Body lỗi = ProblemDetail (RFC 9457) + code + errors + traceId.
- Kế thừa ResponseEntityExceptionHandler; lưới cuối Exception → 500 không lộ chi tiết.
- Exception trong Filter không tới advice.
Tự kiểm tra
Hủy order đã giao nên trả status gì?
409 Conflict (trạng thái hiện tại không cho phép).
Có handler cho BusinessException và OrderNotFoundException trong cùng advice: cái nào chạy?
Handler có kiểu gần nhất: OrderNotFoundException.
Media type của ProblemDetail?
application/problem+json.
Vì sao exception trong Servlet Filter không vào @RestControllerAdvice?
Filter chạy trước DispatcherServlet, advice chỉ hoạt động trong DispatcherServlet.
Lỗi 404 có nên log ERROR kèm stack trace không?
Không; 4xx log WARN/INFO, ERROR dành cho 5xx.