Command Palette

Search for a command to run...

[Spring Boot Basics] Xử lý exception tập trung trong Spring Boot: @RestControllerAdvice, @ExceptionHandler và ProblemDetail

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ữ.

Các exception có status 404, 405, 409 và 500 đổ về một @RestControllerAdvice, nơi trả lời tất cả bằng cùng một dạng ProblemDetail

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:

Bash
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.zip
Bash
unzip demo.zip -d demo

File build.gradle sinh ra có dependency spring-boot-starter-webmvcspring-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ý.

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record Product(Long id, String sku, String name, BigDecimal price) {
}
src/main/java/com/example/demo/product/CreateProductRequest.java
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:

src/main/java/com/example/demo/product/ProductNotFoundException.java
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;
    }
}
src/main/java/com/example/demo/product/DuplicateSkuException.java
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;
    }
}
src/main/java/com/example/demo/product/ProductStore.java
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;
    }
}
src/main/java/com/example/demo/product/ProductController.java
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:

src/main/java/com/example/demo/review/ReviewController.java
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:

Bash
./gradlew bootJar
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8120

GET /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ó:

Bash
curl -i http://localhost:8120/api/products/1/stock
Text
HTTP/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:

Text
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:

Bash
curl -i -H "Accept: text/html" http://localhost:8120/api/products/1/stock
Text
HTTP/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ỏ):

Text
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 500

Bà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ử:

Text
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.DefaultHandlerExceptionResolver
  • DefaultErrorAttributes là của Boot. Nó ghi lại exception để error body dùng về sau, và luôn chuyển exception đi tiếp.
  • ExceptionHandlerExceptionResolver chạ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.
  • ResponseStatusExceptionResolver xử lý ResponseStatusException và các exception có annotation @ResponseStatus, bằng cách gọi response.sendError(status). HandlerMethodValidationException kế thừa ResponseStatusException, nên các lỗi validation parameter ở bài 19 đã kết thúc tại đây.
  • DefaultHandlerExceptionResolver xử 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.

Đường đi của một exception: DispatcherServlet hỏi lần lượt DefaultErrorAttributes, ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver và DefaultHandlerExceptionResolver; chỉ @ExceptionHandler tự ghi response, còn sendError và exception không được xử lý đều kết thúc bằng ERROR dispatch tới /error và 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:

PropertyMặc địnhThêm vào body
spring.web.error.include-messagenevermessage: message của exception
spring.web.error.include-binding-errorsnevererrors: mọi lỗi validation kèm code của chúng
spring.web.error.include-stacktracenevertrace: toàn bộ stack trace trong một string
spring.web.error.include-exceptionfalseexception: tên class của exception
spring.web.error.include-pathalwayspath
spring.web.error.path/errorpath của error controller, không phải field trong body
spring.web.error.whitelabel.enabledtruetrang 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:

src/main/resources/application.properties
spring.web.error.include-message=always
spring.web.error.include-binding-errors=always
spring.web.error.include-stacktrace=always

Với include-message=always, request stock bị lỗi có thêm message:

JSON
{"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:

JSON
{
  "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:

Text
{"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:

JSON
{"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: InvocableHandlerMethod cho 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=always cô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ó:

src/main/java/com/example/demo/product/ProductNotFoundException.java
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 {
Bash
curl -i http://localhost:8120/api/products/99
Text
HTTP/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:

Text
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:

Bash
curl -i "http://localhost:8120/api/products?sort=colour"
Text
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:

src/main/java/com/example/demo/product/ApiError.java
package com.example.demo.product;
 
public record ApiError(int status, String message, String path) {
}

Rồi thêm hai handler method vào ProductController:

src/main/java/com/example/demo/product/ProductController.java
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)); 
    } 
}
Bash
curl -i http://localhost:8120/api/products/99
Text
HTTP/1.1 404
Content-Type: application/json
 
{"status":404,"message":"Product 99 not found","path":"/api/products/99"}
Bash
curl -i -H "Content-Type: application/json" -d '{"sku":"KB-001","name":"Another keyboard","price":990000}' http://localhost:8120/api/products
Text
HTTP/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 @ExceptionHandler là các type exception mà method xử lý, và method nhận exception qua một parameter.
  • Parameter của request. HttpServletRequest cho request servlet gốc; WebRequest là 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 ghi uri=: muốn lấy path thì dùng getRequestURI().
  • Status. Trả ResponseEntity thì status được đặt trong code, theo từng lần gọi. Chỉ trả body thì cần @ResponseStatus trê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, @ResponseStatus trên ProductNotFoundException khô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:

Bash
curl -i http://localhost:8120/api/products/99/reviews
Text
HTTP/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@Documented:

Text
java.lang.annotation.Target
java.lang.annotation.Retention
java.lang.annotation.Documented
org.springframework.web.bind.annotation.ControllerAdvice
org.springframework.web.bind.annotation.ResponseBody

Chuyể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.

src/main/java/com/example/demo/GlobalExceptionHandler.java
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:

Text
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:

Text
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:

Java
@ExceptionHandler(ProductNotFoundException.class)
public ApiError handleNotFound(ProductNotFoundException ex, HttpServletRequest request) {
    return new ApiError(404, ex.getMessage(), request.getRequestURI());
}
Text
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:

src/main/java/com/example/demo/GlobalExceptionHandler.java
@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:

JSON
{"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:

Cách Spring chọn một @ExceptionHandler: trước tiên là handler của chính controller, sau đó tới các bean @ControllerAdvice theo @Order; trong một class, handler gần nhất với type bị ném sẽ thắng, và chỉ khi không handler nào khớp type đó Spring mới xét tới cause, ở mọi độ sâu

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ể:

src/main/java/com/example/demo/GlobalExceptionHandler.java
@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);
    }
}
Text
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:

src/main/java/com/example/demo/product/ProductController.java
    @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.

Text
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ự:

src/main/java/com/example/demo/FallbackExceptionHandler.java
@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);
    }
}
src/main/java/com/example/demo/ProductExceptionHandler.java
@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);
    }
}
FallbackExceptionHandlerProductExceptionHandlerGET /api/products/99
@Order(1)@Order(2)500, Unexpected error
@Order(2)@Order(1)404, Product 99 not found
không có @Orderkhông có @Order500, 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:

Text
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 RuntimeException lẫn ProductNotFoundException, cùng request đó trả về 500 Unexpected error. Bản thân CompletionException là một RuntimeException, 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 ProductNotFoundException trong ProductController và 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:

  1. Class của chính controller, rồi từng advice áp dụng được, từ @Order nhỏ nhất trở lên. Class đầu tiên có handler khớp sẽ thắng.
  2. 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 9457Trong Spring
typeURI định danh loại vấn đề; khi vắng mặt thì là about:blanksetType(URI)
titletó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
statusHTTP status codeProblemDetail.forStatus(...), forStatusAndDetail(...)
detailgiải thích cho lần xảy ra cụ thể nàyforStatusAndDetail(status, detail), setDetail(String)
instanceURI cho lần xảy ra nàysetInstance(URI); Spring MVC tự điền path của request nếu chưa đặt
extension membermọi member khác mà loại vấn đề đó định nghĩasetProperty(name, value)

Viết lại advice để trả về ProblemDetail:

src/main/java/com/example/demo/GlobalExceptionHandler.java
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();
    }
}
Bash
curl -i http://localhost:8120/api/products/99
Text
HTTP/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:

Text
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:

Java
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(problem);
Text
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:

Text
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: 400

Title 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 điContent-Type nhận về
*/* (mặc định của curl)application/problem+json
application/jsonapplication/problem+json
application/xmlapplication/problem+json
text/htmlapplication/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ó:

src/main/java/com/example/demo/product/DuplicateSkuException.java
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ề:

Text
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:

Text
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:

Text
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:

ExceptionImplement ErrorResponse
NoResourceFoundException
NoHandlerFoundException
HttpRequestMethodNotSupportedException
HttpMediaTypeNotAcceptableException
HttpMediaTypeNotSupportedException
MissingPathVariableException
MissingServletRequestParameterException
MissingServletRequestPartException
ServletRequestBindingException
MethodArgumentNotValidException
HandlerMethodValidationException
AsyncRequestTimeoutException
MaxUploadSizeExceededException
ResponseStatusException, ErrorResponseException
TypeMismatchException, MethodArgumentTypeMismatchExceptionkhông
HttpMessageNotReadableExceptionkhông
HttpMessageNotWritableExceptionkhông
ConversionNotSupportedExceptionkhông
MethodValidationExceptionkhông
AsyncRequestNotUsableExceptionkhông
jakarta.validation.ConstraintViolationExceptionkhông

Exception nào implement ErrorResponse thì tự mang status và một ProblemDetaildetail, đó 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:

JSON
{"timestamp":"2026-09-12T07:36:08.520Z","status":404,"error":"Not Found","path":"/api/nope"}

Sau, với Content-Type: application/problem+json:

JSON
{"detail":"No static resource api/nope.","instance":"/api/nope","status":404,"title":"Not Found"}

405, DELETE /api/products. Trước:

JSON
{"timestamp":"2026-09-12T07:36:08.528Z","status":405,"error":"Method Not Allowed","path":"/api/products"}

Sau, vẫn giữ Allow: GET, POST:

JSON
{"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:

JSON
{"timestamp":"2026-09-12T07:36:08.574Z","status":400,"error":"Bad Request","path":"/api/products"}

Sau:

JSON
{"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:

Text
   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:

  • RuntimeException không được bắt của bạn vẫn giữ nguyên. GET /api/products/1/stock vẫn trả về {"timestamp":"2026-09-12T07:36:18.498Z","status":500,"error":"Internal Server Error","path":"/api/products/1/stock"} với Content-Type: application/json, kèm dòng log ERROR của Tomcat.
  • ProductNotFoundException@ResponseStatus vẫn giữ nguyên: {"timestamp":"2026-09-12T07:40:52.369Z","status":404,"error":"Not Found","path":"/api/products/99"}. Annotation này do ResponseStatusExceptionResolver xử lý, và resolver đó chỉ gọi sendError.
  • ResponseStatusException thì có thay đổi, vì nó là một ErrorResponseException: 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ành detail.

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.

src/main/java/com/example/demo/GlobalExceptionHandler.java
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ệ:

Bash
curl -i -H "Content-Type: application/json" -d '{"sku":"","name":"Keyboard","price":-5}' http://localhost:8120/api/products
Text
HTTP/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:

Bash
curl -i -X PUT -H "Content-Type: application/json" -d '{"sku":"","name":"Keyboard","price":-5}' http://localhost:8120/api/products/1
Text
HTTP/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à:

Text
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:

Bash
curl -i -X PUT -H "Content-Type: application/json" -d '{"sku":"KB-001","name":"Mechanical keyboard","price":1190000}' http://localhost:8120/api/products/0
Text
HTTP/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"}]}
Bash
curl -i http://localhost:8120/api/products/0
Text
HTTP/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, pricesku: 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ải UNPROCESSABLE_ENTITY. Cả hai đều có trong spring-web 7.0.9, và UNPROCESSABLE_ENTITY mang @Deprecated(since = "7.0") để nhường chỗ cho cái tên mà RFC 9110 dùng. Title Unprocessable Content là reason phrase của constant đó.
  • Một ProblemDetail mới cho mỗi response. getBody() của chính exception được tạo với status 400. validationProblem dự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ề id vì 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:

Text
   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:

src/main/java/com/example/demo/GlobalExceptionHandler.java
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 unchanged
Bash
curl -i http://localhost:8120/api/products/1/stock
Text
HTTP/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:

Text
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:

RequestKhông có adviceAdvice thường chỉ có handler ExceptionNhư cột trước, bật spring.mvc.problemdetails.enabled=trueSubclass của ResponseEntityExceptionHandler chỉ có handler Exception
DELETE /api/products405500405405
GET /api/nope404500404404
POST /api/products không hợp lệ400500400400
GET /api/products/99, có @ResponseStatus và không có handler404500500500

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:

  • ExceptionHandlerExceptionResolver chạy trước hai resolver còn lại. Handler Exception khớp với mọi exception, nên ResponseStatusExceptionResolverDefaultHandlerExceptionResolver không bao giờ tới lượt. Kể cả những exception ném ra trước khi chọn được controller, như NoResourceFoundException cho 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ê HttpRequestMethodNotSupportedException và 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, ProblemDetailsExceptionHandler củ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.
  • @ResponseStatus trên class exception bị mất trong mọi cách bố trí một khi có handler Exception, 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, titledetail 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ề:

Text
problemDetail.type.org.springframework.web.HttpRequestMethodNotSupportedException
problemDetail.title.org.springframework.web.HttpRequestMethodNotSupportedException
problemDetail.org.springframework.web.HttpRequestMethodNotSupportedException

Boot cấu hình MessageSource khi có file src/main/resources/messages.properties:

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:

JSON
{"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ếScopeBody tạo raDùng khi
@ResponseStatus trên class exceptionmọi request ném exception đó, trừ khi một @ExceptionHandler nhận nó trướcbody mặc định của Boot, kể cả khi bật spring.mvc.problemdetails.enabledexception domain trong application không có advice; mất tác dụng khi có handler catch-all
ResponseStatusExceptiondòng code ném nóbody mặc định; ProblemDetail với reason làm detail khi có ResponseEntityExceptionHandlermột kiểm tra dùng một lần bên trong controller
@ExceptionHandler trong controllerexception từ các method của controller đó; thắng mọi advicebất cứ thứ gì handler trả vềlỗi chỉ một controller tạo ra
@RestControllerAdvicemọi controller, hoặc những controller được chọn bằng basePackages, assignableTypes hay annotationsbất cứ thứ gì các handler trả vềerror contract của cả application
subclass của ErrorResponseExceptiondò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 ResponseEntityExceptionHandlermọi exception của Spring MVC mà nó liệt kê, cùng ErrorResponseExceptionProblemDetail, 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=truecùng danh sách đó, khi bạn chưa có ResponseEntityExceptionHandler riêngProblemDetail với titledetail mặc định của Springbody 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@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"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.

Bài viết liên quan

[Spring Boot Basics] JSON với Jackson 3 và DTO trong Spring Boot: serialize, deserialize và MapStruct

JSON trong Spring Boot 4.1.1 với Jackson 3.1.5, kiểm chứng trên project thật: JacksonJsonHttpMessageConverter và bean jacksonJsonMapper, package tools.jackson, JsonMapper immutable và exception unchecked, đo các giá trị mặc định của Jackson 3 so với use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enum và Optional, record, @JsonAlias và @JsonCreator, property spring.jackson và JsonMapperBuilderCustomizer, vì sao DTO tốt hơn để lộ entity, map bằng tay và MapStruct 1.6.3 với Gradle và Maven.

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator 9.1.3, kiểm chứng bằng các lần chạy thật: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] Logging trong Spring Boot: SLF4J, Logback, log level và ghi log ra file

Logging trong Spring Boot 4.1.1 kiểm chứng trên project thật: SLF4J là facade và Logback 1.5.38 là implementation, hai bridge jul-to-slf4j và log4j-to-slf4j, parameterised và fluent logging, ghi log exception, log level, cây logger và log group, --debug so với --trace, pattern dòng log mặc định, logging.file.name kèm rotation, logback-spring.xml với springProfile, MDC và chuyển sang Log4j2.

[Spring Boot Basics] Bean scope và lifecycle trong Spring: singleton, prototype, @PostConstruct và @PreDestroy

Bean scope và bean lifecycle trong Spring, chứng minh bằng cách chạy thật trên Spring Boot 4.1.1: vì sao singleton là một instance cho mỗi container chứ không phải cho cả JVM, so sánh singleton, prototype, request, session và application bằng số instance đếm được qua curl, vì sao @PreDestroy không bao giờ chạy với prototype, cái bẫy singleton giữ prototype cùng ba cách sửa ObjectProvider, @Lookup và scoped proxy, mười bốn bước lifecycle lấy từ một lần trace thật, và @Lazy thực sự đánh đổi những gì.