Ch. 8 · Spring Boot

Spring Boot Problem Details for APIs

Return RFC 9457 problem details from Spring Boot 3: turn on the built-in handler, map domain exceptions, add fields, and keep internals out.

~6 min readintermediateupdated Oct 19, 2026

Every API needs to tell clients what went wrong, and left alone, every team invents its own shape: {"error": "..."} here, {"message": "...", "code": 17} there, an HTML page from the servlet container somewhere else. Problem Details for HTTP APIs is the IETF’s standard answer. It was first published as RFC 7807 in 2016 and replaced by RFC 9457 in 2023, which kept the same format and clarified a few points. Spring Framework 6 (Spring Boot 3) supports it natively through the ProblemDetail class.

Interviewers ask about it because error handling exposes how carefully someone designs an API. A good answer covers the format, how Spring produces it, and the two classic mistakes: clients parsing human-readable text, and servers leaking internals.

The Java here targets Spring Boot 3.x on Java 17+. It’s illustrative and wasn’t compiled for this article; JSON responses are shown in the shape Spring produces, with values from this example.

The format in one response

A problem details response is a JSON object served with the media type application/problem+json:

{
  "type": "https://api.example.com/problems/insufficient-stock",
  "title": "Insufficient stock",
  "status": 409,
  "detail": "Only 2 units of SKU KB-104 are available; 5 were requested.",
  "instance": "/orders",
  "sku": "KB-104",
  "available": 2
}
JSON

Each standard member has a job:

  • type is a URI that identifies the kind of problem. It’s the stable, machine-readable code. If it’s absent, it defaults to about:blank, which means “nothing beyond the status code”.
  • title is a short human summary of that type. It shouldn’t change from one occurrence to the next.
  • status repeats the HTTP status code, which helps when the body is logged or forwarded on its own.
  • detail explains this occurrence, for a human.
  • instance identifies this occurrence, often the request path or an error id.

Anything else, like sku and available above, is an extension member. Clients should ignore extensions they don’t understand.

The rule interviewers listen for: clients branch on type (and status), never on detail. The detail text can be reworded or translated tomorrow. If the mobile app checks detail.contains("available"), a copy edit breaks checkout.

Turning it on in Spring Boot

Out of the box, Spring Boot’s errors for exceptions Spring MVC itself throws (a missing parameter, malformed JSON, an unsupported media type) come from BasicErrorController in Boot’s own format: timestamp, status, error, path. One property switches those built-in exceptions to problem details:

spring.mvc.problemdetails.enabled=true
properties

Boot then registers a ResponseEntityExceptionHandler as a @ControllerAdvice. Every Spring MVC exception implements the ErrorResponse interface, which carries its status and a ProblemDetail body, so this one handler covers them all. Send POST /orders with a body that fails @Valid, and you get a 400 with Content-Type: application/problem+json, a title of Bad Request, and a generic detail. Spring fills instance with the request path if you don’t set it.

The property doesn’t touch your exceptions. An OrderNotFoundException thrown from a service still becomes a 500 until you map it. You have two options.

Make the exception carry its own response by extending ErrorResponseException:

public class OrderNotFoundException extends ErrorResponseException {
    public OrderNotFoundException(long orderId) {
        super(HttpStatus.NOT_FOUND, problem(orderId), null);
    }
    private static ProblemDetail problem(long orderId) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, "Order " + orderId + " does not exist.");
        pd.setType(URI.create("https://api.example.com/problems/order-not-found"));
        pd.setTitle("Order not found");
        pd.setProperty("orderId", orderId);
        return pd;
    }
}
java

That’s quick, but it puts HTTP into your domain layer. Or map exceptions centrally in your own advice, which keeps services free of web types:

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

    @ExceptionHandler(InsufficientStockException.class)
    ProblemDetail insufficientStock(InsufficientStockException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                "Only %d units of SKU %s are available; %d were requested."
                        .formatted(ex.available(), ex.sku(), ex.requested()));
        pd.setType(URI.create("https://api.example.com/problems/insufficient-stock"));
        pd.setTitle("Insufficient stock");
        pd.setProperty("sku", ex.sku());
        pd.setProperty("available", ex.available());
        return pd;
    }

    @ExceptionHandler(Exception.class)
    ProblemDetail unexpected(Exception ex, HttpServletRequest request) {
        String errorId = UUID.randomUUID().toString();
        log.error("Unhandled error {} on {}", errorId, request.getRequestURI(), ex);
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "An unexpected error occurred. Quote error id " + errorId + " to support.");
        pd.setProperty("errorId", errorId);
        return pd;
    }
}
java

