@RestControllerAdvice: Xử Lý Ngoại Lệ Tập Trung Toàn Cục
Khám phá kiến trúc 3 tầng xử lý ngoại lệ trong Spring MVC, cơ chế HandlerExceptionResolver và tập trung hóa ánh xạ lỗi với @RestControllerAdvice.
TL;DR: Xử lý lỗi phân tán bằng khối try-catch trong từng controller method là phản mẫu (anti-pattern) gây trùng lặp mã và làm lộ thông tin nhạy cảm. Spring MVC cung cấp chuỗi xử lý ngoại lệ 3 tầng: @ExceptionHandler cục bộ tại controller $\rightarrow$ @RestControllerAdvice toàn cục $\rightarrow$ HandlerExceptionResolver mặc định của framework. Sử dụng @RestControllerAdvice cho phép tập trung toàn bộ ánh xạ giữa domain exception và HTTP status code tại một điểm duy nhất, giữ cho tầng nghiệp vụ (domain layer) hoàn toàn độc lập với các thư viện HTTP của Spring.
1. Tại sao try-catch phân tán trong từng controller là phản mẫu thiết kế?
Khi một ứng dụng có hàng chục endpoint, việc mỗi controller tự bắt ngoại lệ bằng try-catch sẽ dẫn đến hàng loạt hệ lụy:
// Anti-pattern: try-catch phan tan gay trung lap va kho kiem soat
@RestController
public class ProjectController {
@GetMapping("/projects/{id}")
public ProjectDto getProject(@PathVariable Long id) {
try {
return projectService.findById(id);
} catch (ProjectNotFoundException ex) {
// Logic bi copy-paste qua nhieu controller khac nhau
throw new ResponseStatusException(HttpStatus.NOT_FOUND, ex.getMessage());
} catch (Exception ex) {
log.error("Internal error", ex);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Server error");
}
}
}
Các hạn chế nghiêm trọng của cách tiếp cận này:
- Vi phạm nguyên lý DRY: Cùng một logic định dạng lỗi 404 hay 409 bị sao chép ở mọi controller.
- Không nhất quán về định dạng phản hồi: Một lập trình viên có thể trả về
{"error": "not found"}, trong khi người khác trả về{"message": "not found"}. - Làm bẩn business logic: Controller method bị phân tâm bởi các khối xử lý ngoại lệ thay vì tập trung vào luồng nghiệp vụ chính.
2. Ba tầng xử lý ngoại lệ trong Spring MVC có thứ tự ưu tiên như thế nào?
Khi một ngoại lệ được ném ra từ controller method, Spring MVC lần lượt duyệt qua 3 tầng bảo vệ theo thứ tự ưu tiên:
| Tầng xử lý | Phạm vi hiệu lực | Khi nào áp dụng |
|---|---|---|
1. @ExceptionHandler trong Controller | Chỉ áp dụng cho chính Controller đó | Xử lý lỗi đặc thù riêng biệt của một endpoint cụ thể |
2. @RestControllerAdvice toàn cục | Toàn bộ ứng dụng | Chuẩn thiết kế production: Ánh xạ tập trung mọi domain exception |
3. HandlerExceptionResolver mặc định | Cấp độ Framework Spring MVC | Fallback cuối cùng cho các lỗi built-in (400, 405, 415) |
3. HandlerExceptionResolverComposite vận hành bên dưới khung làm việc ra sao?
Tại tầng điều phối trung tâm DispatcherServlet, phương thức processHandlerException() ủy quyền cho HandlerExceptionResolverComposite. Composite này quản lý một danh sách các resolver được sắp xếp theo chỉ số Order:

ExceptionHandlerExceptionResolver(Order 0): Tìm kiếm method@ExceptionHandlerphù hợp nhất. Đầu tiên nó kiểm tra trong chính controller hiện tại (Tầng 1), nếu không có thì tìm trong các bean@RestControllerAdvice(Tầng 2).ResponseStatusExceptionResolver(Order 1): Đọc annotation@ResponseStatustrên class của exception hoặc xử lý trực tiếpResponseStatusException.DefaultHandlerExceptionResolver(Order 2): Xử lý các ngoại lệ nội bộ của Spring nhưMethodArgumentNotValidException(400),HttpRequestMethodNotSupportedException(405).
4. @RestControllerAdvice tập trung hóa ánh xạ ngoại lệ như thế nào?
@RestControllerAdvice là sự kết hợp của @ControllerAdvice và @ResponseBody. Mọi giá trị trả về từ phương thức xử lý lỗi đều được tự động chuyển thành JSON thông qua HttpMessageConverter:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// Domain exception -> 404 Not Found
@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Task not found");
pd.setDetail(ex.getMessage());
pd.setProperty("taskId", ex.getTaskId());
return pd;
}
// Domain exception -> 409 Conflict
@ExceptionHandler(ProjectKeyConflictException.class)
public ProblemDetail handleDuplicateProject(ProjectKeyConflictException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
pd.setTitle("Project key conflict");
pd.setDetail(ex.getMessage());
pd.setProperty("projectKey", ex.getProjectKey());
return pd;
}
// Catch-all: Bat moi loi ngoai le chua khai bao -> 500 Internal Server Error
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnhandledException(Exception ex, HttpServletRequest req) {
String requestId = MDC.get("requestId");
log.error("Unhandled exception at path={}, requestId={}", req.getRequestURI(), requestId, ex);
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
pd.setTitle("Internal server error");
pd.setDetail("An unexpected error occurred. Please contact support.");
pd.setProperty("requestId", requestId);
return pd;
}
}
Nguyên tắc chọn handler theo khoảng cách kế thừa
Khi một ngoại lệ được ném ra, Spring MVC chọn phương thức @ExceptionHandler có kiểu khai báo gần nhất trên cây phả hệ kế thừa:
@ExceptionHandler(RuntimeException.class) // Generic handler
public ProblemDetail handleRuntimeException(RuntimeException ex) { ... }
@ExceptionHandler(TaskNotFoundException.class) // Specific handler
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) { ... }
Nếu TaskNotFoundException extends RuntimeException xảy ra, Spring luôn ưu tiên gọi handleTaskNotFound vì khoảng cách kế thừa bằng 0.
5. Làm sao ánh xạ domain exception sang HTTP status mà không làm bẩn domain?
Để giữ cho kiến trúc hệ thống tuân thủ Clean Architecture, các domain exception phải là POJO thuần túy và hoàn toàn không phụ thuộc vào Spring Web:
// Domain Layer: Khong import bat ky class nao tu org.springframework.http
public class TaskNotFoundException extends RuntimeException {
private final Long taskId;
public TaskNotFoundException(Long taskId) {
super("Task with id " + taskId + " does not exist");
this.taskId = taskId;
}
public Long getTaskId() {
return taskId;
}
}
// Service Layer: Nem domain exception thuan tuy
@Service
public class TaskService {
public TaskDto findById(Long id) {
return taskRepo.findById(id)
.map(taskMapper::toDto)
.orElseThrow(() -> new TaskNotFoundException(id));
}
}
Lợi ích kiến trúc:
- Dễ kiểm thử Unit Test: Tầng service có thể viết unit test thuần túy với Mockito mà không cần khởi động Spring Context.
- Khả năng tái sử dụng đa giao thức: Cùng một service có thể phục vụ cả REST API, gRPC và Message Consumer (Kafka/RabbitMQ) mà không bị phụ thuộc vào HTTP status code.
6. Khi nào nên dùng shortcut ResponseStatusException và khi nào cần tránh?
Spring cung cấp ResponseStatusException như một cách viết tắt nhanh:
@GetMapping("/tasks/{id}")
public TaskDto getTask(@PathVariable Long id) {
return taskRepo.findById(id)
.map(taskMapper::toDto)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Task not found"));
}
- Nên dùng khi: Viết prototype, các dự án nhỏ hoặc endpoint phụ không yêu cầu mở rộng trường dữ liệu lỗi tùy biến.
- Cần tránh khi: Các hệ thống production quy mô lớn vì nó làm lẫn lộn khái niệm HTTP vào tầng service, khó bổ sung custom properties và gây phân tán mã nguồn.
7. Các cạm bẫy bảo mật và kỹ thuật cần tuyệt đối lưu ý là gì?
Cạm bẫy 1: Dùng @ControllerAdvice thay vì @RestControllerAdvice
Nếu dùng @ControllerAdvice mà quên gắn @ResponseBody trên từng phương thức, Spring sẽ coi đối tượng trả về là tên view HTML, gây ra lỗi render hoặc trả về trang lỗi mặc định của máy chủ.
Cạm bẫy 2: Để lộ thông tin nhạy cảm qua ngoại lệ 500
Tuyệt đối không bao giờ trả về nguyên văn ex.getMessage() hoặc stack trace ra response trong môi trường production:
- Các thông báo như
SQLExceptioncó thể làm lộ cấu trúc bảng, tên cột và tài khoản cơ sở dữ liệu. - Kẻ tấn công có thể khai thác thông tin phiên bản thư viện trong stack trace để dò tìm các lỗ hổng bảo mật đã biết (CVE).
8. @RestControllerAdvice kết nối với các bài học khác ra sao?
- ResponseEntity & HTTP status codes: Xác lập nền tảng ngữ nghĩa các mã trạng thái HTTP trước khi ánh xạ chúng vào tầng ngoại lệ.
- Problem Details RFC 9457: Khám phá định dạng chuẩn hóa IETF của đối tượng
ProblemDetailđược trả về từ@RestControllerAdvice. - Jakarta Bean Validation: Kết hợp cùng
MethodArgumentNotValidExceptionđể trích xuất danh sách các vi phạm dữ liệu đầu vào gửi về cho client.
9. Những điểm then chốt cần ghi nhớ là gì?
- Tập trung hóa toàn bộ logic xử lý lỗi vào một lớp duy nhất được đánh dấu bằng
@RestControllerAdvice. - Duy trì domain exception dưới dạng POJO thuần, tách rời hoàn toàn khỏi các mã trạng thái HTTP.
- Khai báo catch-all handler cho
Exception.classđể bảo vệ hệ thống, gắnrequestIdphục vụ điều tra sự cố nội bộ. - Cơ chế
HandlerExceptionResolverCompositetự động tìm kiếm handler phù hợp nhất dựa trên khoảng cách phả hệ kế thừa của ngoại lệ.
10. Bài tập tự kiểm tra mức độ hiểu sâu
- Q1Tại sao nên tập trung hóa việc xử lý ngoại lệ vào @RestControllerAdvice thay vì bắt lỗi thủ công trong từng Controller?
- Q2Khi có cả handler cho RuntimeException và TaskNotFoundException (kế thừa RuntimeException) trong cùng một advice class, Spring MVC sẽ chọn handler nào khi TaskNotFoundException xảy ra?
- Q3Điều gì sẽ xảy ra nếu lập trình viên sử dụng annotation @ControllerAdvice thay vì @RestControllerAdvice cho lớp xử lý lỗi REST?
- Q4Tại sao trong Clean Architecture, domain exception không nên import hay chứa trường HttpStatus của Spring?
- Q5Tại sao tuyệt đối không nên trả về nguyên văn stack trace hoặc exception.getMessage() trong phản hồi lỗi 500 trên môi trường Production?
Bài tiếp theo: Problem Details (RFC 9457)
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Error, Validation & API Docs: Hoàn Thiện REST API
Bài tiếpProblem Details RFC 9457: Chuẩn Hóa Phản Hồi Lỗi REST
Lộ trình Junior Java Backend Developer
Chặng 15/21 · REST API với Spring MVC — bài 14/21 của chặng
Thảo luận & góp ý
Hỏi đáp về bài này