The full picture
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalid(MethodArgumentNotValidException e) {
var p = ProblemDetail.forStatus(422);
p.setType(URI.create("https://api.acme.com/errors/validation"));
p.setProperty("violations", e.getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage()))
.toList());
return p;
}
@ExceptionHandler(OptimisticLockingFailureException.class)
ProblemDetail conflict(OptimisticLockingFailureException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"Resource was modified concurrently; refetch and retry.");
}
@ExceptionHandler(Exception.class)
ProblemDetail unknown(Exception e, HttpServletRequest req) {
log.error("unhandled [{}]", MDC.get("cid"), e); // logged ONCE, full stack
var p = ProblemDetail.forStatus(500);
p.setProperty("correlationId", MDC.get("cid")); // client can quote it
return p; // no internals leaked
}
}- Resolution order matters: more specific @ExceptionHandler wins; extending
ResponseEntityExceptionHandlergives you Spring's own exceptions (unreadable body, method-not-allowed) mapped consistently too. - Two error surfaces exist in Boot: MVC's advice chain and the container-level
/error(BasicErrorController) for failures outside MVC (filter exceptions!) — align both or clients see two formats. - Security nuance: 404-vs-403 leaks existence of resources; decide the policy explicitly per resource type.
- Version the error
typeURIs like API contracts — clients build logic on them.