02-web-rest-apis

Error Responses: @ControllerAdvice and RFC 9457 ProblemDetail

Map domain exceptions to status codes in one place, and return a standard problem format instead of a per-project error shape nobody documented.

October 8, 2026
spring-bootexception-handlingControllerAdviceProblemDetailRFC-9457rest-apierror-handling

The Error Contract Is Part of the API

Most API design attention goes to the happy path. Then a client integrates, hits a failure, and discovers that errors arrive in four different shapes depending on which layer broke — a validation failure with a field map, a not-found with a bare message, a database constraint as a 500 with an HTML body, and one endpoint returning 200 with {"success": false}.

Clients have to handle every one of those. The error contract is as much a part of your API as the success contract, and it only becomes consistent if it's defined in one place.

That place is @ControllerAdvice.

What You Get Without Doing Anything

Spring Boot's default is BasicErrorController, which produces:

json
{
  "timestamp": "2026-10-08T09:14:22.116+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/api/orders/42"
}

Note what's absent: any indication of what went wrong. The message and trace fields are omitted because server.error.include-message and include-stacktrace default to never.

That default is correct and you should leave it alone. Stack traces in responses leak class names, library versions and internal paths — a genuine reconnaissance aid. The right move is not to loosen it in production but to replace the whole mechanism with deliberate, safe error responses.

🚨

You will find server.error.include-stacktrace: always recommended widely as a debugging fix. Do not ship it. Set include-message: always in a dev profile if you like — stack traces belong in your logs, correlated to a request ID, never in an HTTP response.

ProblemDetail: A Format Already Defined

Before inventing an error shape, note that one is standardised. RFC 9457, Problem Details for HTTP APIs — which obsoleted RFC 7807 in July 2023 — defines a small JSON object:

json
{
  "type": "https://api.example.com/problems/insufficient-stock",
  "title": "Insufficient stock",
  "status": 409,
  "detail": "Only 3 units of ABC-123456 remain; 10 were requested",
  "instance": "/api/orders"
}
FieldMeaning
typeA URI identifying the problem kind. The stable field clients branch on
titleShort human-readable summary, constant per type
statusThe HTTP status code, repeated in the body
detailHuman-readable explanation of this occurrence
instanceA URI for this specific occurrence

Plus any extension members you add. The content type is application/problem+json.

Spring Framework 6 added ProblemDetail as a first-class type, so this needs no library:

java
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.CONFLICT, "Only 3 units of ABC-123456 remain; 10 were requested");
problem.setType(URI.create("https://api.example.com/problems/insufficient-stock"));
problem.setTitle("Insufficient stock");
problem.setProperty("sku", "ABC-123456");
problem.setProperty("available", 3);

Why prefer it to a hand-rolled shape: clients may already understand it, the field semantics are documented so you needn't explain them, type gives a machine-readable discriminator that status alone can't (three different 409s are distinguishable), and setProperty extends it without breaking the base contract.

💡

Much existing material, including the validation and exception handling chapter in the backend engineer track, refers to this as RFC 7807. Same format, same Spring API — 9457 is the current document and clarified a few ambiguities. If you see 7807 cited, it isn't wrong so much as superseded.

Centralising With @RestControllerAdvice

@ControllerAdvice is a @Component whose @ExceptionHandler methods apply across controllers. @RestControllerAdvice is the same plus @ResponseBody — the one you want for an API.

java
@RestControllerAdvice
public class ApiExceptionHandler {
 
    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
    private static final URI BASE = URI.create("https://api.example.com/problems/");
 
    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail onOrderNotFound(OrderNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, "No order exists with id " + ex.getOrderId());
        problem.setType(BASE.resolve("order-not-found"));
        problem.setTitle("Order not found");
        problem.setProperty("orderId", ex.getOrderId());
        return problem;
    }
 
    @ExceptionHandler(InsufficientStockException.class)
    ProblemDetail onInsufficientStock(InsufficientStockException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.CONFLICT,
                "Only %d units of %s remain; %d were requested"
                        .formatted(ex.getAvailable(), ex.getSku(), ex.getRequested()));
        problem.setType(BASE.resolve("insufficient-stock"));
        problem.setTitle("Insufficient stock");
        problem.setProperty("sku", ex.getSku());
        problem.setProperty("available", ex.getAvailable());
        return problem;
    }
 
    @ExceptionHandler(Exception.class)
    ProblemDetail onUnexpected(Exception ex) {
        String reference = UUID.randomUUID().toString();
        log.error("Unhandled exception [ref={}]", reference, ex);   // full detail to logs
 
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "An unexpected error occurred. Quote reference " + reference + " to support.");
        problem.setType(BASE.resolve("internal-error"));
        problem.setTitle("Internal server error");
        problem.setProperty("reference", reference);
        return problem;
    }
}

