Xử lý exception tập trung với @ControllerAdvice trong Spring Boot
1. Tổng quan
Khi viết REST API, nếu mỗi controller tự try-catch rồi trả về response lỗi theo ý mình, bạn sẽ có hàng chục định dạng JSON lỗi khác nhau trong cùng một hệ thống. @ControllerAdvice giải quyết vấn đề này bằng cách cho phép bạn định nghĩa logic xử lý exception ở một nơi duy nhất, áp dụng cho toàn bộ (hoặc một phần) controller.
Đọc xong bài này, bạn sẽ biết cách viết một GlobalExceptionHandler chuẩn, map exception sang HTTP status phù hợp, trả về body lỗi nhất quán, và viết test để đảm bảo nó hoạt động đúng.
2. Vấn đề khi không xử lý tập trung
Hãy xem một controller “thường thấy” khi mới học Spring Boot:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public ResponseEntity<?> getOrder(@PathVariable Long id) {
try {
Order order = orderService.findById(id);
return ResponseEntity.ok(order);
} catch (OrderNotFoundException ex) {
Map<String, String> body = new HashMap<>();
body.put("error", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
}
}
Vấn đề:
- Logic xây body lỗi (
Map<String, String>) lặp lại ở mọi method, mọi controller. - Dễ quên
try-catchở method mới, dẫn đến lỗi 500 với stack trace lộ ra ngoài. - Không có chỗ nào xử lý exception “chung” như
MethodArgumentNotValidException(lỗi validate@Valid).
Lưu ý: Không phải lỗi gì cũng nên bắt bằng try-catch trong controller. Controller chỉ nên gọi service, còn việc “biến exception thành response” là trách nhiệm của tầng xử lý exception riêng.
3. @ControllerAdvice là gì
@ControllerAdvice là một annotation ở class-level, đánh dấu class đó chứa các method dùng để hỗ trợ (advise) nhiều controller cùng lúc — phổ biến nhất là xử lý exception thông qua @ExceptionHandler.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
ErrorResponse body = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
Instant.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
}
Giải thích:
@RestControllerAdvice=@ControllerAdvice+@ResponseBody, dùng cho REST API vì body trả về luôn được serialize thành JSON.@ExceptionHandler(OrderNotFoundException.class)đăng ký method này để xử lý riêng loại exceptionOrderNotFoundException— không cầntry-catchtrongOrderControllernữa.ErrorResponselà một record đơn giản, định nghĩa format lỗi thống nhất cho toàn hệ thống.
3.1. Định nghĩa ErrorResponse và exception tùy chỉnh
public record ErrorResponse(int status, String message, Instant timestamp) {
}
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long orderId) {
super("Không tìm thấy order với id = " + orderId);
}
}
OrderNotFoundException kế thừa RuntimeException nên không cần khai báo throws ở mọi nơi gọi. Trong OrderService, bạn chỉ cần throw khi không tìm thấy:
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Order findById(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
Controller giờ gọn lại rất nhiều:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.findById(id);
}
}
Không còn try-catch, không còn ResponseEntity thủ công — GlobalExceptionHandler lo hết phần chuyển đổi exception thành response.
4. Xử lý nhiều loại exception
Thực tế bạn sẽ có nhiều loại lỗi: not found (404), validate request sai (400), xung đột dữ liệu (409), lỗi hệ thống (500). @ExceptionHandler cho phép khai báo nhiều method, mỗi method xử lý một hoặc nhiều exception.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException ex) {
return buildResponse(HttpStatus.CONFLICT, "Dữ liệu bị xung đột, vui lòng thử lại");
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Lỗi hệ thống, vui lòng liên hệ hỗ trợ");
}
private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, String message) {
ErrorResponse body = new ErrorResponse(status.value(), message, Instant.now());
return ResponseEntity.status(status).body(body);
}
}
Giải thích:
- Mỗi
@ExceptionHandlerchỉ lo một loại lỗi, Spring tự chọn method khớp nhất dựa trên class hierarchy của exception được throw. handleUnknownbắtException.classlàm “lưới an toàn” cuối cùng — tránh để lộ stack trace gốc ra client. Đây gần như luôn nên có trong mọiGlobalExceptionHandler.- Thứ tự khai báo method không quyết định độ ưu tiên; Spring chọn handler có exception type cụ thể nhất khớp với exception thực tế được throw.
Lưu ý: Nếu hai
@ExceptionHandlercùng khai báo chung một exception type trong cùng một@ControllerAdvice, Spring sẽ báo lỗi ambiguous mapping khi khởi động app.
5. Xử lý lỗi validate với @Valid
Một tình huống rất thường gặp: validate request body bằng Bean Validation. Khi validate fail, Spring tự throw MethodArgumentNotValidException, bạn chỉ cần bắt nó và format lại cho dễ đọc.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CreateOrderRequest(
@NotBlank(message = "customerName không được để trống") String customerName,
@Positive(message = "amount phải lớn hơn 0") BigDecimal amount
) {
}
@PostMapping
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
Thêm handler cho MethodArgumentNotValidException:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
fieldErrors.put(error.getField(), error.getDefaultMessage());
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", HttpStatus.BAD_REQUEST.value());
body.put("errors", fieldErrors);
body.put("timestamp", Instant.now());
return ResponseEntity.badRequest().body(body);
}
Giải thích:
ex.getBindingResult().getFieldErrors()trả về danh sách field nào sai và message tương ứng — chính là message bạn khai báo trong@NotBlank,@Positive.- Dùng
LinkedHashMapđể giữ thứ tự field, dễ đọc khi debug.
Gửi request thiếu customerName và amount âm sẽ trả về:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"status": 400,
"errors": {
"customerName": "customerName không được để trống",
"amount": "amount phải lớn hơn 0"
},
"timestamp": "2026-10-05T10:15:30.123Z"
}
6. Thu hẹp phạm vi áp dụng với basePackages
Theo mặc định, @ControllerAdvice áp dụng cho toàn bộ controller trong application context. Nếu bạn có nhiều module (ví dụ admin và public-api) cần xử lý lỗi khác nhau, có thể giới hạn phạm vi:
@RestControllerAdvice(basePackages = "com.nonladev.shop.api.admin")
public class AdminExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
ErrorResponse body = new ErrorResponse(
HttpStatus.FORBIDDEN.value(), "Không có quyền truy cập", Instant.now());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
}
basePackages chỉ áp dụng advice này cho controller nằm trong package com.nonladev.shop.api.admin. Ngoài basePackages, bạn còn có thể dùng annotations (chỉ áp dụng cho controller có annotation cụ thể) hoặc assignableTypes (chỉ áp dụng cho một số class controller nhất định).
7. Dùng ProblemDetail (chuẩn RFC 7807) — cách làm hiện đại
Từ Spring Framework 6, có sẵn ProblemDetail triển khai chuẩn RFC 7807 cho response lỗi — không cần tự định nghĩa ErrorResponse nữa:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Order Not Found");
problem.setProperty("timestamp", Instant.now());
return problem;
}
}
Response sẽ có dạng:
{
"type": "about:blank",
"title": "Order Not Found",
"status": 404,
"detail": "Không tìm thấy order với id = 99",
"instance": null,
"timestamp": "2026-10-05T10:20:00Z"
}
ProblemDetail là lựa chọn tốt nếu team bạn muốn theo chuẩn chung, dễ tích hợp với client/tooling hiểu RFC 7807 (ví dụ một số API gateway, OpenAPI tooling).
8. Viết test cho GlobalExceptionHandler
Test nên đảm bảo đúng status code và đúng body — tránh để handler bị sửa sai mà không ai biết.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(GlobalExceptionHandler.class)
class OrderControllerExceptionTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
given(orderService.findById(99L))
.willThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/orders/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("Không tìm thấy order với id = 99"));
}
}
Hoặc test đơn vị trực tiếp trên handler, không cần context Spring:
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
void shouldMapOrderNotFoundTo404() {
ResponseEntity<ErrorResponse> response =
handler.handleNotFound(new OrderNotFoundException(42L));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody().message())
.isEqualTo("Không tìm thấy order với id = 42");
}
}
Cách thứ hai nhanh hơn vì không cần load MockMvc, phù hợp khi bạn chỉ muốn kiểm tra logic map exception → response, không liên quan routing.
9. Lỗi hay gặp
- Quên handler cho
Exception.class: khi có lỗi không lường trước (NullPointerException, lỗi kết nối DB…), client nhận response 500 kèm stack trace mặc định của Spring Boot — lộ thông tin nội bộ. Luôn có một handler “catch-all” ở cuối. - Để exception message lộ chi tiết nhạy cảm: ví dụ exception từ JPA có thể chứa tên bảng, tên cột. Đừng trả
ex.getMessage()trực tiếp choDataIntegrityViolationExceptionra client, hãy dùng message tổng quát. - Nhiều
@ControllerAdvicecùng xử lý một exception: dễ gây khó hiểu vì không rõ cái nào chạy. Nếu dùng nhiều advice, hãy dùng@Orderđể xác định thứ tự, hoặc tốt nhất gom logic chung vào một class. - Lạm dụng
@ControllerAdvicecho logic nghiệp vụ: advice chỉ nên lo việc “dịch” exception sang response, không nên chứa logic xử lý business (ví dụ gửi email thông báo lỗi) — việc đó nên nằm ở service hoặc event listener riêng.
Lưu ý:
@ControllerAdvicechỉ bắt được exception throw ra từ bên trong method của controller (hoặc các tầng được gọi từ đó) trong cùng một request thread. Exception throw trong một thread khác (ví dụ@Async) hoặc trong filter chạy trướcDispatcherServletsẽ không được bắt bởi@ControllerAdvice.
10. Kết luận
@ControllerAdvice kết hợp @ExceptionHandler giúp bạn tách biệt hoàn toàn logic xử lý lỗi ra khỏi controller, đảm bảo mọi response lỗi có cùng định dạng, đồng thời tránh lộ thông tin nhạy cảm ra client. Hãy luôn có một handler bắt Exception.class làm lưới an toàn cuối cùng, dùng @Valid + MethodArgumentNotValidException cho validate request, và cân nhắc ProblemDetail nếu muốn theo chuẩn RFC 7807. Viết test cho handler cũng quan trọng không kém viết test cho service — vì đây là nơi quyết định client thấy gì khi có lỗi.