Articles 15 to 19 kept meeting the same JSON: {"timestamp":…,"status":404,"error":"Not Found","path":…}. It is Spring Boot's default error response, and each of those articles showed it as it is and moved on. It carries a status and a path and nothing a client can act on, and the defaults behind it are worse than the shape: a product that does not exist answers 500, and the message that would explain a 400 is removed.
This article replaces it, starting from exactly what Boot 4.1.1 does when nobody handles an exception. From there: setting the status from the exception, @ExceptionHandler in one controller and in a @RestControllerAdvice for all of them, the rules Spring follows to pick one handler when several match, ProblemDetail as the body format, what spring.mvc.problemdetails.enabled changes and what it does not, customising Spring MVC's own errors with ResponseEntityExceptionHandler, and a catch-all that does not swallow the 4xx responses you wanted to keep.
![]()
Everything below was produced on OpenJDK 21.0.6 with Spring Boot 4.1.1 (Spring Framework 7.0.9, Tomcat 11.0.24, Jackson 3.1.5, Hibernate Validator 9.1.3.Final) and Gradle 9.7.1. Every status line, header, body and log line is copied from those runs. Most curl -i outputs are trimmed to the status line, the headers that matter and the body.
The product API used in this article
Chapter 3 grows one product catalogue. This article uses a compact version of it, with an in-memory store because databases arrive in Chapter 4. Generate the project with the web and validation starters:
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 demoThe build.gradle it produces depends on spring-boot-starter-webmvc and spring-boot-starter-validation. Then add the classes below. The constraint annotations and @Valid are the subject of article 19; here they only produce validation failures to handle.
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) {
}The two domain exceptions start as plain RuntimeExceptions:
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 validates both the id and the body of a PUT, which matters in the section on validation errors. stock stands in for a call to an inventory service that fails, so it always throws. summary loads the product on another thread and matters in the section on wrapped exceptions. A second controller, in another package, throws the same ProductNotFoundException; it shows how far each handler reaches:
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 the jar and start it:
./gradlew bootJarjava -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8120GET /api/products/1 answers {"id":1,"sku":"KB-001","name":"Mechanical keyboard","price":1290000}.
What Spring Boot does with an unhandled exception
GET /api/products/1/stock throws an IllegalStateException, and nothing in the application handles it:
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"}Four fields: when, the status, its reason phrase and the path. No message and no exception type. Every exception nobody handles gets exactly this body, whatever it means. GET /api/products/99 answered {"timestamp":"2026-09-13T03:22:32.740Z","status":500,"error":"Internal Server Error","path":"/api/products/99"}, and posting a product with the SKU KB-001 answered a 500 of the same shape: a missing product and a duplicate SKU are both reported as a server failure.
The application log records the exception once, at ERROR, with its full 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]The trace continues for 40 more frames through Spring MVC, the servlet filters and Tomcat. The logger is not Spring's: o.a.c.c.C.[.[.[/].[dispatcherServlet] is Tomcat's logger for the servlet, and the message wraps the exception in Request processing failed. The exception left Spring MVC entirely before anything wrote a response.
The Whitelabel page for browsers
The same request with the Accept header a browser sends gets HTML instead:
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>Boot's BasicErrorController maps /error twice, once producing text/html and once for everything else, as the mapping list in article 16 showed. The date and Content-Language come from the server's time zone and locale. Replacing this page with a template such as templates/error/404.html belongs to article 24; this article is about JSON APIs.
How the exception reaches /error
With logging.level.org.springframework.web=DEBUG, the same request logs the whole route (the stack trace after the ERROR line is left out here):
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 500Article 16 followed a request through DispatcherServlet to the controller and back. When the method throws instead of returning, DispatcherServlet hands the exception to its HandlerExceptionResolvers, one after another, until one of them handles it. Reading that list from the running application gave, one entry per line:
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.DefaultHandlerExceptionResolverDefaultErrorAttributesis Boot's. It records the exception so the error body can use it later, and always passes it on.ExceptionHandlerExceptionResolverruns@ExceptionHandlermethods: the controller's own first, then those in@ControllerAdviceclasses. It is the only resolver that lets your code write the response.ResponseStatusExceptionResolverhandlesResponseStatusExceptionand exceptions annotated with@ResponseStatus, by callingresponse.sendError(status).HandlerMethodValidationExceptionextendsResponseStatusException, so article 19's parameter validation failures ended here.DefaultHandlerExceptionResolverhandles Spring MVC's own exceptions, such as the 404, 405 and 415 responses and the other 400s articles 16 to 19 showed, the same way.
IllegalStateException matched none of them, so DispatcherServlet rethrew it (Failed to complete request) and Tomcat logged the ERROR line. Tomcat then forwarded the request to its error page, /error, as an "ERROR" dispatch. That second pass through DispatcherServlet reached BasicErrorController#error, which wrote the map as JSON. sendError ends in the same "ERROR" dispatch, so every path except an @ExceptionHandler finishes in BasicErrorController.

Showing more in the default body with spring.web.error.*
Boot builds the default body from error attributes and removes the revealing ones unless you ask for them. The properties, with their defaults in 4.1.1:
| Property | Default | Adds to the body |
|---|---|---|
spring.web.error.include-message | never | message: the exception's message |
spring.web.error.include-binding-errors | never | errors: every validation error with its codes |
spring.web.error.include-stacktrace | never | trace: the whole stack trace as one string |
spring.web.error.include-exception | false | exception: the exception's class name |
spring.web.error.include-path | always | path |
spring.web.error.path | /error | the path of the error controller, not a body field |
spring.web.error.whitelabel.enabled | true | the HTML page above |
Setting the first three in configuration looks like this; each run below set one of them on its own, as a -- argument on the 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: alwaysWith include-message=always, the failing stock request gains a 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"}The same run gave "message":"Method 'DELETE' is not supported." on the 405 and "message":"Validation failed for object='createProductRequest'. Error count: 2" on an invalid POST /api/products. The Whitelabel page also gained a <div> with the message.
include-binding-errors=always turns that validation failure into a full dump of the binding result. The body for {"sku":"","name":"Keyboard","price":-5}, formatted for reading:
{
"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"
}Useful while debugging, and far more than a client needs: message codes, the name of an internal object, the Java type of each field. Later in this article the same failure becomes a two-entry list.
include-stacktrace=always puts the trace in the response. The start of the body, cut after three frames:
{"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.* no longer works in Spring Boot 4
Boot 4.0 renamed the server.error.* properties to spring.web.error.*. The old names are still in Boot's property metadata, marked deprecated with level error, which means they are no longer bound. Running with --server.error.include-message=always started without a single warning and changed nothing:
{"timestamp":"2026-09-12T07:36:16.822Z","status":500,"error":"Internal Server Error","path":"/api/products/1/stock"}A tutorial or an old application.properties that sets server.error.include-message now fails silently. Rename the key.
Why stack traces stay out of production responses
- The trace is a map of your code. Package and class names, method names, line numbers, and the framework and server underneath:
InvocableHandlerMethodsays Spring MVC, the rest of the trace says Tomcat. That tells an attacker which known vulnerabilities to try. - Messages leak data. Exception messages carry whatever the code put in them: SQL fragments, file paths, host names, another customer's email address.
include-message=alwayspublishes the message of every exception, including those thrown by libraries you did not write. - The client cannot use it. Nothing in a trace tells a caller what to change in the request.
- The log already has it. The ERROR line above holds the full trace, on the server, where it belongs.
The rest of this article builds a body that says what the client needs and nothing else.
Setting the status from the exception itself
@ResponseStatus on the exception class
The first fix for the missing product is one annotation on its exception:
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"}The status is right; the body is still Boot's default. The DEBUG log shows why:
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 read the annotation and called sendError(404), and the "ERROR" dispatch took the request to BasicErrorController as before. Two more things changed. The ERROR line from Tomcat is gone, and ResponseStatusExceptionResolver logged nothing in its place at the default levels. And with include-message=always, the body gains "message":"Product 99 not found", because DefaultErrorAttributes recorded the exception on the way through.
The resolver also looks at causes. GET /api/products/99/summary throws a CompletionException whose cause is the ProductNotFoundException, and it answered 404 as well.
ResponseStatusException for a one-off error
When the controller itself detects the problem and no domain exception exists for it, ResponseStatusException carries the status inline. findAll throws one for an unknown sort field:
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"}The reason passed to the constructor, Unknown sort field: colour, does not reach the body by default. It only appeared with include-message=always, as "message":"Unknown sort field: colour", with the same risks as any other message.
Use @ResponseStatus for a domain exception that deeper code throws, and ResponseStatusException for a check that belongs to one endpoint. Both only choose the status. The body stays Boot's four fields, and neither can add a field a client could use.
@ExceptionHandler in one controller
An @ExceptionHandler method runs instead of the error path and returns the response itself. Start with a small body type of your own:
package com.example.demo.product;
public record ApiError(int status, String message, String path) {
}Then add two handler methods to 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"}What the two handlers show:
- The value of
@ExceptionHandlernames the exception types the method handles, and the method receives the exception as a parameter. - Request parameters.
HttpServletRequestgives the raw servlet request;WebRequestis Spring's abstraction over it.getDescription(false)returnsuri=/api/products, prefix included, which is why the second body saysuri=: for a path, prefergetRequestURI(). - Status. Returning
ResponseEntitysets the status in code, per call. Returning the body alone needs@ResponseStatuson the handler, the same choice article 17 made for ordinary controller methods. Once a handler takes the exception, the@ResponseStatusonProductNotFoundExceptionplays no part: the handler decides. - Nothing is logged. Neither request wrote a log line.
The limit is the class. ReviewController throws the same exception and gets none of this:
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"}That 404 is the annotation on the exception at work, through ResponseStatusExceptionResolver. A handler in ProductController is only consulted for exceptions thrown by ProductController's own handler methods.
@RestControllerAdvice: one handler for every controller
A @ControllerAdvice class holds @ExceptionHandler methods for many controllers. @RestControllerAdvice is that plus @ResponseBody, so return values become response bodies. Reading its meta-annotations by reflection lists exactly that, after the standard @Target, @Retention and @Documented:
java.lang.annotation.Target
java.lang.annotation.Retention
java.lang.annotation.Documented
org.springframework.web.bind.annotation.ControllerAdvice
org.springframework.web.bind.annotation.ResponseBodyMove the two handlers out of ProductController into one class. It sits in com.example.demo here; where such a class belongs in a larger package structure is a question for article 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());
}
}Both controllers now answer with the same 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"}A handled exception still leaves no trace in the log. For a 404 that is usually what you want; if you need a line per handled exception while investigating, spring.mvc.log-resolved-exception=true adds one at 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]A handler that returns a body needs a status from somewhere. This variant of the not-found handler has no @ResponseStatus and no 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, with a body that says 404. The @ResponseStatus(HttpStatus.NOT_FOUND) on the exception class was not used: once a handler exists, only the handler sets the status. Clients and monitoring look at the status line, not at a number inside the JSON.
Spring Security's 401 and 403 responses are produced by its filters before DispatcherServlet runs, so an @ExceptionHandler does not see them by default; Chapter 5 configures them.
Limiting an advice with basePackages, assignableTypes or annotations
By default an advice applies to every controller. Three attributes narrow it:
@RestControllerAdvice
@RestControllerAdvice(basePackages = "com.example.demo.product")
public class GlobalExceptionHandler {ProductController still gets the ApiError body. ReviewController lives in com.example.demo.review, outside the package, and fell back to the default:
{"timestamp":"2026-09-12T07:40:59.838Z","status":404,"error":"Not Found","path":"/api/products/99/reviews"}assignableTypes = ProductController.class selects controllers by type instead, and annotations = RestController.class selects controllers carrying an annotation. They are how one application keeps a JSON error contract for /api controllers separate from, say, controllers that render pages.
Which @ExceptionHandler wins when several match
Real applications have handlers at different levels, and an exception often matches more than one. Spring's rules, each checked below against Framework 7.0.9, fit in one picture:

The closest exception type wins inside one class
One advice with a general and a specific handler:
@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 is a RuntimeException, so both methods match it. Spring sorts the matches by how many steps up the class hierarchy each handler's type is from the thrown type: ProductNotFoundException is zero steps away, RuntimeException one. The closest wins: the RuntimeException handler comes first in the file and still lost. IllegalStateException has only the general match.
A controller's own handler beats every advice
Now put the general handler in the controller and keep only the specific one in the 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 is the version from the previous section, with its ProductNotFoundException handler.
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"}The advice has a handler for the exact type, and it still lost. Spring searches the controller's class first and stops at the first class that has any matching handler; the distance comparison only ranks the handlers inside that one class. ReviewController has no handlers of its own, so its request went on to the advice.
Ordering several advices with @Order
Split the two handlers into two advices and give them an order:
@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 |
no @Order | no @Order | 500, Unexpected error |
Advices are searched from the lowest @Order value up, with the same first-class-wins rule: the advice asked first answers with whatever it has, however general. Without @Order, both sit at the lowest precedence and the order between them is not something you control; this project happened to put FallbackExceptionHandler first. If you split handlers across advices, order them explicitly, or keep them in one class where the closest type decides.
Matching the cause of a wrapped exception
summary loads the product with CompletableFuture.supplyAsync(...).join(). join() wraps anything the task threw in a CompletionException, so the exception that reaches Spring is not the one ProductStore threw. With the two-handler GlobalExceptionHandler from the advice section, which has no handler for CompletionException:
GET /api/products/99/summary -> 404 {"status":404,"message":"Product 99 not found","path":"/api/products/99/summary"}The ProductNotFoundException handler ran, and its ex parameter held the cause, whose message it returned. When no handler in a class matches the thrown exception's type, Spring repeats the search with its cause, and then with the cause's cause. A ProductNotFoundException wrapped two and three levels deep in other exceptions was matched the same way.
Cause matching is the last resort within a class, and it stays inside the class order:
- Against the
RuntimeException-plus-ProductNotFoundExceptionadvice, the same request answered 500Unexpected error.CompletionExceptionis itself aRuntimeException, so the class had a match for the thrown type and never looked at the cause. - A controller's handler that matches only the cause still beats an advice that matches the wrapper exactly. With a
ProductNotFoundExceptionhandler inProductControllerand an advice handlingCompletionException, the request answered 404 from the controller.
Put together, Spring walks classes in a fixed order and, in each class, runs a two-step search:
- The controller's own class, then each applicable advice from the lowest
@Orderup. The first class with a match wins. - Inside a class: the handler for the closest supertype of the thrown exception. Only if the class has no handler for that type at all, the same search with its cause, at any depth.
ProblemDetail: the RFC 9457 error format
ApiError works, but it is one more invented format for clients to learn. RFC 9457, Problem Details for HTTP APIs (July 2023), defines a standard one, and its header says Obsoletes: 7807, the RFC that first defined it. It gives an error body a small set of members and its own media type, application/problem+json. Spring's class for it is org.springframework.http.ProblemDetail:
| Member | Meaning in RFC 9457 | In Spring |
|---|---|---|
type | a URI that identifies the kind of problem; when absent, it is about:blank | setType(URI) |
title | a short, human-readable summary of that kind of problem | setTitle(String); without it, the status's reason phrase |
status | the HTTP status code | ProblemDetail.forStatus(...), forStatusAndDetail(...) |
detail | an explanation of this occurrence | forStatusAndDetail(status, detail), setDetail(String) |
instance | a URI for this occurrence | setInstance(URI); Spring MVC fills in the request path when unset |
| extension members | anything else the problem type defines | setProperty(name, value) |
Rewrite the advice to return 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}And the duplicate SKU:
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"}The handler never set instance; Spring MVC filled it in from the request path. The standard members come out in alphabetical order, and extension members such as productId follow them at the top level, not nested under a properties object. The type URI does not have to resolve to anything, but if it does, a page describing the problem is what RFC 9457 suggests it should lead to.
Returning ProblemDetail directly or through ResponseEntity
Both handlers above set the right status. A bare ProblemDetail return value takes its HTTP status from the object. ResponseEntity.of(problem) does the same and returns a builder, so you can add headers before build(): that is the reason to choose it.
What to avoid is setting the status twice. A handler that builds ResponseEntity.status(HttpStatus.CONFLICT).body(problem) around a problem created for 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"}The status line says 409 and the body says 400. Spring sends the ResponseEntity status and logs a warning:
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: 400The title also came from the 400: Bad Request, the reason phrase of the status the problem was created with.
Content-Type application/problem+json
The not-found request was repeated with four Accept headers:
Accept sent | Content-Type received |
|---|---|
*/* (curl's default) | application/problem+json |
application/json | application/problem+json |
application/xml | application/problem+json |
text/html | application/problem+json |
A ProblemDetail is always written as application/problem+json here. The JSON converter offers that media type for ProblemDetail, and when the Accept header matches nothing it can produce, Spring falls back to the problem media type instead of answering 406. A client that insists on exactly application/json in its response checks has to accept application/problem+json too.
ErrorResponseException: an exception that carries its ProblemDetail
ProblemDetail can also live in the exception. ErrorResponseException holds a status, headers and a ProblemDetail, and it implements the ErrorResponse interface that exposes them. Rewrite DuplicateSkuException to extend it:
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;
}
}With no advice in the application at all, the duplicate POST answered:
HTTP/1.1 409
Content-Type: application/json
{"timestamp":"2026-09-12T07:41:13.059Z","status":409,"error":"Conflict","path":"/api/products"}The status came from the exception, but the ProblemDetail was thrown away. DefaultHandlerExceptionResolver handles any ErrorResponse by calling sendError with its status, which leads to BasicErrorController, and it logged:
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}']]The detail only surfaced as message with include-message=always. The ProblemDetail becomes the body once a ResponseEntityExceptionHandler handles the exception, which is exactly what the property in the next section registers. With 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"}The same mechanism explains ResponseStatusException: it extends ErrorResponseException.
Which Spring MVC exceptions implement ErrorResponse
Checking ErrorResponse.class.isAssignableFrom(...) for the exceptions Spring MVC throws most often:
| Exception | Implements ErrorResponse |
|---|---|
NoResourceFoundException | yes |
NoHandlerFoundException | yes |
HttpRequestMethodNotSupportedException | yes |
HttpMediaTypeNotAcceptableException | yes |
HttpMediaTypeNotSupportedException | yes |
MissingPathVariableException | yes |
MissingServletRequestParameterException | yes |
MissingServletRequestPartException | yes |
ServletRequestBindingException | yes |
MethodArgumentNotValidException | yes |
HandlerMethodValidationException | yes |
AsyncRequestTimeoutException | yes |
MaxUploadSizeExceededException | yes |
ResponseStatusException, ErrorResponseException | yes |
TypeMismatchException, MethodArgumentTypeMismatchException | no |
HttpMessageNotReadableException | no |
HttpMessageNotWritableException | no |
ConversionNotSupportedException | no |
MethodValidationException | no |
AsyncRequestNotUsableException | no |
jakarta.validation.ConstraintViolationException | no |
An exception that implements ErrorResponse carries its own status and a ProblemDetail with a detail, which is where the framework bodies in the next section come from. The ones that do not, such as the HttpMessageNotReadableException behind a malformed JSON body, are still turned into a ProblemDetail by ResponseEntityExceptionHandler, which lists them by name. ConstraintViolationException is in neither group: no method of ResponseEntityExceptionHandler handles it, and article 19 shows it escaping as a 500 from a class annotated @Validated.
spring.mvc.problemdetails.enabled: ProblemDetail for Spring MVC errors
spring.mvc.problemdetails.enabled defaults to false. Set to true, it changes the body of Spring MVC's own errors without a line of code. The same requests against the application without any advice:
404 for a path nothing maps, GET /api/nope. Before:
{"timestamp":"2026-09-12T07:36:08.520Z","status":404,"error":"Not Found","path":"/api/nope"}After, with Content-Type: application/problem+json:
{"detail":"No static resource api/nope.","instance":"/api/nope","status":404,"title":"Not Found"}405, DELETE /api/products. Before:
{"timestamp":"2026-09-12T07:36:08.528Z","status":405,"error":"Method Not Allowed","path":"/api/products"}After, still with Allow: GET, POST:
{"detail":"Method 'DELETE' is not supported.","instance":"/api/products","status":405,"title":"Method Not Allowed"}400 for a failed @Valid @RequestBody, the invalid POST. Before:
{"timestamp":"2026-09-12T07:36:08.574Z","status":400,"error":"Bad Request","path":"/api/products"}After:
{"detail":"Invalid request content.","instance":"/api/products","status":400,"title":"Bad Request"}The other framework 400s followed: GET /api/products/0 gave "detail":"Validation failure" and malformed JSON gave "detail":"Failed to read request". None of these details says which field was wrong; that takes code, in the next section.
The property registers one bean. Running with --debug printed it in the 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 is an empty @ControllerAdvice subclass of ResponseEntityExceptionHandler, and its @Bean method carries @Order(0). The log changed too: without the property, DefaultHandlerExceptionResolver wrote a WARN Resolved [...] line for the 405, the validation failure and the malformed JSON. With it, only the 405 was logged, as o.s.web.servlet.PageNotFound : Request method 'DELETE' is not supported.
What the property does not change
It only covers the exceptions ResponseEntityExceptionHandler lists, and that list includes ErrorResponseException, so its subclasses too. With the property on:
- Your uncaught
RuntimeExceptionis untouched.GET /api/products/1/stockstill answered{"timestamp":"2026-09-12T07:36:18.498Z","status":500,"error":"Internal Server Error","path":"/api/products/1/stock"}withContent-Type: application/json, plus the Tomcat ERROR log line. ProductNotFoundExceptionwith@ResponseStatusis untouched:{"timestamp":"2026-09-12T07:40:52.369Z","status":404,"error":"Not Found","path":"/api/products/99"}. The annotation is handled byResponseStatusExceptionResolver, which only callssendError.ResponseStatusExceptiondoes change, because it is anErrorResponseException: the unknown sort field answered{"detail":"Unknown sort field: colour","instance":"/api/products","status":400,"title":"Bad Request"}, the reason now visible asdetail.
So the property gives framework errors a standard body, and your own exceptions still need handlers.
Customising Spring MVC errors with ResponseEntityExceptionHandler
ResponseEntityExceptionHandler is an abstract class with one @ExceptionHandler method covering the whole list of Spring MVC exceptions and ErrorResponseException. It dispatches each to a protected method, handleMethodArgumentNotValid, handleHttpRequestMethodNotSupported and so on, and each of those ends in handleExceptionInternal, which builds the ResponseEntity with a ProblemDetail body. Extend it in your advice and you get the framework ProblemDetails from the previous section; override one method and you change one kind of error.
A 422 with a list of field errors
Article 15's design for this catalogue separates two kinds of bad input. A request that cannot be read or bound — malformed JSON, a type mismatch, an invalid @PathVariable or @RequestParam — is 400. A well-formed body that breaks a rule, such as a negative price, is 422 Unprocessable Content. Spring's default for an invalid body is 400, as article 19 showed: without an advice, the invalid POST answered 400, and so did the same body sent with PUT /api/products/1. The advice below implements the design; many APIs keep 400 for both, which is fine as long as it is consistent.
The PUT is the case that needs care. An invalid @Valid @RequestBody normally arrives as MethodArgumentNotValidException, but as soon as another parameter of the same method carries a constraint, such as @Min(1) on replace's id, Spring validates the whole call as method validation and the invalid body arrives as HandlerMethodValidationException, the exception invalid parameters produce. Mapping one exception to 422 and the other to 400 would give a bad PUT body a 400. So the override for HandlerMethodValidationException looks at what failed: getBeanResults() holds object arguments validated field by field, getValueResults() simple values such as 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 is the plain RuntimeException version again. The invalid POST:
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"}]}The same body sent with 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"}]}The same response, from a different exception. With spring.mvc.log-resolved-exception=true, the POST was logged as MethodArgumentNotValidException and the PUT as:
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"]An invalid id with a valid body stays a 400, and so does the invalid id on GET:
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"}]}An invalid id and an invalid body together answered 422 with all three entries, id, price and sku: once the body is part of the problem, the client has a body to fix.
Details worth knowing:
HttpStatus.UNPROCESSABLE_CONTENT, notUNPROCESSABLE_ENTITY. Both exist in spring-web 7.0.9, andUNPROCESSABLE_ENTITYis@Deprecated(since = "7.0")in favour of the name RFC 9110 uses. The titleUnprocessable Contentis that constant's reason phrase.- A fresh
ProblemDetailfor each response. The exception's owngetBody()is created with status 400.validationProblembuilds a new one with the status actually sent, so"status"in the body always matches the status line, avoiding the mismatch shown earlier. - Parameter names.
getMethodParameter().getParameterName()returnsidbecause Boot compiles with-parameters, as article 17 explained. - Sorting. Bean Validation does not promise an order for its violations, so the list is sorted by field to keep responses stable.
- Everything else is inherited. In the same run, the 405 answered
{"detail":"Method 'DELETE' is not supported.","instance":"/api/products","status":405,"title":"Method Not Allowed"}and malformed JSON{"detail":"Failed to read request","instance":"/api/products","status":400,"title":"Bad Request"}, exactly as with the property.
Boot backs off when you extend ResponseEntityExceptionHandler
Boot's ProblemDetailsExceptionHandler is conditional on there being no ResponseEntityExceptionHandler bean. With this advice in the application and spring.mvc.problemdetails.enabled=true still set, the --debug report said:
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)There is exactly one ResponseEntityExceptionHandler, yours, and the property no longer does anything. Leaving it set is harmless; removing it is clearer. The opposite combination — the property on and an advice of yours that does not extend the class — keeps Boot's handler, with its @Order(0) ahead of your advice. That matters for the catch-all below.
A catch-all handler that leaks nothing
Some exception will always get through: a bug, a library failure, an inventory service that does not answer. Without a handler it produces Boot's default 500 and a Tomcat log line. Add one handler for Exception to the class above:
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"}No exception class, no message, no trace: the client learns that the request failed on the server and nothing about why. The why is in the log, once:
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]The Tomcat ERROR line from the start of the article is gone, because the exception no longer leaves DispatcherServlet, so at the default log settings this handler's line is the only record. That makes the rules from article 14 non-negotiable here: pass ex as the last argument, without a placeholder, so the stack trace is printed.
The rest of the application behaved as before in the same run: 404 and 409 from their own handlers, 422 and 400 from the overrides, 405 and the unknown path from the inherited methods. One request did change. GET /api/products/99/summary now answered 500: its CompletionException matches Exception directly, so the class has a match for the thrown type and the cause is never examined. A catch-all ends cause matching in its class. If a wrapper exception should map to its cause, handle the wrapper type explicitly.
Why a catch-all can turn a 405 into a 500
The catch-all above lives in a class that extends ResponseEntityExceptionHandler, and that is not a detail. The same handleUnexpected method was tried in three setups against requests that should not be 500s:
| Request | No advice | Plain advice with only the Exception handler | Same, with spring.mvc.problemdetails.enabled=true | ResponseEntityExceptionHandler subclass with only the Exception handler |
|---|---|---|---|---|
DELETE /api/products | 405 | 500 | 405 | 405 |
GET /api/nope | 404 | 500 | 404 | 404 |
invalid POST /api/products | 400 | 500 | 400 | 400 |
GET /api/products/99, @ResponseStatus and no handler | 404 | 500 | 500 | 500 |
In the plain advice, the unknown sort field and the malformed JSON also became 500, and the 405 lost its Allow header. Each of them was logged as Unhandled exception. The reasons follow from the resolver order:
ExceptionHandlerExceptionResolverruns before the other two resolvers. AnExceptionhandler matches every exception, soResponseStatusExceptionResolverandDefaultHandlerExceptionResolvernever get a turn. That includes exceptions thrown before any controller was chosen, such asNoResourceFoundExceptionfor the unknown path: a global advice applies to them too.- In a
ResponseEntityExceptionHandlersubclass, the inherited handler listsHttpRequestMethodNotSupportedExceptionand the others by their exact types. In the same class, the closest type wins, so the framework exceptions keep their status. - With the property and a plain advice, Boot's
ProblemDetailsExceptionHandleris a separate advice with@Order(0), asked before an advice without@Order, so it takes the framework exceptions first. @ResponseStatuson an exception class is lost in every setup once anExceptionhandler exists, because the annotation is read by the resolver that no longer runs.
The rule: put the catch-all in the advice that extends ResponseEntityExceptionHandler, and give every domain exception its own @ExceptionHandler or make it an ErrorResponseException. Do not rely on @ResponseStatus in an application with a catch-all.
Message codes for ProblemDetail titles and details
ResponseEntityExceptionHandler resolves the type, title and detail of framework errors through Spring's MessageSource before writing them. The codes are built from the exception's fully qualified class name; for HttpRequestMethodNotSupportedException, ErrorResponse's static helpers return:
problemDetail.type.org.springframework.web.HttpRequestMethodNotSupportedException
problemDetail.title.org.springframework.web.HttpRequestMethodNotSupportedException
problemDetail.org.springframework.web.HttpRequestMethodNotSupportedExceptionBoot configures a MessageSource when src/main/resources/messages.properties exists:
problemDetail.title.org.springframework.web.HttpRequestMethodNotSupportedException=Method not supported
problemDetail.org.springframework.web.HttpRequestMethodNotSupportedException={0} is not supported on this URL. Supported methods: {1}.With the advice from the catch-all section, the 405 became:
{"detail":"DELETE is not supported on this URL. Supported methods: [GET, POST].","instance":"/api/products","status":405,"title":"Method not supported"}The placeholders are the exception's detail message arguments, here the method and the supported methods. Your own @ExceptionHandler methods do not go through this: they build their ProblemDetail directly.
Exception handling mechanisms compared
| Mechanism | Scope | Body it produces | Use it for |
|---|---|---|---|
@ResponseStatus on the exception class | every request that throws it, unless an @ExceptionHandler takes it first | Boot's default body, even with spring.mvc.problemdetails.enabled | a domain exception in an application with no advice; lost once a catch-all exists |
ResponseStatusException | the line that throws it | default body; ProblemDetail with the reason as detail once a ResponseEntityExceptionHandler is present | a one-off check inside a controller |
@ExceptionHandler in a controller | exceptions from that controller's methods; beats every advice | whatever the handler returns | an error only one controller produces |
@RestControllerAdvice | every controller, or those selected by basePackages, assignableTypes or annotations | whatever its handlers return | the application's error contract |
ErrorResponseException subclass | the line that throws it | its own ProblemDetail once a ResponseEntityExceptionHandler is present; otherwise the default body with its status | an exception that should carry its complete ProblemDetail |
ResponseEntityExceptionHandler subclass | all Spring MVC exceptions it lists, plus ErrorResponseException | ProblemDetail, customisable per exception type | shaping framework errors: field lists, 422, message codes |
spring.mvc.problemdetails.enabled=true | the same list, when no ResponseEntityExceptionHandler of yours exists | ProblemDetail with Spring's default title and detail | standard framework error bodies without writing code |
FAQ
What is the difference between @ControllerAdvice and @RestControllerAdvice?
@RestControllerAdvice is @ControllerAdvice plus @ResponseBody, as its meta-annotations show. With @ControllerAdvice alone, a handler that returns an object needs @ResponseBody itself; returning ResponseEntity or ProblemDetail writes a body either way. For a JSON API, use @RestControllerAdvice.
Why does my @ExceptionHandler return 200 OK?
Because the handler returns a body and sets no status. Without @ResponseStatus on the handler method or a ResponseEntity, the response is 200, even if the exception class carries @ResponseStatus(HttpStatus.NOT_FOUND): in the run above, a handler for that exception answered HTTP/1.1 200 with "status":404 inside the JSON. Return ProblemDetail, ResponseEntity, or add @ResponseStatus to the method.
Can @ControllerAdvice handle a 404 for a URL that does not exist?
Yes, in Spring Boot 4. A path that no controller maps reaches the static resource handler, which throws NoResourceFoundException, and that goes through the exception resolvers like any other exception. With spring.mvc.problemdetails.enabled=true or a ResponseEntityExceptionHandler subclass, GET /api/nope answered a ProblemDetail with "detail":"No static resource api/nope."; a plain catch-all advice turned it into a 500.
Does spring.mvc.problemdetails.enabled change 500 errors?
No. It covers Spring MVC's own exceptions and ErrorResponseException. An uncaught IllegalStateException still answered Boot's default JSON with Content-Type: application/json, and a @ResponseStatus exception still answered the default body with its status. Those need your own handlers.
Why is server.error.include-message ignored in Spring Boot 4?
Spring Boot 4.0 renamed server.error.* to spring.web.error.*, and the old names are no longer bound. Setting server.error.include-message=always started the application without a warning and left the body unchanged. Use spring.web.error.include-message.
Should an API return 400 or 422 for validation errors?
Either works if the API is consistent and documents it. This series follows the design from article 15: 400 for input that cannot be read or bound, including invalid path and query parameters, and 422 for a well-formed body that breaks a rule. Spring's default for an invalid body is 400. The overrides in this article answer 422 with HttpStatus.UNPROCESSABLE_CONTENT whenever the body is invalid, whether it arrives as MethodArgumentNotValidException or, on a method that also validates a parameter, as HandlerMethodValidationException, and keep 400 when only parameters are invalid.
Conclusion
An exception that leaves a controller goes to DispatcherServlet's resolvers in a fixed order. @ExceptionHandler methods come first and are the only ones that write their own body. @ResponseStatus and Spring MVC's own exceptions follow and end in sendError. Anything left is rethrown, logged by Tomcat and turned into Boot's default body by BasicErrorController, which spring.web.error.*, no longer server.error.*, can make more revealing and should not in production. Handlers are chosen class by class — the controller first, then advices in @Order — and inside a class by the closest exception type, with causes as a last resort. ProblemDetail gives every error the RFC 9457 shape and application/problem+json. spring.mvc.problemdetails.enabled applies it to framework errors only, and a ResponseEntityExceptionHandler subclass lets you reshape them, here into a 422 with a field list. A catch-all belongs in that subclass, where it cannot turn a 405 into a 500, and it should log the exception once and tell the client nothing.
So far every class in the catalogue has lived next to the controller that uses it. The next article gives the code a structure: layered architecture with Controller, Service and Repository, and whether to organise packages by layer or by feature.