That last handler is the pattern worth copying. The client gets a stable, safe message plus a reference; the log gets the stack trace keyed by the same reference. Support becomes "quote the reference" instead of "describe what you saw", and nothing internal leaks.

⚠️

A catch-all @ExceptionHandler(Exception.class) makes a logging mistake expensive: forget to log, and you have silently converted every unexpected failure into a tidy 500 with no diagnostic trail anywhere. Log at error with the exception as the last argument — log.error("msg [ref={}]", ref, ex) — so SLF4J treats it as a throwable and prints the stack trace rather than calling toString() on it.

Validation failures, properly rendered

The two exceptions from the previous guide, turned into field-level detail:

java
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail onInvalidBody(MethodArgumentNotValidException ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setType(BASE.resolve("validation-failed"));
    problem.setTitle("Validation failed");
    problem.setDetail("The request contains %d invalid field(s)"
            .formatted(ex.getBindingResult().getErrorCount()));
    problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of(
                    "field", fe.getField(),
                    "message", Objects.requireNonNullElse(fe.getDefaultMessage(), "invalid")))
            .toList());
    return problem;
}
 
@ExceptionHandler(ConstraintViolationException.class)
ProblemDetail onConstraintViolation(ConstraintViolationException ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setType(BASE.resolve("validation-failed"));
    problem.setTitle("Validation failed");
    problem.setProperty("errors", ex.getConstraintViolations().stream()
            .map(v -> Map.of(
                    "field", v.getPropertyPath().toString(),
                    "message", v.getMessage()))
            .toList());
    return problem;
}

The second one is not optional — without it, a @Validated service rejecting bad input returns 500, blaming your server for the caller's mistake.

✅

Include field errors as a list of objects rather than a field → message map. A single field can violate two constraints, and a map silently drops one of them.

How a Handler Gets Chosen

Three rules govern selection:

Controller-local beats advice. A handler inside the controller wins over a global one — occasionally useful, mostly a source of surprise. Prefer global.

The most specific exception type wins. With handlers for both Exception and OrderNotFoundException, the latter handles its own type. So a catch-all does not shadow your specific handlers, and you can add them incrementally.

Advice ordering is explicit. Multiple @ControllerAdvice beans are consulted in @Order sequence. Put your catch-all advice last:

java
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class FallbackExceptionHandler { }

You can also scope an advice to part of the application — handy when a public API and an internal one need different error formats:

java
@RestControllerAdvice(basePackages = "com.example.shop.publicapi")
@RestControllerAdvice(assignableTypes = { OrderController.class })
@RestControllerAdvice(annotations = { PublicApi.class })

Check yourself

An advice class has handlers for both Exception and OrderNotFoundException. A controller throws OrderNotFoundException. Which runs?

Extending ResponseEntityExceptionHandler

Spring provides ResponseEntityExceptionHandler, an abstract advice base with handlers for every standard Spring MVC exception already written. Extend it and you inherit consistent ProblemDetail responses for 405s, 415s, unreadable bodies and the rest:

java
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
 
    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail onOrderNotFound(OrderNotFoundException ex) { }
 
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        // override just this one to add field-level errors
    }
}

Worth it when you want every framework error in problem format without writing each handler. The trade-off is less obvious control flow — overriding protected hooks with four-parameter signatures, rather than reading a flat list of @ExceptionHandler methods. For a small API, explicit handlers are easier to follow; for a large one, inheriting the standard set pays off.

There is also a property that gets you part of the way with no code at all:

