Command Palette

Search for a command to run...

[Spring Boot Basics] Global Exception Handling in Spring Boot: @RestControllerAdvice, @ExceptionHandler and ProblemDetail

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.

Exceptions with statuses 404, 405, 409 and 500 flowing into one @RestControllerAdvice, which answers all of them with the same ProblemDetail shape

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:

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

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

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) {
}

The two domain exceptions start as plain RuntimeExceptions:

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

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 the jar and start it:

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

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

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"}

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:

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]

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:

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>

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

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

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

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 is Boot's. It records the exception so the error body can use it later, and always passes it on.
  • ExceptionHandlerExceptionResolver runs @ExceptionHandler methods: the controller's own first, then those in @ControllerAdvice classes. It is the only resolver that lets your code write the response.
  • ResponseStatusExceptionResolver handles ResponseStatusException and exceptions annotated with @ResponseStatus, by calling response.sendError(status). HandlerMethodValidationException extends ResponseStatusException, so article 19's parameter validation failures ended here.
  • DefaultHandlerExceptionResolver handles 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.

The route of an exception: DispatcherServlet asks DefaultErrorAttributes, ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver and DefaultHandlerExceptionResolver in order; only an @ExceptionHandler writes its own response, while sendError and an unhandled exception both end in an ERROR dispatch to /error and 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:

PropertyDefaultAdds to the body
spring.web.error.include-messagenevermessage: the exception's message
spring.web.error.include-binding-errorsnevererrors: every validation error with its codes
spring.web.error.include-stacktracenevertrace: the whole stack trace as one string
spring.web.error.include-exceptionfalseexception: the exception's class name
spring.web.error.include-pathalwayspath
spring.web.error.path/errorthe path of the error controller, not a body field
spring.web.error.whitelabel.enabledtruethe 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:

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

With include-message=always, the failing stock request gains a 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"}

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:

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"
}

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:

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.* 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:

JSON
{"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: InvocableHandlerMethod says 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=always publishes 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:

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"}

The status is right; the body is still Boot's default. The DEBUG log shows why:

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

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"}

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:

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

Then add two handler methods to 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"}

What the two handlers show:

  • The value of @ExceptionHandler names the exception types the method handles, and the method receives the exception as a parameter.
  • Request parameters. HttpServletRequest gives the raw servlet request; WebRequest is Spring's abstraction over it. getDescription(false) returns uri=/api/products, prefix included, which is why the second body says uri=: for a path, prefer getRequestURI().
  • Status. Returning ResponseEntity sets the status in code, per call. Returning the body alone needs @ResponseStatus on the handler, the same choice article 17 made for ordinary controller methods. Once a handler takes the exception, the @ResponseStatus on ProductNotFoundException plays 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:

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"}

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:

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

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

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());
    }
}

Both controllers now answer with the same 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"}

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:

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]

A handler that returns a body needs a status from somewhere. This variant of the not-found handler has no @ResponseStatus and no 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, 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:

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

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

How Spring picks one @ExceptionHandler: first the controller's own handlers, then @ControllerAdvice beans in @Order; inside one class the closest handler for the thrown type wins, and only if none matches that type does Spring look at the cause, at any depth

The closest exception type wins inside one class

One advice with a general and a specific handler:

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

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 is the version from the previous section, with its ProductNotFoundException handler.

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"}

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:

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
no @Orderno @Order500, 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:

Text
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-ProductNotFoundException advice, the same request answered 500 Unexpected error. CompletionException is itself a RuntimeException, 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 ProductNotFoundException handler in ProductController and an advice handling CompletionException, 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:

  1. The controller's own class, then each applicable advice from the lowest @Order up. The first class with a match wins.
  2. 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:

MemberMeaning in RFC 9457In Spring
typea URI that identifies the kind of problem; when absent, it is about:blanksetType(URI)
titlea short, human-readable summary of that kind of problemsetTitle(String); without it, the status's reason phrase
statusthe HTTP status codeProblemDetail.forStatus(...), forStatusAndDetail(...)
detailan explanation of this occurrenceforStatusAndDetail(status, detail), setDetail(String)
instancea URI for this occurrencesetInstance(URI); Spring MVC fills in the request path when unset
extension membersanything else the problem type definessetProperty(name, value)

Rewrite the advice to return 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}

And the duplicate SKU:

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"}

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:

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"}

The status line says 409 and the body says 400. Spring sends the ResponseEntity status and logs a warning:

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

The 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 sentContent-Type received
*/* (curl's default)application/problem+json
application/jsonapplication/problem+json
application/xmlapplication/problem+json
text/htmlapplication/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:

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;
    }
}

With no advice in the application at all, the duplicate POST answered:

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

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}']]

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:

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"}

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:

ExceptionImplements ErrorResponse
NoResourceFoundExceptionyes
NoHandlerFoundExceptionyes
HttpRequestMethodNotSupportedExceptionyes
HttpMediaTypeNotAcceptableExceptionyes
HttpMediaTypeNotSupportedExceptionyes
MissingPathVariableExceptionyes
MissingServletRequestParameterExceptionyes
MissingServletRequestPartExceptionyes
ServletRequestBindingExceptionyes
MethodArgumentNotValidExceptionyes
HandlerMethodValidationExceptionyes
AsyncRequestTimeoutExceptionyes
MaxUploadSizeExceededExceptionyes
ResponseStatusException, ErrorResponseExceptionyes
TypeMismatchException, MethodArgumentTypeMismatchExceptionno
HttpMessageNotReadableExceptionno
HttpMessageNotWritableExceptionno
ConversionNotSupportedExceptionno
MethodValidationExceptionno
AsyncRequestNotUsableExceptionno
jakarta.validation.ConstraintViolationExceptionno

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:

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

After, with Content-Type: application/problem+json:

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

405, DELETE /api/products. Before:

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

After, still with Allow: GET, POST:

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

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

After:

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

Text
   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 RuntimeException is untouched. GET /api/products/1/stock still answered {"timestamp":"2026-09-12T07:36:18.498Z","status":500,"error":"Internal Server Error","path":"/api/products/1/stock"} with Content-Type: application/json, plus the Tomcat ERROR log line.
  • ProductNotFoundException with @ResponseStatus is untouched: {"timestamp":"2026-09-12T07:40:52.369Z","status":404,"error":"Not Found","path":"/api/products/99"}. The annotation is handled by ResponseStatusExceptionResolver, which only calls sendError.
  • ResponseStatusException does change, because it is an ErrorResponseException: the unknown sort field answered {"detail":"Unknown sort field: colour","instance":"/api/products","status":400,"title":"Bad Request"}, the reason now visible as detail.

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.

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 is the plain RuntimeException version again. The invalid POST:

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"}]}

The same body sent with 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"}]}

The same response, from a different exception. With spring.mvc.log-resolved-exception=true, the POST was logged as MethodArgumentNotValidException and the PUT as:

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"]

An invalid id with a valid body stays a 400, and so does the invalid id on GET:

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"}]}

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, not UNPROCESSABLE_ENTITY. Both exist in spring-web 7.0.9, and UNPROCESSABLE_ENTITY is @Deprecated(since = "7.0") in favour of the name RFC 9110 uses. The title Unprocessable Content is that constant's reason phrase.
  • A fresh ProblemDetail for each response. The exception's own getBody() is created with status 400. validationProblem builds 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() returns id because 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:

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)

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:

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"}

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:

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]

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:

RequestNo advicePlain advice with only the Exception handlerSame, with spring.mvc.problemdetails.enabled=trueResponseEntityExceptionHandler subclass with only the Exception handler
DELETE /api/products405500405405
GET /api/nope404500404404
invalid POST /api/products400500400400
GET /api/products/99, @ResponseStatus and no handler404500500500

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:

  • ExceptionHandlerExceptionResolver runs before the other two resolvers. An Exception handler matches every exception, so ResponseStatusExceptionResolver and DefaultHandlerExceptionResolver never get a turn. That includes exceptions thrown before any controller was chosen, such as NoResourceFoundException for the unknown path: a global advice applies to them too.
  • In a ResponseEntityExceptionHandler subclass, the inherited handler lists HttpRequestMethodNotSupportedException and 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 ProblemDetailsExceptionHandler is a separate advice with @Order(0), asked before an advice without @Order, so it takes the framework exceptions first.
  • @ResponseStatus on an exception class is lost in every setup once an Exception handler 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:

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

Boot configures a MessageSource when src/main/resources/messages.properties exists:

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

With the advice from the catch-all section, the 405 became:

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

MechanismScopeBody it producesUse it for
@ResponseStatus on the exception classevery request that throws it, unless an @ExceptionHandler takes it firstBoot's default body, even with spring.mvc.problemdetails.enableda domain exception in an application with no advice; lost once a catch-all exists
ResponseStatusExceptionthe line that throws itdefault body; ProblemDetail with the reason as detail once a ResponseEntityExceptionHandler is presenta one-off check inside a controller
@ExceptionHandler in a controllerexceptions from that controller's methods; beats every advicewhatever the handler returnsan error only one controller produces
@RestControllerAdviceevery controller, or those selected by basePackages, assignableTypes or annotationswhatever its handlers returnthe application's error contract
ErrorResponseException subclassthe line that throws itits own ProblemDetail once a ResponseEntityExceptionHandler is present; otherwise the default body with its statusan exception that should carry its complete ProblemDetail
ResponseEntityExceptionHandler subclassall Spring MVC exceptions it lists, plus ErrorResponseExceptionProblemDetail, customisable per exception typeshaping framework errors: field lists, 422, message codes
spring.mvc.problemdetails.enabled=truethe same list, when no ResponseEntityExceptionHandler of yours existsProblemDetail with Spring's default title and detailstandard 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.

Related Posts

[Spring Boot Basics] JSON with Jackson 3 and DTOs in Spring Boot: Serialization, Deserialization and MapStruct

JSON in Spring Boot 4.1.1 with Jackson 3.1.5, verified on a real project: JacksonJsonHttpMessageConverter and the jacksonJsonMapper bean, the tools.jackson packages, the immutable JsonMapper and unchecked exceptions, measured Jackson 3 defaults against use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enums and Optional, records, @JsonAlias and @JsonCreator, spring.jackson properties and JsonMapperBuilderCustomizer, why DTOs beat exposing the entity, manual mapping and MapStruct 1.6.3 with Gradle and Maven.

[Spring Boot Basics] Layered Architecture in Spring Boot: Controller, Service, Repository and Package by Layer vs by Feature

Layered architecture in Spring Boot 4.1.1, refactored and verified on a real catalogue API: the controller that does everything and the startup crash when it is reused, what controller, service and repository each own, where DTO mapping belongs, a step-by-step refactor proven unchanged with curl, a service interface or a concrete class checked with Mockito 5.23, five layering anti-patterns, and package by layer versus package by feature measured on one change, with package-private beans injected and the compiler error that keeps features apart.

[Spring Boot Basics] Validation in Spring Boot: Bean Validation Annotations, @Valid and Custom Validators

Bean Validation in Spring Boot 4.1.1 with Hibernate Validator 9.1.3, checked against real runs: spring-boot-starter-validation, @NotNull vs @NotEmpty vs @NotBlank, @Size, @DecimalMin, @Digits, @Email and @Pattern on request DTO records, @Valid on @RequestBody and the default 400, nested objects and lists, @PathVariable and @RequestParam validation and the @Validated 500 trap, validation groups, ValidationMessages.properties and Accept-Language, custom ConstraintValidator and cross-field constraints, and validation in the service layer.

[Spring Boot Basics] Logging in Spring Boot: SLF4J, Logback, Log Levels and Log Files

Logging in Spring Boot 4.1.1, verified on a real project: SLF4J as the facade and Logback 1.5.38 as the implementation, the jul-to-slf4j and log4j-to-slf4j bridges, parameterised and fluent logging, exceptions, log levels, the logger hierarchy and log groups, --debug versus --trace, the default log line pattern, logging.file.name with rotation, logback-spring.xml with springProfile, MDC and switching to Log4j2.