Extending ResponseEntityExceptionHandler keeps Spring’s handling of its own exceptions, and you add methods for yours. If you extend it like this, you don’t need the Boot property; Boot only registers its own handler when you haven’t. Returning ProblemDetail from an @ExceptionHandler is enough: Spring takes the HTTP status from its status field. The setProperty values are rendered as top-level JSON members, as in the stock example above, thanks to a Jackson mixin Spring registers.

Validation errors deserve detail

The default response for a failed @Valid tells the client the request was invalid but not which field. Clients filling a form need the list. Override the hook in ResponseEntityExceptionHandler:

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex, HttpHeaders headers,
        HttpStatusCode status, WebRequest request) {
    ProblemDetail pd = ex.getBody();
    pd.setType(URI.create("https://api.example.com/problems/validation"));
    pd.setTitle("Request validation failed");
    pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
            .toList());
    return ResponseEntity.status(status).body(pd);
}
java

Now POST /orders with an empty customerEmail and a quantity of zero returns something like:

{
  "type": "https://api.example.com/problems/validation",
  "title": "Request validation failed",
  "status": 400,
  "detail": "Invalid request content.",
  "instance": "/orders",
  "errors": [
    { "field": "customerEmail", "message": "must not be blank" },
    { "field": "quantity", "message": "must be greater than or equal to 1" }
  ]
}
JSON

The errors array is an extension member, and documenting its shape is part of your API contract. The messages come from Bean Validation’s defaults; you can replace them through a MessageSource, which also handles translation. Spring’s ResponseEntityExceptionHandler resolves title and detail through message codes like problemDetail.title.<exception class name>, so you can customise built-in responses from a messages.properties file without code.

What not to put in an error

The catch-all handler above deliberately returns a generic message. The tempting version, pd.setDetail(ex.getMessage()), is a security and support problem. Exception messages are written for developers. A DataIntegrityViolationException message can include the SQL statement and a constraint name like uk_users_email, which tells an attacker your schema and confirms that an email is registered. A NullPointerException message names internal classes. Stack traces are worse.

So: log the full exception with an error id, return the id to the client, and map the exceptions clients can act on to specific types with written-for-clients details. Boot’s own defaults agree: server.error.include-message, include-binding-errors and include-stacktrace are never by default, and it’s a common mistake to set them to always in production while debugging and forget them.

Two smaller points round it out. Keep status and the real HTTP status identical; a 200 with a problem body confuses every proxy and client library. And make the type URIs resolve to a page documenting the problem, even a simple one, so a developer who sees insufficient-stock in a log can look it up.

Practice question: the brittle mobile client

A mobile app shows “Card declined, try another card” by checking whether the message field of your API’s error contains the word “declined”. A backend developer improves the wording to “The issuing bank refused the payment” and the app starts showing a generic error. How would you redesign the error contract so this can’t happen?

Answer and reasoning

The app is parsing human-readable text, which was never a contract. Move to problem details and give each failure the client must handle its own type URI: https://api.example.com/problems/card-declined, .../insufficient-funds, .../payment-provider-unavailable. The app maps those types to its own localised messages and keeps a fallback for unknown types. The server is then free to reword detail, which becomes purely informational, for logs and support.

Add extension members for anything the client needs to decide what to do, like a retryable: true flag on provider outages, or a declineCode from the processor if the UX distinguishes between declines. Document the types and their extensions alongside the endpoint, version them by adding new types rather than changing old ones, and add a contract test that asserts the type and status of each documented failure, so a refactor that changes them fails CI rather than the app. During migration, keep the old message field as an extension for one release so existing app versions keep working.

Where to go next

More in Spring Boot

read ✓Spring Boot · mid

Spring Boot REST Pagination

Paginate Spring Boot endpoints with Pageable, add a unique tie-breaker to every sort, and switch to keyset scrolling when OFFSET gets slow.

~6 min readread →
read ✓Spring Boot · mid

Spring Bean Validation Groups

Validate create and update requests differently with groups, avoid silently skipping Default constraints, and order checks with @GroupSequence.

~6 min readread →
esc