yaml
spring:
  mvc:
    problemdetails:
      enabled: true

This makes Spring MVC return ProblemDetail bodies for its own exceptions. It does nothing for your domain exceptions — those still need handlers — but it removes the inconsistency where framework errors look different from yours.

Designing the Exceptions Themselves

Handlers are only as good as what they're given. An exception carrying a pre-formatted message is hard to render differently; one carrying data is easy:

java
public class InsufficientStockException extends RuntimeException {
 
    private final String sku;
    private final int requested;
    private final int available;
 
    public InsufficientStockException(String sku, int requested, int available) {
        super("Insufficient stock for %s: requested %d, available %d"
                .formatted(sku, requested, available));
        this.sku = sku;
        this.requested = requested;
        this.available = available;
    }
 
    public String getSku() { return sku; }
    public int getRequested() { return requested; }
    public int getAvailable() { return available; }
}

Three guidelines:

Extend RuntimeException. Checked exceptions force throws declarations through every layer for no benefit here, and — importantly — Spring's @Transactional rolls back on unchecked exceptions by default, not checked ones. A checked domain exception will commit the transaction unless you configure rollbackFor.

Carry structured fields, not just a string. That's what lets the handler build extension members.

Name for the domain, not the status. OrderNotFoundException, not NotFound404Exception. The HTTP mapping is a presentation decision belonging in the advice; the same exception might be a 404 over REST and something else over a message queue.

A status code mapping worth agreeing on

SituationStatusWhy
Malformed syntax, failed validation400The request itself is wrong
Not authenticated401Credentials missing or invalid
Authenticated, not permitted403Identity known, access denied
Resource doesn't exist404
Wrong verb for the path405Set by the framework
Conflicts with current state409Duplicate, version conflict, stock
Syntactically valid, semantically impossible422Optional; many APIs use 400
Rate limited429Include Retry-After
Unexpected server fault500Your bug, not the caller's
Downstream dependency unavailable503Include Retry-After

The 4xx/5xx split is the one to get right: 4xx means the caller can fix it; 5xx means they can't. Returning 500 for bad input tells clients to retry something that will never succeed, and it pollutes your error-rate alerting with other people's mistakes.

⚠️

Spring Security's authentication and authorisation failures are raised in a filter, before DispatcherServlet — so a @ControllerAdvice never sees them. 401s and 403s are configured on the security filter chain via AuthenticationEntryPoint and AccessDeniedHandler. Expect to do that work separately to make security errors match your problem format.

Check yourself

A POST creating a resource hits a unique-constraint violation in the database. Spring surfaces DataIntegrityViolationException with no handler for it. What does the client receive, and what should it be?

Don't Forget the Logs

A handler converts an exception into a response. It should also decide what gets recorded, and the levels differ by cause:

java
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail onOrderNotFound(OrderNotFoundException ex) {
    log.debug("Order not found: {}", ex.getOrderId());    // expected; not an incident
}
 
@ExceptionHandler(Exception.class)
ProblemDetail onUnexpected(Exception ex) {
    log.error("Unhandled exception [ref={}]", reference, ex);   // always, with the trace
}

Logging every 404 at error makes error-rate dashboards meaningless — a client requesting a deleted order is normal traffic. The rule of thumb: 4xx is information, 5xx is an incident. Log 4xx at debug or info, 5xx at error with the full stack trace.

The Mental Model, Restated

  1. The error contract is part of the API. Define it once, in a @RestControllerAdvice.
  2. Use ProblemDetail (RFC 9457) rather than inventing a shape — type is the field clients branch on.
  3. The most specific handler wins, so a catch-all is safe and specific handlers can be added incrementally.
  4. Never return stack traces. Log them with a reference and give the client the reference.
  5. Design exceptions to carry data, extend RuntimeException, and name them for the domain.
  6. 4xx the caller can fix, 5xx they can't — and log them at different levels accordingly.

What's Next

Both success and error responses now pass through the message converters, and so far Jackson's defaults have been doing that work unexamined. The next guide covers the serialisation layer directly: content negotiation, the Jackson annotations that control JSON shape, custom serialisers, polymorphic types, and the configuration seams worth preferring over replacing the ObjectMapper.