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's ProblemDetail 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 to about: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.
Spring Framework represents such a body with the 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

  • @RestControllerAdvice makes ApiExceptionHandler a bean whose @ExceptionHandler methods apply to exceptions thrown by every controller. (It is @ControllerAdvice plus @ResponseBody, just as @RestController is @Controller plus @ResponseBody.)
  • Extending ResponseEntityExceptionHandler gives 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 a ProblemDetail. Its methods can be overridden to customise one case, as handleMethodArgumentNotValid() is here.
  • ProblemDetail.forStatusAndDetail() creates a problem; setType(), setTitle() and setProperty() fill in the rest. Returning a ProblemDetail from a handler sets the response status from it and the content type to application/problem+json.
  • OutOfStockException extends ErrorResponseException, 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 a 404 problem with a type, a title, the detail message and a bookId extension member.
  • Spring added instance - the request path - by itself.
  • The order failed with OutOfStockException, which needed no handler: its 409 problem, including the restockExpected extension, came from the exception itself.
  • Both responses have Content-Type: application/problem+json, which tells clients the body follows RFC 9457.
Which style to choose? Keeping domain exceptions free of HTTP and mapping them in the advice keeps the business layer independent of the web layer. 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"}}
The overridden 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 extends ResponseEntityExceptionHandler, 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 its Allow header) and 404 problems, each with a precise detail - 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 IllegalStateException whose 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 generic 500 problem. Never send exception messages or stack traces of unexpected errors to clients: they leak internal details and help attackers.
Without a custom advice, setting 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 -
  1. an @ExceptionHandler method in the same controller, like the one in the validation tutorialClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators;
  2. @ExceptionHandler methods in @ControllerAdvice beans, in their @Order; an advice can be limited to some controllers with basePackages or assignableTypes;
  3. @ResponseStatus on the exception class, and ResponseStatusException or ErrorResponseException instances;
  4. Spring Boot's fallback error handling, which produced the default error bodies earlier in this module.
Within one place, the handler for the most specific exception type wins - which is why the 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+json for every error, with a stable type URI per kind of problem - ideally pointing to documentation of that problem.
  • Put machine-readable details in extension members (bookId, errors, restockExpected), and keep detail for 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 its type, 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.