@RestControllerAdvice — tập trung xử lý exception cho toàn REST API
Mỗi controller tự catch exception = DRY violation và 5xx ẩn. Bài này giải thích 3 layer exception handling, cơ chế HandlerExceptionResolver, cách @RestControllerAdvice tập trung mapping domain exception → HTTP status, và khi nào nên dùng ResponseStatusException shortcut.
TL;DR: Spring có 3 layer exception handling theo thứ tự ưu tiên: @ExceptionHandler cục bộ trong controller → @RestControllerAdvice global → HandlerExceptionResolver default của framework. Không tập trung exception handling, mỗi controller tự try-catch — duplicate code và khó audit. @RestControllerAdvice là một class duy nhất chứa toàn bộ mapping domain exception → HTTP status, sử dụng HandlerExceptionResolverComposite để dispatch tới đúng handler. Domain exception giữ nguyên POJO thuần — không import gì từ org.springframework.http. ResponseStatusException là shortcut cho prototype hoặc endpoint one-off.
Bài Response handling — ResponseEntity & status giải thích HTTP status semantics. Bài này giải thích câu hỏi tiếp theo: khi service throw exception, làm sao biến nó thành response đúng status code một cách không lặp code?
1. Bài toán — exception rải rác trong controller
Hãy bắt đầu từ vấn đề cụ thể. App TaskFlow có 5 controller, mỗi endpoint cần trả 404 khi không tìm thấy resource, 409 khi conflict, 500 với message generic (không leak internal). Nếu mỗi controller tự xử lý:
@RestController
public class ProjectController {
@GetMapping("/projects/{id}")
public ProjectDto get(@PathVariable Long id) {
try {
return projectService.findById(id);
} catch (ProjectNotFoundException ex) {
// Copy-paste logic nay o 5 controller
return ResponseEntity.notFound().build(); // compile error -- wrong return type
} catch (Exception ex) {
log.error("Error", ex);
return ResponseEntity.status(500)...; // lap lai
}
}
}
Vấn đề ngay lập tức:
- Duplicate logic: 5 controller, mỗi cái copy đoạn try-catch này — muốn thay đổi format response phải sửa 5 chỗ.
- Inconsistent: dev A trả
{"error": "not found"}, dev B trả{"message": "not found"}— client không thể parse đồng nhất. - Audit khó: muốn biết "exception nào được handle?" phải đọc từng method trong từng controller.
- Type mismatch: method return
ProjectDtonhưng catch trảResponseEntity— compile error, phải đổi return type toàn bộ.
@RestControllerAdvice giải quyết tất cả bằng cách tách exception handling ra khỏi controller và tập trung vào một class.
2. 3 layer exception handling
Spring iterate qua 3 layer theo thứ tự ưu tiên khi một exception chưa được handle trong handler method:
| Layer | Scope | Khi nào dùng |
|---|---|---|
@ExceptionHandler trong controller | Chỉ controller đó | Rất hiếm — exception đặc thù 1 endpoint không áp dụng nơi nào khác |
@RestControllerAdvice global | Toàn app | Pattern chuẩn 2026 — mapping mọi domain exception |
HandlerExceptionResolver default | Spring framework | Fallback cuối — handle exception built-in MVC |
Khi exception không khớp layer nào, Spring trả 500 với body Problem Details generic. Vì vậy layer 2 luôn nên có @ExceptionHandler(Exception.class) làm catch-all.
3. Cơ chế bên dưới — HandlerExceptionResolver
Câu hỏi cơ chế: khi Spring dispatch exception tới đúng layer, cụ thể nó làm gì?
DispatcherServlet.processHandlerException() gọi HandlerExceptionResolverComposite, là một danh sách HandlerExceptionResolver được iterate theo thứ tự Order. Resolver nào resolve được thì trả về ModelAndView kèm status và chuỗi dừng ở đó; không resolver nào khớp thì exception được re-throw.
Ba resolver cụ thể:
ExceptionHandlerExceptionResolver (Order 0): Tìm method @ExceptionHandler — trước tiên trong controller hiện tại (Layer 1), sau đó trong các class @ControllerAdvice/@RestControllerAdvice được scan (Layer 2). Khi tìm thấy match, gọi method đó và serialize return value thành response body. Đây là resolver xử lý cả custom handler của bạn.
ResponseStatusExceptionResolver (Order 1): Đọc annotation @ResponseStatus trên class exception. Nếu exception annotated @ResponseStatus(HttpStatus.NOT_FOUND), resolver set status 404 và trả response. Không cần @ExceptionHandler — annotation trên class exception là đủ.
DefaultHandlerExceptionResolver (Order 2): Handle exception built-in Spring MVC như MethodArgumentNotValidException (400), HttpRequestMethodNotSupportedException (405), HttpMediaTypeNotSupportedException (415), MissingServletRequestParameterException (400). Boot 3+ resolver này trả body Problem Details JSON thay vì HTML.
Vì sao tách làm 3 resolver thay vì một? Mỗi resolver có chiến lược resolve khác nhau — đọc annotation, tìm method, handle hard-coded. Composite pattern cho phép extend bằng cách add resolver mới vào danh sách mà không sửa code hiện tại.
Đến đây có hai lần đếm "ba" và chúng không khớp nhau:

