Error handling

@ControllerAdvice, RFC 9457 Problem Details, mapping exceptions to responses, and never leaking a stack trace or an entity name.

5 min read🔗 REST API Engineering

An error response is part of your API, and it is the part clients write the most code against — because the happy path is one shape and the failures are many. If each failure has its own shape, every client grows a pile of special cases with your service's name on it.

This lesson is one handler, one shape, everywhere.

What Spring gives you, and why it is not enough

The previous lesson ended here. Three fields failed validation:

plaintext
$ curl -i -X POST /customers -d '{"name":"","email":"not-an-email","age":12}'
HTTP/1.1 400
Content-Type: application/json
 
{"timestamp":"2026-09-12T10:29:11.016+00:00","status":400,"error":"Bad Request","path":"/customers"}

The status is right. The body tells the client nothing it did not already know. A form cannot show a field error; a log cannot say what was rejected.

And an unhandled exception is worse, in the other direction:

plaintext
$ curl -i /boom
HTTP/1.1 500
 
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/boom"}

There is no way for the caller to say what went wrong to a support engineer, and no way for the support engineer to find it. In a default Spring Boot setup with include-stacktrace turned up, the opposite problem appears — the client receives your class names, your package structure and your library versions.

One @RestControllerAdvice

Adding a single class changes both, for every endpoint, with no change to any controller:

Problems.javajava
@RestControllerAdvice
public class Problems {
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> invalid(MethodArgumentNotValidException e) {
        List<Map<String, String>> errors = new ArrayList<>();
        e.getBindingResult().getFieldErrors().forEach(f -> {
            Map<String, String> m = new LinkedHashMap<>();
            m.put("field", f.getField());
            m.put("message", f.getDefaultMessage());
            errors.add(m);
        });
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("type", "https://api.example.com/problems/validation-failed");
        body.put("title", "Validation failed");
        body.put("status", 422);
        body.put("detail", errors.size() + " field(s) were rejected");
        body.put("instance", "/customers");
        body.put("errors", errors);
        return ResponseEntity.status(422)
            .contentType(MediaType.parseMediaType("application/problem+json"))
            .body(body);
    }
}

The same request, unchanged:

plaintext
HTTP/1.1 422
Content-Type: application/problem+json
 
{"type":"https://api.example.com/problems/validation-failed",
 "title":"Validation failed",
 "status":422,
 "detail":"3 field(s) were rejected",
 "instance":"/customers",
 "errors":[{"field":"name","message":"name is required"},
           {"field":"email","message":"must be a well-formed email address"},
           {"field":"age","message":"must be 18 or older"}]}

Every rejected field, named, with the message the constraint declared. A front end can highlight three inputs from that without knowing anything about your service.

Problem Details is the shape, and it already exists

That body is not invented. RFC 9457 Problem Details for HTTP APIs defines exactly these fields, and using it means clients that already understand the format need no special casing:

FieldWhat goes in it
typea URI identifying the kind of problem — the stable thing to branch on
titlea short human-readable summary of the type
statusthe HTTP status, repeated so the body is self-contained
detailwhat went wrong this time
instancewhich request it was

Plus any extra members you need — errors above is one. The media type is application/problem+json, and sending it is how a client knows to expect this shape rather than your normal response.

The field to design carefully is type. It is the machine-readable one: clients branch on it, so it must stay stable even when you reword title and detail. A client matching on the text of detail is a client you will break with a typo fix.

What never leaves the server

The second handler is the one that protects you:

java
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> unexpected(Exception e) {
    String correlationId = UUID.randomUUID().toString();
    log.error("{} unhandled", correlationId, e);            // the detail goes HERE
    Map<String, Object> body = new LinkedHashMap<>();
    body.put("type", "about:blank");
    body.put("title", "Internal Server Error");
    body.put("status", 500);
    body.put("detail", "The request could not be completed. Quote this id if you contact support.");
    body.put("correlationId", correlationId);
    return ResponseEntity.status(500)
        .contentType(MediaType.parseMediaType("application/problem+json"))
        .body(body);
}

The client gets this:

plaintext
HTTP/1.1 500
Content-Type: application/problem+json
 
{"type":"about:blank","title":"Internal Server Error","status":500,
 "detail":"The request could not be completed. Quote this id if you contact support.",
 "correlationId":"9cc8a9d9-6fee-481a-bc56-74506683f44e"}

And the log gets this:

plaintext
### logged: 9cc8a9d9-6fee-481a-bc56-74506683f44e java.lang.IllegalStateException: the database is on fire

Same id, both sides. A user quotes eight characters and an engineer finds the exact request. Nothing about your internals crossed the wire.

The list of what must never appear in an error body: stack traces, exception class names, SQL, table or column names, file paths, library versions, another user's data, and the reason an authentication attempt failed. That last one is worth spelling out — "no such user" and "wrong password" are different answers, and returning different ones tells an attacker which emails are registered.

Mapping the exception hierarchy

Domain exceptions get their own handlers, so the controller never decides a status code:

java
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<...> notFound(OrderNotFoundException e) { ... }   // 404
 
@ExceptionHandler(InsufficientStockException.class)
public ResponseEntity<...> conflict(InsufficientStockException e) { ... } // 409

Two rules keep this from sprawling. Handle the most specific type you can — Spring picks the closest match, so a handler for Exception is the net beneath the others and not a replacement for them. And do not let a persistence exception escape: DataIntegrityViolationException reaching the generic handler produces a 500 for what is usually a 409 with a perfectly good explanation, and its message contains your constraint names.

Remember too that the previous lesson left two paths into the same wall: a rejected body throws MethodArgumentNotValidException, a rejected parameter throws ConstraintViolationException. Both need a handler, and both should produce the same shape — a client should not be able to tell from the response which mechanism rejected it.

Progress is saved on this device and to your account when signed in.