Từ bài 15 tới bài 19, chúng ta liên tục gặp cùng một đoạn JSON: {"timestamp":…,"status":404,"error":"Not Found","path":…}. Đó là error response mặc định của Spring Boot, và mỗi bài chỉ trình bày nó đúng như vậy rồi đi tiếp. Nó chỉ có status và path, không có gì để client dựa vào mà xử lý, còn cách hoạt động mặc định đằng sau thì tệ hơn cả hình thức: sản phẩm không tồn tại lại trả về 500, và message có thể giải thích một lỗi 400 thì bị cắt bỏ.
Bài này thay thế nó, bắt đầu từ chính xác những gì Boot 4.1.1 làm khi không ai xử lý exception. Tiếp theo là: đặt status ngay từ exception, @ExceptionHandler trong một controller và trong @RestControllerAdvice cho mọi controller, các quy tắc Spring dùng để chọn một handler khi nhiều handler cùng khớp, ProblemDetail làm định dạng body, spring.mvc.problemdetails.enabled thay đổi gì và không thay đổi gì, tùy biến lỗi của chính Spring MVC bằng ResponseEntityExceptionHandler, và một handler catch-all không nuốt mất các response 4xx mà bạn muốn giữ.
![]()
Mọi thứ bên dưới chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9, Tomcat 11.0.24, Jackson 3.1.5, Hibernate Validator 9.1.3.Final) và Gradle 9.7.1. Mọi status line, header, body và dòng log đều copy từ chính những lần chạy đó. Phần lớn output của curl -i được cắt gọn, chỉ giữ status line, các header quan trọng và body.
Product API dùng trong bài
Chương 3 xây dựng dần một catalogue sản phẩm. Bài này dùng một phiên bản gọn của nó, lưu dữ liệu trong bộ nhớ vì database tới Chương 4 mới xuất hiện. Sinh project với starter web và validation:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation" -o demo.zipunzip demo.zip -d demoFile build.gradle sinh ra có dependency spring-boot-starter-webmvc và spring-boot-starter-validation. Sau đó thêm các class bên dưới. Các annotation ràng buộc và @Valid là chủ đề của bài 19; ở đây chúng chỉ dùng để tạo ra lỗi validation cần xử lý.
package com.example.demo.product;
import java.math.BigDecimal;
public record Product(Long id, String sku, String name, BigDecimal price) {
}package com.example.demo.product;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
public record CreateProductRequest(
@NotBlank String sku,
@NotBlank String name,
@NotNull @Positive BigDecimal price) {
}Hai exception của domain ban đầu chỉ là RuntimeException thông thường:
package com.example.demo.product;
public class ProductNotFoundException extends RuntimeException {
private final long productId;
public ProductNotFoundException(long productId) {
super("Product " + productId + " not found");
this.productId = productId;
}
public long getProductId() {
return productId;
}
}package com.example.demo.product;
public class DuplicateSkuException extends RuntimeException {
private final String sku;
public DuplicateSkuException(String sku) {
super("A product with SKU " + sku + " already exists");
this.sku = sku;
}
public String getSku() {
return sku;
}
}package com.example.demo.product;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
@Component
public class ProductStore {
private final Map<Long, Product> products = new ConcurrentHashMap<>();
private final AtomicLong nextId = new AtomicLong(1);
public ProductStore() {
save(new CreateProductRequest("KB-001", "Mechanical keyboard", new BigDecimal("1290000")));
}
public Product findById(long id) {
Product product = products.get(id);
if (product == null) {
throw new ProductNotFoundException(id);
}
return product;
}
public synchronized Product save(CreateProductRequest request) {
boolean skuTaken = products.values().stream()
.anyMatch(p -> p.sku().equals(request.sku()));
if (skuTaken) {
throw new DuplicateSkuException(request.sku());
}
long id = nextId.getAndIncrement();
Product product = new Product(id, request.sku(), request.name(), request.price());
products.put(id, product);
return product;
}
public synchronized Product replace(long id, CreateProductRequest request) {
findById(id);
Product product = new Product(id, request.sku(), request.name(), request.price());
products.put(id, product);
return product;
}
}package com.example.demo.product;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import java.net.URI;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private static final Set<String> SORT_FIELDS = Set.of("id", "name", "price");
private final ProductStore store;
public ProductController(ProductStore store) {
this.store = store;
}
@GetMapping("/{id}")
public Product findById(@PathVariable @Min(1) Long id) {
return store.findById(id);
}
@GetMapping
public String findAll(@RequestParam(defaultValue = "id") String sort) {
if (!SORT_FIELDS.contains(sort)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown sort field: " + sort);
}
return "sorted by " + sort;
}
@PostMapping
public ResponseEntity<Product> create(@Valid @RequestBody CreateProductRequest request) {
Product product = store.save(request);
return ResponseEntity.created(URI.create("/api/products/" + product.id())).body(product);
}
@PutMapping("/{id}")
public Product replace(@PathVariable @Min(1) Long id, @Valid @RequestBody CreateProductRequest request) {
return store.replace(id, request);
}
@GetMapping("/{id}/stock")
public int stock(@PathVariable Long id) {
store.findById(id);
throw new IllegalStateException("Inventory service did not respond");
}
@GetMapping("/{id}/summary")
public Product summary(@PathVariable Long id) {
return CompletableFuture.supplyAsync(() -> store.findById(id)).join();
}
}replace validate cả id lẫn body của một request PUT, điều này quan trọng ở phần lỗi validation. stock đóng vai một lời gọi tới inventory service bị lỗi, nên lúc nào cũng ném exception. summary load sản phẩm trên một thread khác và sẽ quan trọng ở phần exception bị bọc. Một controller thứ hai, nằm ở package khác, ném cùng ProductNotFoundException; nó cho thấy mỗi handler có tác dụng tới đâu:
package com.example.demo.review;
import com.example.demo.product.ProductStore;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ReviewController {
private final ProductStore store;
public ReviewController(ProductStore store) {
this.store = store;
}
@GetMapping("/api/products/{id}/reviews")
public List<String> reviews(@PathVariable Long id) {
store.findById(id);
return List.of("Great switches", "A bit loud");
}
}Build jar và chạy:
./gradlew bootJarjava -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8120GET /api/products/1 trả về {"id":1,"sku":"KB-001","name":"Mechanical keyboard","price":1290000}.
Spring Boot làm gì với exception không được xử lý
GET /api/products/1/stock ném IllegalStateException, và không có gì trong application xử lý nó:
curl -i http://localhost:8120/api/products/1/stockHTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 03:22:32 GMT
Connection: close
{"timestamp":"2026-09-13T03:22:32.702Z","status":500,"error":"Internal Server Error","path":"/api/products/1/stock"}Bốn field: thời điểm, status, reason phrase của status và path. Không có message, không có type của exception. Mọi exception không được xử lý đều nhận đúng body này, bất kể nó mang ý nghĩa gì. GET /api/products/99 trả về {"timestamp":"2026-09-13T03:22:32.740Z","status":500,"error":"Internal Server Error","path":"/api/products/99"}, và POST một sản phẩm có SKU KB-001 cũng nhận một lỗi 500 cùng dạng: sản phẩm không tồn tại và SKU bị trùng đều bị báo là lỗi phía server.
Log của application ghi exception đúng một lần, ở mức ERROR, kèm đầy đủ stack trace:
2026-09-13T10:22:32.696+07:00 ERROR 53270 --- [demo] [nio-8120-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: Inventory service did not respond] with root cause
java.lang.IllegalStateException: Inventory service did not respond
at com.example.demo.product.ProductController.stock(ProductController.java:59) ~[!/:0.0.1-SNAPSHOT]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:252) ~[spring-web-7.0.9.jar!/:7.0.9]Stack trace còn thêm 40 frame nữa đi qua Spring MVC, các servlet filter và Tomcat. Logger ở đây không phải của Spring: o.a.c.c.C.[.[.[/].[dispatcherServlet] là logger của Tomcat dành cho servlet, và message bọc exception trong Request processing failed. Exception đã rời hẳn khỏi Spring MVC trước khi có bất cứ thứ gì ghi response.
Trang Whitelabel cho trình duyệt
Cùng request đó nhưng với header Accept mà trình duyệt gửi thì nhận về HTML:
curl -i -H "Accept: text/html" http://localhost:8120/api/products/1/stockHTTP/1.1 500
Content-Type: text/html;charset=UTF-8
Content-Language: en-VN
Content-Length: 287
Date: Sat, 12 Sep 2026 07:36:08 GMT
Connection: close
<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Sat Sep 12 14:36:08 ICT 2026</div><div>There was an unexpected error (type=Internal Server Error, status=500).</div></body></html>BasicErrorController của Boot map /error hai lần, một lần produce text/html và một lần cho mọi trường hợp còn lại, như danh sách mapping ở bài 16 đã cho thấy. Ngày giờ và Content-Language lấy từ múi giờ và locale của server. Thay trang này bằng một template như templates/error/404.html là việc của bài 24; bài này tập trung vào JSON API.
Exception đi tới /error bằng cách nào
Với logging.level.org.springframework.web=DEBUG, cùng request đó ghi lại toàn bộ đường đi (stack trace sau dòng ERROR đã được lược bỏ):
2026-09-12T14:36:10.037+07:00 DEBUG 60415 --- [demo] [nio-8120-exec-1] o.s.web.servlet.DispatcherServlet : GET "/api/products/1/stock", parameters={}
2026-09-12T14:36:10.041+07:00 DEBUG 60415 --- [demo] [nio-8120-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.product.ProductController#stock(Long)
2026-09-12T14:36:10.047+07:00 DEBUG 60415 --- [demo] [nio-8120-exec-1] o.s.web.servlet.DispatcherServlet : Failed to complete request: java.lang.IllegalStateException: Inventory service did not respond
2026-09-12T14:36:10.047+07:00 ERROR 60415 --- [demo] [nio-8120-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: Inventory service did not respond] with root cause
2026-09-12T14:36:10.050+07:00 DEBUG 60415 --- [demo] [nio-8120-exec-1] o.s.web.servlet.DispatcherServlet : "ERROR" dispatch for GET "/error", parameters={}
2026-09-12T14:36:10.050+07:00 DEBUG 60415 --- [demo] [nio-8120-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.boot.webmvc.autoconfigure.error.BasicErrorController#error(HttpServletRequest)
2026-09-12T14:36:10.053+07:00 DEBUG 60415 --- [demo] [nio-8120-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Using 'application/json', given [*/*] and supported [application/json, application/*+json]
2026-09-12T14:36:10.053+07:00 DEBUG 60415 --- [demo] [nio-8120-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Writing [{timestamp=Sat Sep 12 14:36:10 ICT 2026, status=500, error=Internal Server Error, path=/api/products (truncated)...]
2026-09-12T14:36:10.064+07:00 DEBUG 60415 --- [demo] [nio-8120-exec-1] o.s.web.servlet.DispatcherServlet : Exiting from "ERROR" dispatch, status 500Bài 16 đã theo một request đi qua DispatcherServlet tới controller rồi quay về. Khi method ném exception thay vì return, DispatcherServlet chuyển exception cho các HandlerExceptionResolver của nó, lần lượt từng cái, cho tới khi có một cái xử lý được. Đọc danh sách đó từ application đang chạy cho kết quả sau, mỗi dòng một phần tử:
org.springframework.boot.webmvc.error.DefaultErrorAttributes
org.springframework.web.servlet.handler.HandlerExceptionResolverComposite
- org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver
- org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver
- org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolverDefaultErrorAttributeslà của Boot. Nó ghi lại exception để error body dùng về sau, và luôn chuyển exception đi tiếp.ExceptionHandlerExceptionResolverchạy các method@ExceptionHandler: của chính controller trước, rồi tới các method trong class@ControllerAdvice. Đây là resolver duy nhất để code của bạn tự ghi response.ResponseStatusExceptionResolverxử lýResponseStatusExceptionvà các exception có annotation@ResponseStatus, bằng cách gọiresponse.sendError(status).HandlerMethodValidationExceptionkế thừaResponseStatusException, nên các lỗi validation parameter ở bài 19 đã kết thúc tại đây.DefaultHandlerExceptionResolverxử lý các exception của chính Spring MVC, như các response 404, 405, 415 và những lỗi 400 còn lại mà bài 16 tới 19 đã gặp, cũng theo cách đó.
IllegalStateException không khớp resolver nào, nên DispatcherServlet ném lại nó (Failed to complete request) và Tomcat ghi dòng log ERROR. Sau đó Tomcat forward request tới trang lỗi của nó, /error, dưới dạng một "ERROR" dispatch. Lượt thứ hai đi qua DispatcherServlet này tới BasicErrorController#error, nơi ghi map ra thành JSON. sendError cũng kết thúc bằng đúng "ERROR" dispatch đó, nên mọi đường đi, trừ @ExceptionHandler, đều dừng ở BasicErrorController.

Hiện thêm thông tin trong body mặc định với spring.web.error.*
Boot dựng body mặc định từ các error attribute và bỏ đi những attribute dễ làm lộ thông tin, trừ khi bạn yêu cầu. Các property cùng giá trị mặc định trong 4.1.1:
| Property | Mặc định | Thêm vào body |
|---|---|---|
spring.web.error.include-message | never | message: message của exception |
spring.web.error.include-binding-errors | never | errors: mọi lỗi validation kèm code của chúng |
spring.web.error.include-stacktrace | never | trace: toàn bộ stack trace trong một string |
spring.web.error.include-exception | false | exception: tên class của exception |
spring.web.error.include-path | always | path |
spring.web.error.path | /error | path của error controller, không phải field trong body |
spring.web.error.whitelabel.enabled | true | trang HTML ở trên |
Đặt ba property đầu tiên trong config trông như sau; mỗi lần chạy bên dưới chỉ bật một property, truyền dưới dạng argument -- trên command line:
spring.web.error.include-message=always
spring.web.error.include-binding-errors=always
spring.web.error.include-stacktrace=alwaysspring:
web:
error:
include-message: always
include-binding-errors: always
include-stacktrace: alwaysVới include-message=always, request stock bị lỗi có thêm message:
{"timestamp":"2026-09-12T07:36:11.852Z","status":500,"error":"Internal Server Error","message":"Inventory service did not respond","path":"/api/products/1/stock"}Cùng lần chạy đó cho "message":"Method 'DELETE' is not supported." ở lỗi 405 và "message":"Validation failed for object='createProductRequest'. Error count: 2" ở một POST /api/products không hợp lệ. Trang Whitelabel cũng có thêm một <div> chứa message.
include-binding-errors=always biến lỗi validation đó thành bản dump đầy đủ của binding result. Body cho {"sku":"","name":"Keyboard","price":-5}, đã format cho dễ đọc:
{
"timestamp": "2026-09-12T07:36:15.141Z",
"status": 400,
"error": "Bad Request",
"errors": [
{
"objectName": "createProductRequest",
"field": "price",
"rejectedValue": -5,
"codes": ["Positive.createProductRequest.price", "Positive.price", "Positive.java.math.BigDecimal", "Positive"],
"arguments": [
{
"arguments": null,
"code": "price",
"codes": ["createProductRequest.price", "price"],
"defaultMessage": "price"
}
],
"bindingFailure": false,
"code": "Positive",
"defaultMessage": "must be greater than 0"
},
{
"objectName": "createProductRequest",
"field": "sku",
"rejectedValue": "",
"codes": ["NotBlank.createProductRequest.sku", "NotBlank.sku", "NotBlank.java.lang.String", "NotBlank"],
"arguments": [
{
"arguments": null,
"code": "sku",
"codes": ["createProductRequest.sku", "sku"],
"defaultMessage": "sku"
}
],
"bindingFailure": false,
"code": "NotBlank",
"defaultMessage": "must not be blank"
}
],
"path": "/api/products"
}Hữu ích khi debug, nhưng thừa rất nhiều so với những gì client cần: message code, tên một object nội bộ, type Java của từng field. Ở phần sau của bài, cùng lỗi này chỉ còn là một danh sách hai phần tử.
include-stacktrace=always đưa stack trace vào response. Phần đầu của body, cắt sau ba frame:
{"timestamp":"2026-09-13T03:22:34.235Z","status":500,"error":"Internal Server Error","trace":"java.lang.IllegalStateException: Inventory service did not respond\n\tat com.example.demo.product.ProductController.stock(ProductController.java:59)\n\tat java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)\n\tat java.base/java.lang.reflect.Method.invoke(Method.java:580)\n\tat …server.error.* không còn tác dụng trong Spring Boot 4
Boot 4.0 đổi tên các property server.error.* thành spring.web.error.*. Tên cũ vẫn còn trong property metadata của Boot, được đánh dấu deprecated với level error, nghĩa là chúng không còn được bind nữa. Chạy với --server.error.include-message=always, application khởi động không có một cảnh báo nào và không có gì thay đổi:
{"timestamp":"2026-09-12T07:36:16.822Z","status":500,"error":"Internal Server Error","path":"/api/products/1/stock"}Một tutorial hay một file application.properties cũ còn đặt server.error.include-message giờ sẽ hỏng mà không báo gì. Hãy đổi tên key.
Vì sao không được đưa stack trace vào response production
- Stack trace là bản đồ code của bạn. Tên package và class, tên method, số dòng, cả framework và server bên dưới:
InvocableHandlerMethodcho biết đó là Spring MVC, phần còn lại của trace cho biết đó là Tomcat. Kẻ tấn công nhờ đó biết nên thử những lỗ hổng đã biết nào. - Message làm lộ dữ liệu. Message của exception chứa bất cứ thứ gì code đưa vào: mẩu SQL, đường dẫn file, tên host, email của một khách hàng khác.
include-message=alwayscông khai message của mọi exception, kể cả exception do thư viện bạn không viết ném ra. - Client không dùng được nó. Không có gì trong stack trace cho người gọi biết cần sửa gì trong request.
- Log đã có sẵn nó. Dòng log ERROR ở trên chứa đầy đủ stack trace, trên server, đúng nơi nó thuộc về.
Phần còn lại của bài xây dựng một body nói đúng những gì client cần và không gì khác.
Đặt status ngay từ exception
@ResponseStatus trên class exception
Cách sửa đầu tiên cho sản phẩm không tồn tại là một annotation trên exception của nó:
package com.example.demo.product;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ProductNotFoundException extends RuntimeException {curl -i http://localhost:8120/api/products/99HTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-09-12T07:40:49.176Z","status":404,"error":"Not Found","path":"/api/products/99"}Status đã đúng; body vẫn là body mặc định của Boot. Log DEBUG cho thấy lý do:
2026-09-12T14:40:53.844+07:00 DEBUG 72729 --- [demo] [nio-8120-exec-1] .w.s.m.a.ResponseStatusExceptionResolver : Resolved [com.example.demo.product.ProductNotFoundException: Product 99 not found]
2026-09-12T14:40:53.845+07:00 DEBUG 72729 --- [demo] [nio-8120-exec-1] o.s.web.servlet.DispatcherServlet : Completed 404 NOT_FOUND
2026-09-12T14:40:53.846+07:00 DEBUG 72729 --- [demo] [nio-8120-exec-1] o.s.web.servlet.DispatcherServlet : "ERROR" dispatch for GET "/error", parameters={}
2026-09-12T14:40:53.847+07:00 DEBUG 72729 --- [demo] [nio-8120-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.boot.webmvc.autoconfigure.error.BasicErrorController#error(HttpServletRequest)ResponseStatusExceptionResolver đọc annotation và gọi sendError(404), rồi "ERROR" dispatch đưa request tới BasicErrorController như trước. Có thêm hai thay đổi. Dòng log ERROR của Tomcat biến mất, và ResponseStatusExceptionResolver cũng không ghi gì thay vào đó ở các log level mặc định. Còn với include-message=always, body có thêm "message":"Product 99 not found", vì DefaultErrorAttributes đã ghi lại exception trên đường đi.
Resolver này cũng xét cả cause. GET /api/products/99/summary ném một CompletionException có cause là ProductNotFoundException, và nó cũng trả về 404.
ResponseStatusException cho lỗi dùng một lần
Khi chính controller phát hiện vấn đề và không có exception domain nào cho trường hợp đó, ResponseStatusException mang status ngay tại chỗ. findAll ném nó khi gặp field sort không tồn tại:
curl -i "http://localhost:8120/api/products?sort=colour"HTTP/1.1 400
Content-Type: application/json
{"timestamp":"2026-09-12T07:36:08.471Z","status":400,"error":"Bad Request","path":"/api/products"}Reason truyền vào constructor, Unknown sort field: colour, mặc định không xuất hiện trong body. Nó chỉ hiện ra khi bật include-message=always, dưới dạng "message":"Unknown sort field: colour", kèm theo mọi rủi ro của message nói chung.
Dùng @ResponseStatus cho exception domain được ném từ code ở tầng sâu hơn, và ResponseStatusException cho một kiểm tra chỉ thuộc về một endpoint. Cả hai chỉ chọn status. Body vẫn là bốn field của Boot, và không cách nào thêm được một field mà client dùng được.
@ExceptionHandler trong một controller
Method @ExceptionHandler chạy thay cho đường xử lý lỗi và tự trả về response. Bắt đầu với một type body nhỏ của riêng bạn:
package com.example.demo.product;
public record ApiError(int status, String message, String path) {
}Rồi thêm hai handler method vào ProductController:
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.context.request.WebRequest;
// the mappings above are unchanged
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ProductNotFoundException ex, HttpServletRequest request) {
ApiError body = new ApiError(404, ex.getMessage(), request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(DuplicateSkuException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ApiError handleDuplicateSku(DuplicateSkuException ex, WebRequest request) {
return new ApiError(409, ex.getMessage(), request.getDescription(false));
}
}curl -i http://localhost:8120/api/products/99HTTP/1.1 404
Content-Type: application/json
{"status":404,"message":"Product 99 not found","path":"/api/products/99"}curl -i -H "Content-Type: application/json" -d '{"sku":"KB-001","name":"Another keyboard","price":990000}' http://localhost:8120/api/productsHTTP/1.1 409
Content-Type: application/json
Content-Length: 94
{"status":409,"message":"A product with SKU KB-001 already exists","path":"uri=/api/products"}Hai handler này cho thấy:
- Giá trị của
@ExceptionHandlerlà các type exception mà method xử lý, và method nhận exception qua một parameter. - Parameter của request.
HttpServletRequestcho request servlet gốc;WebRequestlà abstraction của Spring bọc bên ngoài nó.getDescription(false)trả vềuri=/api/products, có cả tiền tố, vì vậy body thứ hai ghiuri=: muốn lấy path thì dùnggetRequestURI(). - Status. Trả
ResponseEntitythì status được đặt trong code, theo từng lần gọi. Chỉ trả body thì cần@ResponseStatustrên handler, cũng là lựa chọn bài 17 đã đưa ra cho các method controller thông thường. Một khi handler đã nhận exception,@ResponseStatustrênProductNotFoundExceptionkhông còn vai trò gì: handler quyết định. - Không có gì được ghi log. Cả hai request đều không tạo ra dòng log nào.
Giới hạn nằm ở class. ReviewController ném cùng exception đó mà không nhận được gì từ các handler này:
curl -i http://localhost:8120/api/products/99/reviewsHTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-09-12T07:40:55.336Z","status":404,"error":"Not Found","path":"/api/products/99/reviews"}Lỗi 404 đó đến từ annotation trên exception, thông qua ResponseStatusExceptionResolver. Handler trong ProductController chỉ được dùng cho exception ném ra từ chính các handler method của ProductController.
@RestControllerAdvice: một handler cho mọi controller
Class @ControllerAdvice chứa các method @ExceptionHandler dùng chung cho nhiều controller. @RestControllerAdvice là annotation đó cộng thêm @ResponseBody, để giá trị return trở thành response body. Đọc meta-annotation của nó bằng reflection cho đúng như vậy, sau các annotation chuẩn @Target, @Retention và @Documented:
java.lang.annotation.Target
java.lang.annotation.Retention
java.lang.annotation.Documented
org.springframework.web.bind.annotation.ControllerAdvice
org.springframework.web.bind.annotation.ResponseBodyChuyển hai handler ra khỏi ProductController vào một class riêng. Ở đây class này nằm trong com.example.demo; nó nên đặt ở đâu trong một cấu trúc package lớn hơn là câu hỏi của bài 21.
package com.example.demo;
import com.example.demo.product.ApiError;
import com.example.demo.product.DuplicateSkuException;
import com.example.demo.product.ProductNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ProductNotFoundException ex, HttpServletRequest request) {
ApiError body = new ApiError(404, ex.getMessage(), request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(DuplicateSkuException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ApiError handleDuplicateSku(DuplicateSkuException ex, HttpServletRequest request) {
return new ApiError(409, ex.getMessage(), request.getRequestURI());
}
}Giờ cả hai controller đều trả về cùng một dạng body:
GET /api/products/99 -> 404 {"status":404,"message":"Product 99 not found","path":"/api/products/99"}
GET /api/products/99/reviews -> 404 {"status":404,"message":"Product 99 not found","path":"/api/products/99/reviews"}Exception đã được xử lý vẫn không để lại dấu vết gì trong log. Với lỗi 404 thì thường đó là điều bạn muốn; nếu cần một dòng log cho mỗi exception đã xử lý trong lúc điều tra, spring.mvc.log-resolved-exception=true thêm một dòng ở mức WARN:
2026-09-12T14:40:58.367+07:00 WARN 72794 --- [demo] [nio-8120-exec-1] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [com.example.demo.product.ProductNotFoundException: Product 99 not found]Handler trả về body thì cần lấy status từ đâu đó. Phiên bản handler not-found dưới đây không có @ResponseStatus và cũng không dùng ResponseEntity:
@ExceptionHandler(ProductNotFoundException.class)
public ApiError handleNotFound(ProductNotFoundException ex, HttpServletRequest request) {
return new ApiError(404, ex.getMessage(), request.getRequestURI());
}HTTP/1.1 200
Content-Type: application/json
Content-Length: 73
{"status":404,"message":"Product 99 not found","path":"/api/products/99"}200 OK, với một body ghi 404. @ResponseStatus(HttpStatus.NOT_FOUND) trên class exception không được dùng tới: khi đã có handler thì chỉ handler đặt status. Client và hệ thống monitoring nhìn vào status line, không nhìn con số nằm trong JSON.
Response 401 và 403 của Spring Security được tạo bởi các filter của nó trước khi DispatcherServlet chạy, nên mặc định @ExceptionHandler không thấy chúng; Chương 5 sẽ cấu hình chúng.
Giới hạn advice bằng basePackages, assignableTypes hoặc annotations
Mặc định một advice áp dụng cho mọi controller. Ba attribute giúp thu hẹp lại:
@RestControllerAdvice
@RestControllerAdvice(basePackages = "com.example.demo.product")
public class GlobalExceptionHandler {ProductController vẫn nhận body ApiError. ReviewController nằm ở com.example.demo.review, ngoài package đó, nên quay về body mặc định:
{"timestamp":"2026-09-12T07:40:59.838Z","status":404,"error":"Not Found","path":"/api/products/99/reviews"}assignableTypes = ProductController.class chọn controller theo type, còn annotations = RestController.class chọn controller có mang một annotation. Nhờ chúng, một application có thể giữ error contract dạng JSON cho các controller /api tách biệt khỏi, chẳng hạn, các controller render trang.
@ExceptionHandler nào thắng khi nhiều handler cùng khớp
Application thực tế có handler ở nhiều cấp, và một exception thường khớp với hơn một handler. Các quy tắc của Spring, từng quy tắc được kiểm chứng bên dưới trên Framework 7.0.9, gói gọn trong một hình:

Trong một class, type exception gần nhất thắng
Một advice có một handler tổng quát và một handler cụ thể:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiError> handleRuntime(RuntimeException ex, HttpServletRequest request) {
ApiError body = new ApiError(500, "Unexpected error", request.getRequestURI());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ProductNotFoundException ex, HttpServletRequest request) {
ApiError body = new ApiError(404, ex.getMessage(), request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
}GET /api/products/99 -> 404 {"status":404,"message":"Product 99 not found","path":"/api/products/99"}
GET /api/products/1/stock -> 500 {"status":500,"message":"Unexpected error","path":"/api/products/1/stock"}ProductNotFoundException là một RuntimeException, nên cả hai method đều khớp. Spring sắp xếp các kết quả khớp theo số bước đi lên cây kế thừa từ type bị ném tới type của từng handler: ProductNotFoundException cách 0 bước, RuntimeException cách 1 bước. Handler gần nhất thắng: handler RuntimeException đứng trước trong file mà vẫn thua. IllegalStateException thì chỉ khớp handler tổng quát.
Handler của chính controller thắng mọi advice
Giờ đặt handler tổng quát trong controller và chỉ giữ handler cụ thể trong advice:
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiError> handleRuntime(RuntimeException ex, HttpServletRequest request) {
ApiError body = new ApiError(500, "Unexpected error", request.getRequestURI());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}GlobalExceptionHandler là phiên bản ở phần trước, với handler ProductNotFoundException.
GET /api/products/99 -> 500 {"status":500,"message":"Unexpected error","path":"/api/products/99"}
GET /api/products/99/reviews -> 404 {"status":404,"message":"Product 99 not found","path":"/api/products/99/reviews"}Advice có handler cho đúng type, vậy mà vẫn thua. Spring tìm trong class của controller trước và dừng lại ở class đầu tiên có bất kỳ handler nào khớp; phép so khoảng cách chỉ xếp hạng các handler bên trong class đó. ReviewController không có handler riêng, nên request của nó đi tiếp tới advice.
Sắp thứ tự nhiều advice bằng @Order
Tách hai handler thành hai advice và gán thứ tự:
@RestControllerAdvice
@Order(1)
public class FallbackExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiError> handleRuntime(RuntimeException ex, HttpServletRequest request) {
ApiError body = new ApiError(500, "Unexpected error", request.getRequestURI());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}@RestControllerAdvice
@Order(2)
public class ProductExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ProductNotFoundException ex, HttpServletRequest request) {
ApiError body = new ApiError(404, ex.getMessage(), request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
}FallbackExceptionHandler | ProductExceptionHandler | GET /api/products/99 |
|---|---|---|
@Order(1) | @Order(2) | 500, Unexpected error |
@Order(2) | @Order(1) | 404, Product 99 not found |
không có @Order | không có @Order | 500, Unexpected error |
Advice được tìm từ giá trị @Order nhỏ nhất trở lên, với cùng quy tắc class đầu tiên khớp sẽ thắng: advice được hỏi trước trả lời bằng bất cứ handler nào nó có, dù tổng quát tới đâu. Không có @Order, cả hai đều ở mức ưu tiên thấp nhất và thứ tự giữa chúng không nằm trong tầm kiểm soát của bạn; project này tình cờ đặt FallbackExceptionHandler lên trước. Nếu chia handler ra nhiều advice, hãy gán thứ tự tường minh, hoặc giữ chúng trong một class để type gần nhất quyết định.
Khớp theo cause của exception bị bọc
summary load sản phẩm bằng CompletableFuture.supplyAsync(...).join(). join() bọc mọi thứ task ném ra trong một CompletionException, nên exception tới được Spring không phải exception mà ProductStore đã ném. Với GlobalExceptionHandler hai handler ở phần advice, vốn không có handler cho CompletionException:
GET /api/products/99/summary -> 404 {"status":404,"message":"Product 99 not found","path":"/api/products/99/summary"}Handler ProductNotFoundException đã chạy, và parameter ex của nó giữ cause, message nó trả về chính là message của cause. Khi không có handler nào trong một class khớp với type của exception bị ném, Spring lặp lại việc tìm với cause của nó, rồi với cause của cause. Một ProductNotFoundException bị bọc hai và ba tầng trong các exception khác cũng được khớp theo cách đó.
Khớp theo cause là phương án cuối cùng bên trong một class, và vẫn tuân theo thứ tự giữa các class:
- Với advice có cả handler
RuntimeExceptionlẫnProductNotFoundException, cùng request đó trả về 500Unexpected error. Bản thânCompletionExceptionlà mộtRuntimeException, nên class đã có handler khớp với type bị ném và không bao giờ xét tới cause. - Handler của controller chỉ khớp theo cause vẫn thắng một advice khớp đúng type của wrapper. Với handler
ProductNotFoundExceptiontrongProductControllervà một advice xử lýCompletionException, request trả về 404 từ controller.
Gộp lại, Spring đi qua các class theo thứ tự cố định và trong mỗi class tìm theo hai bước:
- Class của chính controller, rồi từng advice áp dụng được, từ
@Ordernhỏ nhất trở lên. Class đầu tiên có handler khớp sẽ thắng. - Bên trong một class: handler cho supertype gần nhất của exception bị ném. Chỉ khi class hoàn toàn không có handler cho type đó, Spring mới tìm tương tự với cause của nó, ở mọi độ sâu.
ProblemDetail: định dạng lỗi theo RFC 9457
ApiError dùng được, nhưng đó lại là thêm một định dạng tự chế mà client phải học. RFC 9457, Problem Details for HTTP APIs (tháng 7 năm 2023), định nghĩa một định dạng chuẩn, và phần header của nó ghi Obsoletes: 7807, tức RFC đầu tiên định nghĩa định dạng này. Nó cho error body một nhóm member nhỏ và media type riêng, application/problem+json. Class tương ứng trong Spring là org.springframework.http.ProblemDetail:
| Member | Ý nghĩa trong RFC 9457 | Trong Spring |
|---|---|---|
type | URI định danh loại vấn đề; khi vắng mặt thì là about:blank | setType(URI) |
title | tóm tắt ngắn, dễ đọc cho loại vấn đề đó | setTitle(String); nếu không đặt thì là reason phrase của status |
status | HTTP status code | ProblemDetail.forStatus(...), forStatusAndDetail(...) |
detail | giải thích cho lần xảy ra cụ thể này | forStatusAndDetail(status, detail), setDetail(String) |
instance | URI cho lần xảy ra này | setInstance(URI); Spring MVC tự điền path của request nếu chưa đặt |
| extension member | mọi member khác mà loại vấn đề đó định nghĩa | setProperty(name, value) |
Viết lại advice để trả về ProblemDetail:
package com.example.demo;
import com.example.demo.product.DuplicateSkuException;
import com.example.demo.product.ProductNotFoundException;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail handleNotFound(ProductNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setType(URI.create("https://api.example.com/problems/product-not-found"));
problem.setTitle("Product not found");
problem.setProperty("productId", ex.getProductId());
return problem;
}
@ExceptionHandler(DuplicateSkuException.class)
public ResponseEntity<ProblemDetail> handleDuplicateSku(DuplicateSkuException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setType(URI.create("https://api.example.com/problems/duplicate-sku"));
problem.setTitle("Duplicate SKU");
problem.setProperty("sku", ex.getSku());
return ResponseEntity.of(problem).build();
}
}curl -i http://localhost:8120/api/products/99HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"Product 99 not found","instance":"/api/products/99","status":404,"title":"Product not found","type":"https://api.example.com/problems/product-not-found","productId":99}Còn SKU bị trùng:
HTTP/1.1 409
Content-Type: application/problem+json
{"detail":"A product with SKU KB-001 already exists","instance":"/api/products","status":409,"title":"Duplicate SKU","type":"https://api.example.com/problems/duplicate-sku","sku":"KB-001"}Handler không hề đặt instance; Spring MVC tự điền nó từ path của request. Các member chuẩn xuất hiện theo thứ tự alphabet, và các extension member như productId nằm ngay sau chúng ở cấp cao nhất, không lồng trong một object properties. URI type không bắt buộc phải truy cập được, nhưng nếu có thì RFC 9457 khuyến khích nó dẫn tới một trang mô tả vấn đề.
Trả ProblemDetail trực tiếp hay qua ResponseEntity
Cả hai handler ở trên đều đặt đúng status. Giá trị return là một ProblemDetail trần sẽ lấy HTTP status từ chính object. ResponseEntity.of(problem) cũng vậy và trả về một builder, nên bạn có thể thêm header trước khi gọi build(): đó là lý do để chọn nó.
Điều cần tránh là đặt status hai lần. Một handler tạo ResponseEntity.status(HttpStatus.CONFLICT).body(problem) quanh một problem được tạo cho 400:
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(problem);HTTP/1.1 409
Content-Type: application/problem+json
{"detail":"A product with SKU KB-001 already exists","instance":"/api/products","status":400,"title":"Bad Request"}Status line nói 409 còn body nói 400. Spring gửi status của ResponseEntity và ghi một cảnh báo:
2026-09-13T09:51:40.698+07:00 WARN 12293 --- [demo] [nio-8120-exec-4] o.s.w.s.m.m.a.HttpEntityMethodProcessor : public org.springframework.http.ResponseEntity<org.springframework.http.ProblemDetail> com.example.demo.GlobalExceptionHandler.handleDuplicateSku(com.example.demo.product.DuplicateSkuException) returned ResponseEntity: <409 CONFLICT Conflict,ProblemDetail[type='null', title='Bad Request', status=400, detail='A product with SKU KB-001 already exists', instance='/api/products', properties='null'],[]>, but its status doesn't match the ProblemDetail status: 400Title cũng lấy theo 400: Bad Request, reason phrase của status mà problem được tạo ra cùng.
Content-Type application/problem+json
Request not-found được lặp lại với bốn header Accept:
Accept gửi đi | Content-Type nhận về |
|---|---|
*/* (mặc định của curl) | application/problem+json |
application/json | application/problem+json |
application/xml | application/problem+json |
text/html | application/problem+json |
Ở đây ProblemDetail luôn được ghi ra dưới dạng application/problem+json. JSON converter cung cấp media type đó cho ProblemDetail, và khi header Accept không khớp thứ gì nó có thể tạo ra, Spring quay về media type của problem thay vì trả 406. Client nào kiểm tra response phải đúng application/json thì cần chấp nhận cả application/problem+json.
ErrorResponseException: exception mang sẵn ProblemDetail
ProblemDetail cũng có thể nằm ngay trong exception. ErrorResponseException chứa status, header và một ProblemDetail, và nó implement interface ErrorResponse để cung cấp những thông tin đó. Viết lại DuplicateSkuException để kế thừa nó:
package com.example.demo.product;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
public class DuplicateSkuException extends ErrorResponseException {
public DuplicateSkuException(String sku) {
super(HttpStatus.CONFLICT, problemFor(sku), null);
}
private static ProblemDetail problemFor(String sku) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, "A product with SKU " + sku + " already exists");
problem.setType(URI.create("https://api.example.com/problems/duplicate-sku"));
problem.setTitle("Duplicate SKU");
problem.setProperty("sku", sku);
return problem;
}
}Khi application hoàn toàn không có advice nào, POST trùng SKU trả về:
HTTP/1.1 409
Content-Type: application/json
{"timestamp":"2026-09-12T07:41:13.059Z","status":409,"error":"Conflict","path":"/api/products"}Status lấy từ exception, nhưng ProblemDetail bị bỏ đi. DefaultHandlerExceptionResolver xử lý mọi ErrorResponse bằng cách gọi sendError với status của nó, dẫn tới BasicErrorController, và ghi log:
2026-09-12T14:41:13.054+07:00 WARN 72977 --- [demo] [nio-8120-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [com.example.demo.product.DuplicateSkuException: 409 CONFLICT, ProblemDetail[type='https://api.example.com/problems/duplicate-sku', title='Duplicate SKU', status=409, detail='A product with SKU KB-001 already exists', instance='null', properties='{sku=KB-001}']]detail chỉ hiện ra dưới dạng message khi bật include-message=always. ProblemDetail trở thành body một khi có ResponseEntityExceptionHandler xử lý exception, và đó chính là thứ mà property ở phần sau đăng ký. Với spring.mvc.problemdetails.enabled=true:
HTTP/1.1 409
Content-Type: application/problem+json
{"detail":"A product with SKU KB-001 already exists","instance":"/api/products","status":409,"title":"Duplicate SKU","type":"https://api.example.com/problems/duplicate-sku","sku":"KB-001"}Cùng cơ chế đó giải thích ResponseStatusException: nó kế thừa ErrorResponseException.
Exception nào của Spring MVC implement ErrorResponse
Kiểm tra ErrorResponse.class.isAssignableFrom(...) với các exception mà Spring MVC hay ném nhất:
| Exception | Implement ErrorResponse |
|---|---|
NoResourceFoundException | có |
NoHandlerFoundException | có |
HttpRequestMethodNotSupportedException | có |
HttpMediaTypeNotAcceptableException | có |
HttpMediaTypeNotSupportedException | có |
MissingPathVariableException | có |
MissingServletRequestParameterException | có |
MissingServletRequestPartException | có |
ServletRequestBindingException | có |
MethodArgumentNotValidException | có |
HandlerMethodValidationException | có |
AsyncRequestTimeoutException | có |
MaxUploadSizeExceededException | có |
ResponseStatusException, ErrorResponseException | có |
TypeMismatchException, MethodArgumentTypeMismatchException | không |
HttpMessageNotReadableException | không |
HttpMessageNotWritableException | không |
ConversionNotSupportedException | không |
MethodValidationException | không |
AsyncRequestNotUsableException | không |
jakarta.validation.ConstraintViolationException | không |
Exception nào implement ErrorResponse thì tự mang status và một ProblemDetail có detail, đó chính là nguồn gốc các body của framework ở phần sau. Những exception không implement, như HttpMessageNotReadableException đứng sau một JSON body sai cú pháp, vẫn được ResponseEntityExceptionHandler biến thành ProblemDetail, vì class này liệt kê chúng theo tên. ConstraintViolationException không thuộc nhóm nào: không method nào của ResponseEntityExceptionHandler xử lý nó, và bài 19 cho thấy nó thoát ra thành lỗi 500 từ một class có annotation @Validated.
spring.mvc.problemdetails.enabled: ProblemDetail cho lỗi của Spring MVC
spring.mvc.problemdetails.enabled mặc định là false. Đặt thành true, nó thay đổi body của các lỗi do chính Spring MVC tạo ra mà không cần viết dòng code nào. Cùng các request đó trên application không có advice nào:
404 cho path không được map, GET /api/nope. Trước:
{"timestamp":"2026-09-12T07:36:08.520Z","status":404,"error":"Not Found","path":"/api/nope"}Sau, với Content-Type: application/problem+json:
{"detail":"No static resource api/nope.","instance":"/api/nope","status":404,"title":"Not Found"}405, DELETE /api/products. Trước:
{"timestamp":"2026-09-12T07:36:08.528Z","status":405,"error":"Method Not Allowed","path":"/api/products"}Sau, vẫn giữ Allow: GET, POST:
{"detail":"Method 'DELETE' is not supported.","instance":"/api/products","status":405,"title":"Method Not Allowed"}400 cho @Valid @RequestBody không hợp lệ, tức POST không hợp lệ. Trước:
{"timestamp":"2026-09-12T07:36:08.574Z","status":400,"error":"Bad Request","path":"/api/products"}Sau:
{"detail":"Invalid request content.","instance":"/api/products","status":400,"title":"Bad Request"}Các lỗi 400 khác của framework cũng theo đó: GET /api/products/0 cho "detail":"Validation failure" còn JSON sai cú pháp cho "detail":"Failed to read request". Không detail nào cho biết field nào sai; việc đó cần tới code, ở phần tiếp theo.
Property này đăng ký một bean. Chạy với --debug sẽ thấy nó trong conditions report:
WebMvcAutoConfiguration.ProblemDetailsErrorHandlingConfiguration#problemDetailsExceptionHandler matched:
- @ConditionalOnMissingBean (types: org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; SearchStrategy: all) did not find any beans (OnBeanCondition)ProblemDetailsExceptionHandler là một subclass @ControllerAdvice rỗng của ResponseEntityExceptionHandler, và method @Bean của nó mang @Order(0). Log cũng thay đổi: khi không có property, DefaultHandlerExceptionResolver ghi một dòng WARN Resolved [...] cho lỗi 405, lỗi validation và JSON sai cú pháp. Khi bật property, chỉ lỗi 405 được ghi log, dưới dạng o.s.web.servlet.PageNotFound : Request method 'DELETE' is not supported.
Những gì property này không thay đổi
Nó chỉ bao quát các exception mà ResponseEntityExceptionHandler liệt kê, và danh sách đó có ErrorResponseException, nên có luôn các subclass của nó. Khi bật property:
RuntimeExceptionkhông được bắt của bạn vẫn giữ nguyên.GET /api/products/1/stockvẫn trả về{"timestamp":"2026-09-12T07:36:18.498Z","status":500,"error":"Internal Server Error","path":"/api/products/1/stock"}vớiContent-Type: application/json, kèm dòng log ERROR của Tomcat.ProductNotFoundExceptioncó@ResponseStatusvẫn giữ nguyên:{"timestamp":"2026-09-12T07:40:52.369Z","status":404,"error":"Not Found","path":"/api/products/99"}. Annotation này doResponseStatusExceptionResolverxử lý, và resolver đó chỉ gọisendError.ResponseStatusExceptionthì có thay đổi, vì nó là mộtErrorResponseException: field sort không tồn tại trả về{"detail":"Unknown sort field: colour","instance":"/api/products","status":400,"title":"Bad Request"}, reason giờ hiện ra thànhdetail.
Tóm lại, property này cho lỗi của framework một body chuẩn, còn exception của riêng bạn vẫn cần handler.
Tùy biến lỗi của Spring MVC với ResponseEntityExceptionHandler
ResponseEntityExceptionHandler là một abstract class có một method @ExceptionHandler duy nhất bao quát toàn bộ danh sách exception của Spring MVC và ErrorResponseException. Nó chuyển từng exception tới một method protected, handleMethodArgumentNotValid, handleHttpRequestMethodNotSupported và các method khác, và mỗi method đó kết thúc bằng handleExceptionInternal, nơi dựng ResponseEntity với body là ProblemDetail. Kế thừa nó trong advice của bạn là có ngay các ProblemDetail của framework như ở phần trước; override một method là thay đổi một loại lỗi.
Status 422 kèm danh sách lỗi theo field
Thiết kế của bài 15 cho catalogue này tách hai loại input sai. Request không đọc hoặc bind được — JSON sai cú pháp, sai type, @PathVariable hay @RequestParam không hợp lệ — là 400. Body đúng cú pháp nhưng vi phạm một quy tắc, như giá âm, là 422 Unprocessable Content. Mặc định của Spring cho body không hợp lệ là 400, như bài 19 đã trình bày: khi không có advice, POST không hợp lệ trả về 400, và cùng body đó gửi bằng PUT /api/products/1 cũng vậy. Advice dưới đây hiện thực thiết kế đó; nhiều API giữ 400 cho cả hai trường hợp, điều đó hoàn toàn ổn miễn là nhất quán.
PUT là trường hợp cần cẩn thận. Một @Valid @RequestBody không hợp lệ thường tới dưới dạng MethodArgumentNotValidException, nhưng ngay khi một parameter khác của cùng method mang constraint, như @Min(1) trên id của replace, Spring validate toàn bộ lời gọi theo kiểu method validation và body không hợp lệ tới dưới dạng HandlerMethodValidationException, cũng là exception mà parameter không hợp lệ tạo ra. Map exception này sang 422 còn exception kia sang 400 sẽ khiến một PUT có body sai nhận 400. Vì vậy override cho HandlerMethodValidationException xem cái gì đã sai: getBeanResults() chứa các argument dạng object được validate theo từng field, getValueResults() chứa các giá trị đơn giản như id.
package com.example.demo;
import com.example.demo.product.DuplicateSkuException;
import com.example.demo.product.ProductNotFoundException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.method.ParameterErrors;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
record FieldViolation(String field, String message) {
}
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail handleNotFound(ProductNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setType(URI.create("https://api.example.com/problems/product-not-found"));
problem.setTitle("Product not found");
problem.setProperty("productId", ex.getProductId());
return problem;
}
@ExceptionHandler(DuplicateSkuException.class)
public ProblemDetail handleDuplicateSku(DuplicateSkuException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setType(URI.create("https://api.example.com/problems/duplicate-sku"));
problem.setTitle("Duplicate SKU");
problem.setProperty("sku", ex.getSku());
return problem;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<FieldViolation> errors = new ArrayList<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.add(new FieldViolation(error.getField(), error.getDefaultMessage()));
}
HttpStatus responseStatus = HttpStatus.UNPROCESSABLE_CONTENT;
return handleExceptionInternal(ex, validationProblem(responseStatus, errors), headers, responseStatus, request);
}
@Override
protected ResponseEntity<Object> handleHandlerMethodValidationException(
HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<FieldViolation> errors = new ArrayList<>();
boolean bodyInvalid = false;
for (ParameterErrors result : ex.getBeanResults()) {
if (result.getMethodParameter().hasParameterAnnotation(RequestBody.class)) {
bodyInvalid = true;
}
for (FieldError error : result.getFieldErrors()) {
errors.add(new FieldViolation(error.getField(), error.getDefaultMessage()));
}
}
for (ParameterValidationResult result : ex.getValueResults()) {
String name = result.getMethodParameter().getParameterName();
for (MessageSourceResolvable error : result.getResolvableErrors()) {
errors.add(new FieldViolation(name, error.getDefaultMessage()));
}
}
HttpStatus responseStatus = bodyInvalid ? HttpStatus.UNPROCESSABLE_CONTENT : HttpStatus.BAD_REQUEST;
return handleExceptionInternal(ex, validationProblem(responseStatus, errors), headers, responseStatus, request);
}
private ProblemDetail validationProblem(HttpStatus status, List<FieldViolation> errors) {
errors.sort(Comparator.comparing(FieldViolation::field));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
status, "Request has " + errors.size() + " invalid value(s).");
problem.setProperty("errors", errors);
return problem;
}
}DuplicateSkuException ở đây lại là phiên bản RuntimeException thông thường. POST không hợp lệ:
curl -i -H "Content-Type: application/json" -d '{"sku":"","name":"Keyboard","price":-5}' http://localhost:8120/api/productsHTTP/1.1 422
Content-Type: application/problem+json
{"detail":"Request has 2 invalid value(s).","instance":"/api/products","status":422,"title":"Unprocessable Content","errors":[{"field":"price","message":"must be greater than 0"},{"field":"sku","message":"must not be blank"}]}Cùng body đó gửi bằng PUT:
curl -i -X PUT -H "Content-Type: application/json" -d '{"sku":"","name":"Keyboard","price":-5}' http://localhost:8120/api/products/1HTTP/1.1 422
Content-Type: application/problem+json
{"detail":"Request has 2 invalid value(s).","instance":"/api/products/1","status":422,"title":"Unprocessable Content","errors":[{"field":"price","message":"must be greater than 0"},{"field":"sku","message":"must not be blank"}]}Cùng một response, nhưng từ một exception khác. Với spring.mvc.log-resolved-exception=true, POST được ghi log là MethodArgumentNotValidException, còn PUT là:
2026-09-13T10:23:29.675+07:00 WARN 53544 --- [demo] [nio-8120-exec-2] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST "Validation failure"]Id không hợp lệ đi cùng body hợp lệ thì vẫn là 400, và id không hợp lệ trên GET cũng vậy:
curl -i -X PUT -H "Content-Type: application/json" -d '{"sku":"KB-001","name":"Mechanical keyboard","price":1190000}' http://localhost:8120/api/products/0HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Request has 1 invalid value(s).","instance":"/api/products/0","status":400,"title":"Bad Request","errors":[{"field":"id","message":"must be greater than or equal to 1"}]}curl -i http://localhost:8120/api/products/0HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Request has 1 invalid value(s).","instance":"/api/products/0","status":400,"title":"Bad Request","errors":[{"field":"id","message":"must be greater than or equal to 1"}]}Id không hợp lệ và body không hợp lệ cùng lúc thì trả về 422 với đủ ba phần tử id, price và sku: một khi body là một phần của vấn đề, client có một body cần sửa.
Những chi tiết đáng biết:
HttpStatus.UNPROCESSABLE_CONTENT, không phảiUNPROCESSABLE_ENTITY. Cả hai đều có trong spring-web 7.0.9, vàUNPROCESSABLE_ENTITYmang@Deprecated(since = "7.0")để nhường chỗ cho cái tên mà RFC 9110 dùng. TitleUnprocessable Contentlà reason phrase của constant đó.- Một
ProblemDetailmới cho mỗi response.getBody()của chính exception được tạo với status 400.validationProblemdựng một object mới với đúng status được gửi đi, nên"status"trong body luôn khớp với status line, tránh kiểu lệch status đã thấy ở trên. - Tên parameter.
getMethodParameter().getParameterName()trả vềidvì Boot compile với-parameters, như bài 17 đã giải thích. - Sắp xếp. Bean Validation không cam kết thứ tự cho các vi phạm, nên danh sách được sắp theo field để response ổn định.
- Mọi thứ còn lại được kế thừa. Trong cùng lần chạy, lỗi 405 trả về
{"detail":"Method 'DELETE' is not supported.","instance":"/api/products","status":405,"title":"Method Not Allowed"}và JSON sai cú pháp trả về{"detail":"Failed to read request","instance":"/api/products","status":400,"title":"Bad Request"}, y hệt khi bật property.
Boot lùi lại khi bạn kế thừa ResponseEntityExceptionHandler
ProblemDetailsExceptionHandler của Boot chỉ được tạo khi chưa có bean ResponseEntityExceptionHandler nào. Với advice này trong application và spring.mvc.problemdetails.enabled=true vẫn bật, report của --debug ghi:
WebMvcAutoConfiguration.ProblemDetailsErrorHandlingConfiguration#problemDetailsExceptionHandler:
Did not match:
- @ConditionalOnMissingBean (types: org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; SearchStrategy: all) found beans of type 'org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler' globalExceptionHandler (OnBeanCondition)Chỉ có đúng một ResponseEntityExceptionHandler, là của bạn, và property không còn tác dụng gì. Để nguyên nó cũng không hại gì; bỏ đi thì rõ ràng hơn. Tổ hợp ngược lại — bật property và có một advice của bạn không kế thừa class này — thì giữ lại handler của Boot, với @Order(0) đứng trước advice của bạn. Điều đó quan trọng với handler catch-all ở phần dưới.
Handler bắt mọi exception mà không làm lộ thông tin
Luôn sẽ có exception nào đó lọt qua: một bug, lỗi từ thư viện, một inventory service không trả lời. Không có handler thì nó tạo ra lỗi 500 mặc định của Boot và một dòng log của Tomcat. Thêm một handler cho Exception vào class ở trên:
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
problem.setTitle("Internal Server Error");
return problem;
}
// handleNotFound, handleDuplicateSku and the two overrides are unchangedcurl -i http://localhost:8120/api/products/1/stockHTTP/1.1 500
Content-Type: application/problem+json
{"detail":"An unexpected error occurred.","instance":"/api/products/1/stock","status":500,"title":"Internal Server Error"}Không có class exception, không message, không stack trace: client chỉ biết request thất bại ở phía server và không biết gì về nguyên nhân. Nguyên nhân nằm trong log, đúng một lần:
2026-09-13T10:23:29.769+07:00 ERROR 53544 --- [demo] [io-8120-exec-10] c.example.demo.GlobalExceptionHandler : Unhandled exception on GET /api/products/1/stock
java.lang.IllegalStateException: Inventory service did not respond
at com.example.demo.product.ProductController.stock(ProductController.java:59) ~[!/:0.0.1-SNAPSHOT]Dòng log ERROR của Tomcat ở đầu bài đã biến mất, vì exception không còn rời khỏi DispatcherServlet, nên với cấu hình log mặc định, dòng log của handler này là bản ghi duy nhất. Vì vậy các quy tắc ở bài 14 là bắt buộc ở đây: truyền ex làm argument cuối cùng, không có placeholder, để stack trace được in ra.
Phần còn lại của application vẫn hoạt động như trước trong cùng lần chạy: 404 và 409 từ các handler riêng, 422 và 400 từ các override, 405 và path không tồn tại từ các method được kế thừa. Có một request đã thay đổi. GET /api/products/99/summary giờ trả về 500: CompletionException của nó khớp thẳng với Exception, nên class đã có handler khớp type bị ném và cause không bao giờ được xét tới. Handler catch-all chấm dứt việc khớp theo cause trong class của nó. Nếu một exception bọc ngoài cần được map theo cause, hãy xử lý tường minh type của wrapper đó.
Vì sao handler bắt mọi exception có thể biến 405 thành 500
Handler catch-all ở trên nằm trong một class kế thừa ResponseEntityExceptionHandler, và đó không phải chi tiết nhỏ. Cùng method handleUnexpected được thử trong ba cách bố trí, với những request không nên thành 500:
| Request | Không có advice | Advice thường chỉ có handler Exception | Như cột trước, bật spring.mvc.problemdetails.enabled=true | Subclass của ResponseEntityExceptionHandler chỉ có handler Exception |
|---|---|---|---|---|
DELETE /api/products | 405 | 500 | 405 | 405 |
GET /api/nope | 404 | 500 | 404 | 404 |
POST /api/products không hợp lệ | 400 | 500 | 400 | 400 |
GET /api/products/99, có @ResponseStatus và không có handler | 404 | 500 | 500 | 500 |
Với advice thường, field sort không tồn tại và JSON sai cú pháp cũng thành 500, còn lỗi 405 mất luôn header Allow. Mỗi exception đều được ghi log là Unhandled exception. Nguyên nhân đến từ thứ tự các resolver:
ExceptionHandlerExceptionResolverchạy trước hai resolver còn lại. HandlerExceptionkhớp với mọi exception, nênResponseStatusExceptionResolvervàDefaultHandlerExceptionResolverkhông bao giờ tới lượt. Kể cả những exception ném ra trước khi chọn được controller, nhưNoResourceFoundExceptioncho path không tồn tại: một advice toàn cục cũng áp dụng cho chúng.- Trong một subclass của
ResponseEntityExceptionHandler, handler được kế thừa liệt kêHttpRequestMethodNotSupportedExceptionvà các exception khác theo đúng type. Trong cùng một class, type gần nhất thắng, nên exception của framework giữ được status. - Khi bật property cùng một advice thường,
ProblemDetailsExceptionHandlercủa Boot là một advice riêng với@Order(0), được hỏi trước advice không có@Order, nên nó nhận exception của framework trước. @ResponseStatustrên class exception bị mất trong mọi cách bố trí một khi có handlerException, vì annotation đó được đọc bởi resolver không còn được chạy nữa.
Quy tắc: đặt handler catch-all trong advice kế thừa ResponseEntityExceptionHandler, và cho mỗi exception domain một @ExceptionHandler riêng hoặc biến nó thành ErrorResponseException. Đừng dựa vào @ResponseStatus trong application có handler catch-all.
Message code cho title và detail của ProblemDetail
ResponseEntityExceptionHandler resolve type, title và detail của lỗi framework thông qua MessageSource của Spring trước khi ghi ra. Các code được ghép từ tên đầy đủ của class exception; với HttpRequestMethodNotSupportedException, các helper static của ErrorResponse trả về:
problemDetail.type.org.springframework.web.HttpRequestMethodNotSupportedException
problemDetail.title.org.springframework.web.HttpRequestMethodNotSupportedException
problemDetail.org.springframework.web.HttpRequestMethodNotSupportedExceptionBoot cấu hình MessageSource khi có file src/main/resources/messages.properties:
problemDetail.title.org.springframework.web.HttpRequestMethodNotSupportedException=Method not supported
problemDetail.org.springframework.web.HttpRequestMethodNotSupportedException={0} is not supported on this URL. Supported methods: {1}.Với advice ở phần catch-all, lỗi 405 trở thành:
{"detail":"DELETE is not supported on this URL. Supported methods: [GET, POST].","instance":"/api/products","status":405,"title":"Method not supported"}Placeholder là các detail message argument của exception, ở đây là method và các method được hỗ trợ. Các method @ExceptionHandler của riêng bạn không đi qua cơ chế này: chúng tự dựng ProblemDetail của mình.
So sánh các cơ chế xử lý exception
| Cơ chế | Scope | Body tạo ra | Dùng khi |
|---|---|---|---|
@ResponseStatus trên class exception | mọi request ném exception đó, trừ khi một @ExceptionHandler nhận nó trước | body mặc định của Boot, kể cả khi bật spring.mvc.problemdetails.enabled | exception domain trong application không có advice; mất tác dụng khi có handler catch-all |
ResponseStatusException | dòng code ném nó | body mặc định; ProblemDetail với reason làm detail khi có ResponseEntityExceptionHandler | một kiểm tra dùng một lần bên trong controller |
@ExceptionHandler trong controller | exception từ các method của controller đó; thắng mọi advice | bất cứ thứ gì handler trả về | lỗi chỉ một controller tạo ra |
@RestControllerAdvice | mọi controller, hoặc những controller được chọn bằng basePackages, assignableTypes hay annotations | bất cứ thứ gì các handler trả về | error contract của cả application |
subclass của ErrorResponseException | dòng code ném nó | ProblemDetail của chính nó khi có ResponseEntityExceptionHandler; nếu không thì body mặc định với status của nó | exception cần mang theo ProblemDetail đầy đủ |
subclass của ResponseEntityExceptionHandler | mọi exception của Spring MVC mà nó liệt kê, cùng ErrorResponseException | ProblemDetail, tùy biến được theo từng type exception | định hình lỗi của framework: danh sách field, 422, message code |
spring.mvc.problemdetails.enabled=true | cùng danh sách đó, khi bạn chưa có ResponseEntityExceptionHandler riêng | ProblemDetail với title và detail mặc định của Spring | body lỗi chuẩn cho framework mà không cần viết code |
FAQ
@ControllerAdvice và @RestControllerAdvice khác nhau thế nào?
@RestControllerAdvice là @ControllerAdvice cộng thêm @ResponseBody, như meta-annotation của nó cho thấy. Chỉ với @ControllerAdvice, handler trả về một object cần tự có @ResponseBody; trả ResponseEntity hay ProblemDetail thì đằng nào cũng ghi ra body. Với JSON API, hãy dùng @RestControllerAdvice.
Vì sao @ExceptionHandler của tôi trả về 200 OK?
Vì handler trả về body mà không đặt status. Không có @ResponseStatus trên handler method hay ResponseEntity, response sẽ là 200, kể cả khi class exception mang @ResponseStatus(HttpStatus.NOT_FOUND): ở lần chạy phía trên, một handler cho exception đó trả về HTTP/1.1 200 với "status":404 nằm trong JSON. Hãy trả ProblemDetail, ResponseEntity, hoặc thêm @ResponseStatus vào method.
@ControllerAdvice có xử lý được 404 cho URL không tồn tại không?
Có, trong Spring Boot 4. Path mà không controller nào map sẽ tới handler cho static resource, handler này ném NoResourceFoundException, và exception đó đi qua các exception resolver như mọi exception khác. Với spring.mvc.problemdetails.enabled=true hoặc một subclass của ResponseEntityExceptionHandler, GET /api/nope trả về ProblemDetail có "detail":"No static resource api/nope."; một advice catch-all thông thường thì biến nó thành 500.
spring.mvc.problemdetails.enabled có thay đổi lỗi 500 không?
Không. Nó bao quát các exception của chính Spring MVC và ErrorResponseException. Một IllegalStateException không được bắt vẫn trả về JSON mặc định của Boot với Content-Type: application/json, và exception có @ResponseStatus vẫn trả body mặc định với status của nó. Những trường hợp đó cần handler của riêng bạn.
Vì sao server.error.include-message bị bỏ qua trong Spring Boot 4?
Spring Boot 4.0 đổi tên server.error.* thành spring.web.error.*, và tên cũ không còn được bind. Đặt server.error.include-message=always thì application vẫn khởi động mà không cảnh báo gì và body không đổi. Hãy dùng spring.web.error.include-message.
API nên trả 400 hay 422 cho lỗi validation?
Cách nào cũng được nếu API nhất quán và có tài liệu. Series này theo thiết kế từ bài 15: 400 cho input không đọc hoặc bind được, kể cả path và query parameter không hợp lệ, và 422 cho body đúng cú pháp nhưng vi phạm quy tắc. Mặc định của Spring cho body không hợp lệ là 400. Các override trong bài này trả 422 với HttpStatus.UNPROCESSABLE_CONTENT mỗi khi body không hợp lệ, dù nó tới dưới dạng MethodArgumentNotValidException hay, trên method có validate thêm parameter, dưới dạng HandlerMethodValidationException, và giữ 400 khi chỉ có parameter không hợp lệ.
Kết luận
Exception rời khỏi controller sẽ đi qua các resolver của DispatcherServlet theo thứ tự cố định. Method @ExceptionHandler đứng đầu và là nơi duy nhất tự ghi body của mình. @ResponseStatus và exception của chính Spring MVC theo sau và kết thúc bằng sendError. Những gì còn lại bị ném lại, được Tomcat ghi log và biến thành body mặc định của Boot bởi BasicErrorController, thứ mà spring.web.error.*, không còn là server.error.*, có thể làm lộ nhiều thông tin hơn và không nên làm vậy trên production. Handler được chọn theo từng class — controller trước, rồi tới các advice theo @Order — và bên trong một class thì theo type exception gần nhất, cause chỉ là phương án cuối. ProblemDetail cho mọi lỗi dạng RFC 9457 cùng application/problem+json. spring.mvc.problemdetails.enabled chỉ áp dụng nó cho lỗi của framework, còn một subclass của ResponseEntityExceptionHandler cho phép bạn định hình lại chúng, ở đây là 422 kèm danh sách field cho mọi body không hợp lệ. Handler catch-all thuộc về subclass đó, nơi nó không thể biến 405 thành 500, và nó nên ghi log exception đúng một lần và không tiết lộ gì cho client.
Tới giờ mọi class của catalogue đều nằm ngay cạnh controller dùng nó. Bài tiếp theo cho code một cấu trúc: kiến trúc phân lớp Controller – Service – Repository, và nên tổ chức package theo layer hay theo feature.