REST Error Handling with @ControllerAdvice and ProblemDetail
Introduction
This tutorial builds consistent, informative error responses for a Spring Boot REST API, handled in one place. We will start with the problems of ad-hoc error handling, and the standard that solves them: Problem Details for HTTP APIs (RFC 9457) and Spring'sProblemDetail class. We will then write a global error handler with @RestControllerAdvice and @ExceptionHandler that turns a domain exception into a problem response, and an exception that describes its own response by extending ErrorResponseException. Next we will return validation errors in the same format, see Spring MVC's own errors - wrong types, unsupported methods, unknown paths - come out consistently too, and add a safe catch-all for unexpected failures. Lastly, we will look at the order in which handlers are chosen and list good practices.(This tutorial continues from Bean Validation in Spring BootClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators, whose controller-local handler we now make global.)
Why errors need a standard format
In the earlier tutorials, errors came back in different shapes: Spring Boot's default JSON (timestamp, status, error, path) for some, a plain map of field errors for validation, plain text for a conflict. Every client has to handle each shape differently, and the default body deliberately says little about what went wrong. RFC 9457, Problem Details for HTTP APIs, defines one JSON format for errors, with the media type application/problem+json and these members -
type: a URI identifying the kind of problem (defaults toabout:blank).title: a short summary of the problem type, the same for every occurrence.status: the HTTP status code.detail: an explanation of this occurrence, for humans.instance: a URI for this occurrence - often the request path.- any further extension members the API wants to add, such as an error code or a list of field errors.
ProblemDetail class, and can produce it for all of its own exceptions.
The example API and its global error handler
The API below has a few endpoints that fail in different ways, and one class,ApiExceptionHandler, that turns every failure into a problem response -
ErrorHandlingApp.java - domain exceptions, a controller and a @RestControllerAdvice
//ErrorHandlingApp.java
package com.javabrahman.rest.errors;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.net.URI;
import java.util.Map;
import java.util.TreeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
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.web.ErrorResponseException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
// A plain domain exception: knows nothing about HTTP
class BookNotFoundException extends RuntimeException {
final long id;
BookNotFoundException(long id) {
super("No book with id " + id);
this.id = id;
}
}
// An exception that describes its own HTTP response
class OutOfStockException extends ErrorResponseException {
OutOfStockException(String title) {
super(HttpStatus.CONFLICT, problem(title), null);
}
private static ProblemDetail problem(String title) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "'" + title + "' is out of stock");
pd.setType(URI.create("https://api.example.com/problems/out-of-stock"));
pd.setTitle("Out of stock");
pd.setProperty("restockExpected", "2026-11-20");
return pd;
}
}
record NewBook(@NotBlank String title, @Positive BigDecimal price) {
}
@RestController
@RequestMapping("/api/books")
class BookController {
@GetMapping("/{id}")
String one(@PathVariable long id) {
if (id != 1) {
throw new BookNotFoundException(id);
}
return "Effective Java";
}
@PostMapping
String create(@Valid @RequestBody NewBook book) {
return "created " + book.title();
}
@PostMapping("/{id}/orders")
String order(@PathVariable long id) {
throw new OutOfStockException("Effective Java");
}
@GetMapping("/report")
String report() {
throw new IllegalStateException("Connection to db.internal:5432 refused for user shop_admin");
}
}
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
// 1. A domain exception becomes a 404 problem
@ExceptionHandler(BookNotFoundException.class)
ProblemDetail bookNotFound(BookNotFoundException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
pd.setType(URI.create("https://api.example.com/problems/book-not-found"));
pd.setTitle("Book not found");
pd.setProperty("bookId", e.id);
return pd;
}
// 2. Validation errors: Spring's standard problem, plus the field errors
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail pd = e.getBody(); // status 400, title "Bad Request", detail "Invalid request content."
Map<String, String> errors = new TreeMap<>();
e.getBindingResult().getFieldErrors().forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
pd.setProperty("errors", errors);
return handleExceptionInternal(e, pd, headers, status, request);
}
// 3. Anything else: log it, and tell the client as little as possible
@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception e) {
log.error("Unexpected error", e);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred. It has been logged and will be looked at.");
}
}
@SpringBootApplication
public class ErrorHandlingApp {
public static void main(String[] args) {
SpringApplication.run(ErrorHandlingApp.class, args);
}
}
Explanation of the code
@RestControllerAdvicemakesApiExceptionHandlera bean whose@ExceptionHandlermethods apply to exceptions thrown by every controller. (It is@ControllerAdviceplus@ResponseBody, just as@RestControlleris@Controllerplus@ResponseBody.)- Extending
ResponseEntityExceptionHandlergives it ready-made handlers for all of Spring MVC's own exceptions - type mismatches, unsupported methods and media types, unknown paths, validation failures - each producing aProblemDetail. Its methods can be overridden to customise one case, ashandleMethodArgumentNotValid()is here. ProblemDetail.forStatusAndDetail()creates a problem;setType(),setTitle()andsetProperty()fill in the rest. Returning aProblemDetailfrom a handler sets the response status from it and the content type toapplication/problem+json.OutOfStockExceptionextendsErrorResponseException, so it carries its own status and problem; Spring renders it without any handler being written for it.
Domain errors as problem responses
OUTPUT of a missing book and an out-of-stock order> curl -i http://localhost:8080/api/books/1
HTTP/1.1 200
Content-Length: 14
Content-Type: text/plain; charset=UTF-8
Effective Java
> curl -i http://localhost:8080/api/books/42
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"No book with id 42","instance":"/api/books/42","status":404,"title":"Book not found","type":"https://api.example.com/problems/book-not-found","bookId":42}
> curl -i -X POST http://localhost:8080/api/books/1/orders
HTTP/1.1 409
Content-Type: application/problem+json
{"detail":"'Effective Java' is out of stock","instance":"/api/books/1/orders","status":409,"title":"Out of stock","type":"https://api.example.com/problems/out-of-stock","restockExpected":"2026-11-20"}
Explanation of the output
- Book 42 does not exist. The controller threw
BookNotFoundException, a plain exception with no HTTP knowledge, and the advice turned it into a404problem with atype, atitle, thedetailmessage and abookIdextension member. - Spring added
instance- the request path - by itself. - The order failed with
OutOfStockException, which needed no handler: its409problem, including therestockExpectedextension, came from the exception itself. - Both responses have
Content-Type: application/problem+json, which tells clients the body follows RFC 9457.
ErrorResponseException subclasses are convenient for exceptions that exist only for the web layer.
Validation errors in the same format
OUTPUT of an invalid book> curl -i -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"","price":0}'
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Invalid request content.","instance":"/api/books","status":400,"title":"Bad Request","errors":{"price":"must be greater than 0","title":"must not be blank"}}
handleMethodArgumentNotValid() started from Spring's own problem for validation failures - status, title, and the detail Invalid request content. - and added an errors member with each field's message. Clients now find field errors in the same place for every endpoint.
Spring MVC's own errors, and the unexpected ones
Because the advice extendsResponseEntityExceptionHandler, the errors Spring MVC raises itself come out as problems too. And the catch-all handler turns any other exception into a 500 problem -
OUTPUT of Spring MVC errors and an unexpected exception> curl -i http://localhost:8080/api/books/abc
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/api/books/abc","status":400,"title":"Bad Request"}
> curl -i -X DELETE http://localhost:8080/api/books/1
HTTP/1.1 405
Allow: GET
Content-Type: application/problem+json
{"detail":"Method 'DELETE' is not supported.","instance":"/api/books/1","status":405,"title":"Method Not Allowed"}
> curl -i http://localhost:8080/api/authors
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"No static resource api/authors.","instance":"/api/authors","status":404,"title":"Not Found"}
> curl -i http://localhost:8080/api/books/report
HTTP/1.1 500
Content-Type: application/problem+json
{"detail":"An unexpected error occurred. It has been logged and will be looked at.","instance":"/api/books/report","status":500,"title":"Internal Server Error"}
Explanation of the output
- A path variable of the wrong type, an unsupported method and an unknown path produced
400,405(still with itsAllowheader) and404problems, each with a precisedetail- instead of the bare default error body of earlier tutorials. The unknown path's detail mentions a static resource because, when no controller matches, Spring MVC finally tries to serve a static file of that name and reports that none exists (NoResourceFoundException). - The report endpoint threw an
IllegalStateExceptionwhose message contains a server name and a user name. The catch-all handler logged the exception with its stack trace, and the client received only a generic500problem. Never send exception messages or stack traces of unexpected errors to clients: they leak internal details and help attackers.
spring.mvc.problemdetails.enabled=true makes Spring Boot register a ResponseEntityExceptionHandler of its own, so that at least Spring MVC's errors use the problem format. Once you write your own advice extending it, as here, that property is not needed.
How a handler is chosen
When a controller method throws, Spring looks for a handler in this order -- an
@ExceptionHandlermethod in the same controller, like the one in the validation tutorialClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators; @ExceptionHandlermethods in@ControllerAdvicebeans, in their@Order; an advice can be limited to some controllers withbasePackagesorassignableTypes;@ResponseStatuson the exception class, andResponseStatusExceptionorErrorResponseExceptioninstances;- Spring Boot's fallback error handling, which produced the default error bodies earlier in this module.
Exception.class catch-all did not swallow BookNotFoundException.
Good practices
- Handle errors in one
@RestControllerAdvice, and keep controllers free of try/catch. - Use
application/problem+jsonfor every error, with a stabletypeURI per kind of problem - ideally pointing to documentation of that problem. - Put machine-readable details in extension members (
bookId,errors,restockExpected), and keepdetailfor humans. - Log unexpected errors with their stack trace, return a generic message, and consider adding a correlation id to both the log and the problem, so that support can find the log entry.
- Document the problem types in the API documentation, covered in Documenting Spring Boot REST APIs with OpenAPIClick to read tutorial: Documenting Spring Boot REST APIs with OpenAPI and springdoc.
Summary
In this tutorial we first saw why ad-hoc error bodies are a problem, and the RFC 9457 Problem Details format with itstype, title, status, detail and instance members. We then wrote a @RestControllerAdvice that turned a domain exception into a 404 problem, and an ErrorResponseException that rendered its own 409 problem. Next we added field errors to Spring's validation problem, saw type mismatches, unsupported methods and unknown paths come out as problems because the advice extends ResponseEntityExceptionHandler, and hid an unexpected exception's details behind a generic 500. Lastly, we went through the order in which handlers are chosen, and good practices. The next tutorial shows how to change an API without breaking its clients: API versioning in Spring Framework 7Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4.Spring REST API Tutorials
Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4 Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces
Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4 Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces