Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4
Where a Spring Boot 4 error body actually comes from, what spring.mvc.problemdetails.enabled does and does not cover, and a thirteen-failure matrix under five setups, plus the ways a tidy ProblemDetail setup hides 500s, leaks internals, or changed underneath you in Spring Framework 7.
Ask a Spring Boot API for an order that does not exist, then for one with a malformed id, then without credentials. Three failures, and in a surprisingly large number of production services, three different response bodies — one of them RFC 9457, one of them Spring Boot’s {"timestamp","status","error","path"}, one of them nothing at all. Nobody designed that. It is what you get when problem details are switched on in one place and the errors happen in another.
This article is about closing those gaps on Spring Boot 4: where an error body actually comes from, what spring.mvc.problemdetails.enabled does and does not cover, and the handful of ways a tidy ProblemDetail setup hides failures, leaks internals, or changed underneath you in Spring Framework 7.
Part
For you if
Covers
1 — Beginner
you have never returned a ProblemDetail
RFC 9457 in one table, Spring’s four types, the three places an error body comes from
2 — Intermediate
you turned the Boot flag on and some errors still look wrong
the thirteen-failure matrix, the recommended setup, validation errors that say something, debugging advice order
3 — Advanced
you own the API contract and its clients
silent 500s, the about:blank change in 7.0, hand-built mappers, XML negotiation, what clients can and cannot trust
Versions this was verified against. Spring Boot 4.1.1 (GA, published to Maven Central on 20 August 2026), Spring Framework 7.0.9, Spring Security 7.1.1, Jackson 3, Eclipse Temurin JDK 25.0.4.1 LTS. Versions were read from maven-metadata.xml on Maven Central, not from announcements. One comparison uses spring-web 6.2.19, loaded in jshell.
Companion code: spring-boot-demo, directory problem-details/. One order API with thirteen ways to fail, five exception-handling setups selected by profile, sixteen contract tests, and every transcript quoted below under docs/output/, regenerated by scripts/run-all.sh.
Part 1 — What a problem document is, and where error bodies come from
RFC 9457 in one table
A problem document is a JSON object served as application/problem+json (or XML as application/problem+xml). It has five members, all optional:
Member
Meaning
type
A URI naming the kind of problem. Absent means about:blank.
title
A short summary of that kind — the same for every occurrence.
status
The HTTP status, repeated in the body.
detail
A human-readable explanation of this occurrence.
instance
A URI identifying this occurrence.
Anything else is an extension member: an orderId, a list of validation errors, a correlation id. RFC 9457 obsoletes RFC 7807 without changing the wire format. What it added is a registry of common problem types, guidance on multiple problems (“the most relevant or urgent problem” should be represented), and guidance for type URIs that cannot be dereferenced. Even the XML namespace is still urn:ietf:rfc:7807.
Spring’s four types
Type
What it is
ProblemDetail
The body: the five members plus a properties map for extensions.
ErrorResponse
An interface — “I know my status, headers and ProblemDetail”. Every Spring MVC exception implements it.
ErrorResponseException
A convenient base class for your own self-describing exceptions.
ResponseEntityExceptionHandler
A @ControllerAdvice base class that renders every Spring MVC exception as a problem.
The smallest working version needs none of your own code:
spring:
mvc:
problemdetails:
enabled: true
$ curl -X DELETE /orders/1
HTTP 405 Content-Type: application/problem+json
{"detail":"Method 'DELETE' is not supported.","instance":"/orders/1","status":405,"title":"Method Not Allowed"}
That is a correct RFC 9457 response, and for a while it looks like the whole job. It is about a third of it.
The three places an error body comes from
Everything else in this article follows from one fact: an HTTP error response in a Spring Boot servlet application is produced in one of three places, and a @ControllerAdvice can only influence one of them.
Part 2 measures exactly that picture.
Part 2 — Thirteen failures, five setups, one shape
The matrix
The companion project has one small order API and thirteen ways to make it fail: a domain exception, every common Spring MVC exception, an ErrorResponseException of its own, an unexpected IllegalStateException, an exception thrown by a servlet filter, and Spring Security’s 401 and 403. One script calls all thirteen under five setups and classifies each response by its body:
Read it by column.
defaults — Spring Boot 4.1.1 out of the box. spring.mvc.problemdetails.enabled is false in Boot’s configuration metadata, so nothing is a problem document, and the domain exception is a 500.
boot-flag — the property turned on. Boot registers ProblemDetailsExceptionHandler, an empty subclass of ResponseEntityExceptionHandler, and it does exactly what its superclass does: Spring MVC’s own exceptions and any ErrorResponse. Your OrderNotFoundException, the unexpected 500, the filter and Security all keep Boot’s JSON. This is the column most APIs are actually in — two error shapes, split along a line your clients cannot see.
advice — your own @RestControllerAdvice extending ResponseEntityExceptionHandler, plus Security handlers. Everything but the filter.
advice,errors — the same, plus a replacement for Boot’s /error controller. Thirteen out of thirteen.
catchall-first — a trap, covered below.
The Boot flag backs off the moment you write your own handler. Its configuration class carries @ConditionalOnMissingBean(ResponseEntityExceptionHandler.class) and @Order(0) (read with javap from spring-boot-webmvc-4.1.1.jar). Declare a subclass of your own and Boot’s disappears, whether or not the property is set — the advice-order transcript shows only one advice left with both profiles active. So the property is a starting point, not something to keep alongside a real handler.
The recommended setup
Extend ResponseEntityExceptionHandler so every Spring MVC exception is already right, then add your domain exceptions and a catch-all:
$ curl -X GET /orders/999
HTTP 404 Content-Type: application/problem+json
{"detail":"Order 999 does not exist","instance":"/orders/999","status":404,"title":"Order not found","type":"https://ankurm.com/problems/order-not-found","orderId":999}
Three details are doing real work there. The type is a stable URI a client can switch on — title and detail are for people and may be translated. The instance was filled in by Spring from the request path; nobody set it. And the catch-all puts its own sentence in detail, never ex.getMessage(): the exception in this project carries a JDBC URL and a database user, which is precisely the “implementation details” RFC 9457’s security considerations warn about. Keep both catch-all and specific handlers in one class — within a class, the most specific handler wins.
Validation errors that say something
The stock body for a request with three invalid fields names none of them:
HTTP 400 Content-Type: application/problem+json
{"detail":"The request body has 3 invalid field(s)","instance":"/orders","status":400,"title":"Bad Request","errors":[{"pointer":"#/quantity","detail":"must be greater than or equal to 1"},{"pointer":"#/customerEmail","detail":"must be a well-formed email address"},{"pointer":"#/sku","detail":"must not be blank"}]}
The {"pointer","detail"} shape is not invented: it is the example RFC 9457 uses in its own section 3. Two notes from running it. The three errors came back in a different order on different runs, so the contract test asserts membership, not position. And a constraint placed directly on a parameter — @RequestParam @Max(100) int limit — raises a different exception, HandlerMethodValidationException (Spring Framework 6.1’s built-in method validation, no @Validated needed), with its own stock detail of "Validation failure" and its own hook, handleHandlerMethodValidationException.
The last two boxes: filters and Security
The advice cannot see the filter’s exception; Tomcat forwards it to /error. Replace Spring Boot’s controller there — declaring any ErrorController bean (org.springframework.boot.webmvc.error in Boot 4) makes ErrorMvcAutoConfiguration skip BasicErrorController:
@RestController
public class ProblemDetailErrorController implements ErrorController {
private final ErrorAttributes errorAttributes;
public ProblemDetailErrorController(ErrorAttributes errorAttributes) {
this.errorAttributes = errorAttributes;
}
@RequestMapping("${server.error.path:${error.path:/error}}")
ResponseEntity<ProblemDetail> error(HttpServletRequest request) {
Map<String, Object> attrs = errorAttributes.getErrorAttributes(
new ServletWebRequest(request), ErrorAttributeOptions.defaults());
int status = attrs.get("status") instanceof Integer s ? s : 500;
ProblemDetail pd = ProblemDetail.forStatus(status);
pd.setDetail(status >= 500 ? "An unexpected error occurred." : String.valueOf(attrs.get("error")));
if (attrs.get("path") instanceof String path) {
pd.setInstance(URI.create(path));
}
return ResponseEntity.status(status).contentType(MediaType.APPLICATION_PROBLEM_JSON).body(pd);
}
}
$ curl -X GET /orders/1 [X-Tenant: BAD!]
HTTP 500 Content-Type: application/problem+json
{"detail":"An unexpected error occurred.","instance":"/orders/1","status":500,"title":"Internal Server Error"}
Security’s default entry point calls sendError(401), which also lands on /error, so the error controller alone — no advice, no Security customisation — already turns Security’s responses into problems:
The companion project goes one step further with an AuthenticationEntryPoint and AccessDeniedHandler that write the problem themselves, keeping a precise type and the WWW-Authenticate header. It is registered on httpBasic(...) as well as exceptionHandling(...), because HTTP Basic carries its own entry point and ignores the other one. For the mechanics of where those handlers sit, the filter chain article walks through the order.
Debugging: which advice handled this?
The catchall-first column is a real configuration found in real code: a catch-all advice given @Order(Ordered.HIGHEST_PRECEDENCE) “so it always runs”. It turns the unknown path, the wrong method and the malformed body into 500s:
$ curl -X GET /no-such-thing
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/no-such-thing","status":500,"title":"Internal Server Error"}
When an error has the wrong shape, find out which advice handled it before changing any of them. ControllerAdviceBean.findAnnotatedBeans(context) lists them in consultation order — the diagnostic endpoint in the companion project prints exactly that:
## profiles: catchall-first
order -2147483648 com.ankurm.problems.advice.CatchAllFirstHandler
order 0 org.springframework.boot.webmvc.autoconfigure.ProblemDetailsExceptionHandler
Part 3 — What the defaults do not tell you
Tidy errors can be invisible errors
When an exception escapes to the container, Tomcat logs it with its stack trace. When an @ExceptionHandler handles it, nothing logs it unless the handler does — a handled exception is, as far as the framework is concerned, no longer an error. One request to the failing endpoint per setup, counting what reached the log:
defaults (Boot /error) ERROR lines: 1 stack frames: 87
boot-flag ERROR lines: 1 stack frames: 87
advice, catch-all logs with errorId ERROR lines: 1 stack frames: 87
advice, catch-all WITHOUT the log line ERROR lines: 0 stack frames: 0
catchall-first (returns 500, never logs) ERROR lines: 0 stack frames: 0
The last two return a well-formed 500 and leave no trace anywhere. Adding problem details to an API that used to log its failures can remove the only record of them, and nobody notices until someone asks why the error rate dashboard went quiet the week the new handler shipped.
The fingerprint is a 500 with no matching log line. Put a correlation id in the body and log the exception with the same id — that is what the errorId in the recommended catch-all is for. Support gets a string from the customer, you grep for it, and the stack trace is there.
Spring Framework 7 stopped defaulting type to about:blank
Every framework error above has no type member. On Spring Framework 6.2 each one said "type":"about:blank". Evaluated in jshell against both jars:
Both versions’ ProblemDetailJacksonMixin carry @JsonInclude(NON_EMPTY), so 6.2 rendered the URI and 7.0 omits the member. RFC 9457 says an absent typemeansabout:blank, so both are valid. But a contract test that matched the literal "type":"about:blank" fails on the upgrade to Spring Boot 4, and on the client side ProblemDetail.getType() is now null for these responses — so pd.getType().equals(...) throws where it used to work.
Writing a ProblemDetail yourself? Use the application’s mapper
The Security handlers and any filter that writes an error body have to serialise the ProblemDetail themselves. One object with one extension member, through three mappers:
"Spring Boot's JsonMapper bean": "{\"detail\":\"demo\",\"status\":409,\"title\":\"Conflict\",\"sku\":\"SKU-2\"}",
"JsonMapper.builder().build()": "{\"detail\":\"demo\",\"instance\":null,\"properties\":{\"sku\":\"SKU-2\"},\"status\":409,\"title\":\"Conflict\",\"type\":null}",
"new JsonMapper()": "{\"detail\":\"demo\",\"instance\":null,\"properties\":{\"sku\":\"SKU-2\"},\"status\":409,\"title\":\"Conflict\",\"type\":null}"
ProblemDetail is a plain bean. Flattening properties into top-level members and dropping empty ones is the mixin’s work, and a mapper you construct does not have the mixin. The new ObjectMapper().writeValue(response.getOutputStream(), problem) line in most entry-point tutorials produces "properties":{...} and "type":null — not a valid problem document. Inject the JsonMapper Spring Boot built.
Accept: application/xml gets a JSON error
With jackson-dataformat-xml on the classpath, a successful response honours Accept: application/xml. The error response to the same request does not:
Accept
GET /orders/1 (200)
GET /orders/999 (404)
application/json
application/json
application/problem+json
application/xml
application/xml
application/problem+json
application/problem+xml
—
application/problem+xml
image/png
—
application/problem+json, still 404
For a ProblemDetail body the producible types are application/problem+json and application/problem+xml, and application/xml is compatible with neither — problem+xml is a different subtype, not a specialisation. Rather than turn the error into a 406, Spring falls back to JSON. That is the right behaviour; the surprise is that an XML client has to ask for application/problem+xml by name.
What a client can trust
On the client side, RestClient throws HttpClientErrorException or HttpServerErrorException, and getResponseBodyAs(ProblemDetail.class) decodes the body. Extension members arrive in getProperties() — the mixin works in both directions. The trap is that decoding always succeeds. Against the defaults profile, where the server sends Boot’s plain JSON:
Unknown members go into properties, so any JSON object “is” a ProblemDetail. Check that the Content-Type is application/problem+json before believing you have one, and treat a null type as about:blank.
The long tail
Each of these has a chapter in the companion repository rather than a section here:
Translated title and detail from messages.properties, keyed by problemDetail.title.<exception class>, with arguments from getDetailMessageArguments() — and why it only applies on the ResponseEntityExceptionHandler path: chapter 4
Nested field paths and list indexes in validation pointers: chapter 3
Why HTTP Basic needs its own entry point registration: chapter 6
Advice ordering, and the @Order(0) on Boot’s handler: chapter 2
Should you convert an existing API? Not in one go, and not silently. The error body is part of your contract, and clients already parse Boot’s {"timestamp","status","error","path"} — switching the shape under them is a breaking change, even though it is a better shape. For a new API, start with the recommended setup on day one. For an existing one, add application/problem+json for clients that ask for it, measure who still parses the old body, and deprecate on a schedule.
The change worth making unconditionally is the logging one. If your catch-all handler does not log, fix that today.
Further reading
Companion project — runnable, with every transcript quoted above under docs/output/
No Comments yet!