Layer 1 và layer 2 cùng do một resolver phân giải, nên thứ tự giữa chúng là luật nội bộ của resolver đó chứ không phải Order.
4. @RestControllerAdvice — tập trung mapping
@RestControllerAdvice = @ControllerAdvice + @ResponseBody. Giống như @RestController = @Controller + @ResponseBody, annotation này đảm bảo mọi method return value đều được serialize thành response body JSON tự động.
Cấu trúc class chuẩn:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// Domain exception → 404
@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(DuplicateProjectException.class)
public ProblemDetail handleDuplicateProject(DuplicateProjectException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
pd.setTitle("Duplicate project");
pd.setDetail(ex.getMessage());
pd.setProperty("projectKey", ex.getProjectKey());
return pd;
}
// Validation fail → 400 voi danh sach violations
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setDetail("Request has " + ex.getBindingResult().getErrorCount() + " invalid field(s)");
List<Map<String, String>> violations = ex.getBindingResult().getFieldErrors().stream()
.map(err -> Map.of(
"field", err.getField(),
"message", err.getDefaultMessage()
))
.toList();
pd.setProperty("violations", violations);
return pd;
}
// Catch-all — 500 generic, KHONG leak stack trace
@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(Exception ex, HttpServletRequest req) {
String requestId = MDC.get("requestId");
log.error("Unhandled exception, 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 with the request ID.");
pd.setProperty("requestId", requestId);
return pd;
}
}
Vì @RestControllerAdvice = @ResponseBody ngầm, method return ProblemDetail được Spring tự động serialize thành JSON với Content-Type: application/problem+json. Controller method không cần try-catch — chỉ throw exception, advice lo phần còn lại.
4.1 Thứ tự match exception type
Khi một exception throw, ExceptionHandlerExceptionResolver tìm method @ExceptionHandler có type gần nhất trong cây kế thừa:
@ExceptionHandler(RuntimeException.class) // match catch-all RuntimeException
public ProblemDetail handleRuntime(RuntimeException ex) { ... }
@ExceptionHandler(TaskNotFoundException.class) // match specific
public ProblemDetail handleNotFound(TaskNotFoundException ex) { ... }
Nếu TaskNotFoundException extends RuntimeException được throw: Spring chọn handler TaskNotFoundException vì nó gần hơn trong cây kế thừa. Handler RuntimeException chỉ được gọi khi không tìm thấy handler specific hơn.
Pitfall: nếu đặt @ExceptionHandler(Exception.class) trước các handler specific trong class, Spring vẫn chọn đúng — thứ tự trong class không quan trọng, Spring luôn chọn closest match. Nhưng nếu có hai advice class có handler cùng type, thứ tự @Order của advice class quyết định cái nào thắng.
5. Domain exception → HTTP status mapping
Pattern sạch nhất cho production: domain exception không biết gì về HTTP. Exception class là POJO thuần, advice class là nơi duy nhất biết HTTP status.
// Domain layer -- KHONG import spring.http
public class TaskNotFoundException extends RuntimeException {
private final Long taskId;
public TaskNotFoundException(Long taskId) {
super("Task " + taskId + " not found");
this.taskId = taskId;
}
public Long getTaskId() { return taskId; }
}
public class ProjectKeyConflictException extends RuntimeException {
private final String projectKey;
public ProjectKeyConflictException(String projectKey) {
super("Project key '" + projectKey + "' already exists");
this.projectKey = projectKey;
}
public String getProjectKey() { return projectKey; }
}
public class InvalidTaskStateException extends RuntimeException {
private final String currentState;
private final String attemptedTransition;
public InvalidTaskStateException(String currentState, String attemptedTransition) {
super("Cannot transition from " + currentState + " via " + attemptedTransition);
this.currentState = currentState;
this.attemptedTransition = attemptedTransition;
}
// getters...
}
// Service layer -- throw domain exception
@Service
public class TaskService {
public TaskDto findById(Long id) {
return taskRepo.findById(id)
.map(taskMapper::toDto)
.orElseThrow(() -> new TaskNotFoundException(id));
}
public TaskDto complete(Long id) {
Task task = taskRepo.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
if (!task.canComplete()) {
throw new InvalidTaskStateException(task.getStatus().name(), "COMPLETE");
}
task.complete();
return taskMapper.toDto(taskRepo.save(task));
}
}
// Web layer -- advice la noi duy nhat biet HTTP status
@RestControllerAdvice
public class GlobalExceptionHandler {
@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;
}
@ExceptionHandler(ProjectKeyConflictException.class)
public ProblemDetail handleProjectConflict(ProjectKeyConflictException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
pd.setTitle("Project key conflict");
pd.setDetail(ex.getMessage());
pd.setProperty("projectKey", ex.getProjectKey());
return pd;
}
@ExceptionHandler(InvalidTaskStateException.class)
public ProblemDetail handleInvalidState(InvalidTaskStateException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
pd.setTitle("Invalid task state transition");
pd.setDetail(ex.getMessage());
return pd;
}
}
Lợi ích của việc tách này:
- Domain layer test ngoài Spring:
assertThrows(TaskNotFoundException.class, () -> taskService.findById(999L))— không cần@SpringBootTest. - Đổi HTTP status không chạm service: muốn đổi "task not found" từ 404 sang 422? Sửa 1 dòng trong advice, không đụng service.
- Audit exception mapping: mở
GlobalExceptionHandler.java→ thấy toàn bộ domain exception nào map sang status nào.
6. ResponseStatusException — shortcut
ResponseStatusException là exception built-in Spring cho phép throw trực tiếp trong handler method mà không cần domain exception class riêng:
@GetMapping("/tasks/{id}")
public TaskDto get(@PathVariable Long id) {
return taskRepo.findById(id)
.map(taskMapper::toDto)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Task " + id + " not found"
));
}
ResponseStatusExceptionResolver (Order 1) handle nó: đọc status code từ exception, set response status, trả Problem Details body tự động với Boot 3+.
Khi nào phù hợp:
- Prototype / internal tool — code nhanh, ít class.
- Endpoint one-off — exception chỉ throw duy nhất một chỗ, không tái dùng.
- App nhỏ dưới 5 controller — domain layer chưa cần tách phức tạp.
Khi nào không phù hợp:
- Service layer throw exception nhưng cần test ngoài Spring —
ResponseStatusExceptioncoupling service với HTTP. - Cần custom field trong response (như
taskId,projectKey) —ResponseStatusExceptionchỉ cómessage. - Cùng exception throw từ nhiều nơi — phải copy status code mỗi nơi, brittle.
- API public cần error format stable và documented — domain exception + advice rõ ràng hơn.
// SAI -- method tra ProblemDetail nhung khong serialize
@ControllerAdvice
public class GlobalHandler {
@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handle(TaskNotFoundException ex) { ... }
}
@ControllerAdvice không có @ResponseBody ngầm như @RestControllerAdvice. Method return ProblemDetail sẽ được Spring hiểu như tên view để render, không phải response body JSON. Kết quả: client nhận HTML error page hoặc TemplateInputException.
// DUNG
@RestControllerAdvice // = @ControllerAdvice + @ResponseBody
public class GlobalHandler { ... }
// SAI -- Spring khong biet map status nao
throw new RuntimeException("Task " + id + " not found");
Không có @ExceptionHandler(RuntimeException.class) trong advice, exception rơi xuống DefaultHandlerExceptionResolver; resolver này cũng không match nên Spring wrap thành 500 với message ngẫu nhiên.
Ngay cả khi có @ExceptionHandler(Exception.class) catch-all, nó vẫn map thành 500, trong khi đây là 404 client error. Client tưởng server bug, thực ra là resource không tồn tại.
// DUNG -- domain exception specific + advice map sang 404
throw new TaskNotFoundException(id);
7. Liên hệ các bài khác
- ResponseEntity & status: hiểu HTTP status semantics trước khi đọc bài này — advice map exception sang status code nào là câu hỏi bài đó trả lời cho domain exception → HTTP.
- Problem Details (RFC 9457):
@RestControllerAdvicetrảProblemDetailobject — bài tiếp theo đào sâu format RFC 9457, 5 field standard,typeURI, custom extension, vàspring.mvc.problemdetails.enabled.
Tóm tắt
- Spring có 3 layer exception handling, iterate theo thứ tự: controller local →
@RestControllerAdviceglobal →HandlerExceptionResolverdefault. HandlerExceptionResolverCompositedispatch tới 3 resolver theoOrder:ExceptionHandlerExceptionResolver(tìm@ExceptionHandler) →ResponseStatusExceptionResolver(đọc@ResponseStatus) →DefaultHandlerExceptionResolver(exception built-in MVC).@RestControllerAdvice=@ControllerAdvice+@ResponseBody— mọi method return value tự serialize thành JSON response body.- Spring chọn handler
@ExceptionHandlervới type gần nhất trong cây kế thừa — đặt specific trước generic không bắt buộc nhưng tốt cho readability. - Domain exception là POJO thuần, không import HTTP. Advice class là nơi duy nhất biết HTTP status → domain testable ngoài Spring, mapping tập trung một chỗ.
ResponseStatusExceptionlà shortcut cho prototype / endpoint one-off — tránh ở service layer và app enterprise.- Catch-all
@ExceptionHandler(Exception.class)là bắt buộc — log server, response generic +requestId, không bao giờ expose stack trace.
Tự kiểm tra
- Q1Vì sao nên đặt toàn bộ
@ExceptionHandlervào một class@RestControllerAdvicethay vì rải vào từng controller? Liệt kê ít nhất 3 lý do cụ thể. - Q2Giải thích cơ chế
HandlerExceptionResolverComposite: nó gồm mấy resolver, thứ tự nào, và mỗi resolver handle loại exception gì? - Q3Vì sao domain exception (như
TaskNotFoundException) không nên importorg.springframework.http.HttpStatus? Lợi ích cụ thể là gì khi test service layer? - Q4App có
@ExceptionHandler(RuntimeException.class)và@ExceptionHandler(TaskNotFoundException.class)trong cùng một@RestControllerAdvice.TaskNotFoundException extends RuntimeException. Spring chọn handler nào khi throwTaskNotFoundException? Thứ tự khai báo trong class có ảnh hưởng không? - Q5Catch-all handler
@ExceptionHandler(Exception.class)trả response 500. Vì sao không bao giờ includeex.getMessage()hay stack trace trong response body production? Attacker khai thác thông tin đó thế nào?
Bài tiếp theo: Problem Details (RFC 9457)
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.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên