Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Exception Handling & Error Response Design

A consistent error contract for a Spring Boot API: an exception hierarchy worth having, @ControllerAdvice done properly, RFC 7807 ProblemDetail, and validation errors clients can act on.

Beginner7 min readUpdated
On this page

Error handling is the part of an API that gets designed last and read most. A client integrating against your service will spend more time on the failure paths than the happy one, and an inconsistent error contract makes that work miserable. Spring 6 gives you a standard to build on.

Key Takeaways

  • Adopt RFC 7807 ProblemDetail — a standard shape beats a bespoke one nobody documents.
  • Build a small exception hierarchy that maps to HTTP status, then map it once in one place.
  • Validation failures should name the field and the rejected value.
  • Return a correlation id on every error and log the detail against it.
  • Never leak stack traces, SQL or internal class names to a client.

Where exceptions get handled

@ControllerAdvice only sees exceptions raised inside the dispatcher. Filter-level failures need their own handling.

That right-hand branch matters. Authentication failures, CORS rejections and malformed-token errors are thrown by filters, before the dispatcher runs, so they never reach your advice. Spring Security has its own AuthenticationEntryPoint and AccessDeniedHandler for exactly this, and if you want a consistent error shape you must configure them too.

An exception hierarchy worth having

ApiException.java
public abstract class ApiException extends RuntimeException {
 
    private final HttpStatus status;
    private final String type;
 
    protected ApiException(HttpStatus status, String type, String message) {
        super(message);
        this.status = status;
        this.type = type;
    }
 
    public HttpStatus status() { return status; }
    public String type()       { return type; }
}
 
public class ResourceNotFoundException extends ApiException {
    public ResourceNotFoundException(String resource, Object id) {
        super(HttpStatus.NOT_FOUND, "resource-not-found",
              "%s '%s' does not exist".formatted(resource, id));
    }
}
 
public class BusinessRuleException extends ApiException {
    public BusinessRuleException(String rule, String message) {
        super(HttpStatus.UNPROCESSABLE_ENTITY, rule, message);
    }
}
 
public class ConflictException extends ApiException {
    public ConflictException(String message) {
        super(HttpStatus.CONFLICT, "conflict", message);
    }
}

Carrying the status on the exception means the advice does not need a switch over exception types that someone will forget to extend. New exception, correct status, no edit to the handler.

The global handler

GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
 
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    private static final URI BASE = URI.create("https://api.acme.com/errors/");
 
    @ExceptionHandler(ApiException.class)
    public ProblemDetail handleApi(ApiException ex, HttpServletRequest request) {
        var problem = ProblemDetail.forStatusAndDetail(ex.status(), ex.getMessage());
        problem.setType(BASE.resolve(ex.type()));
        problem.setTitle(titleFor(ex.status()));
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("correlationId", MDC.get("correlationId"));
        problem.setProperty("timestamp", Instant.now().toString());
        return problem;
    }
 
    // Bean-validation failures on @RequestBody. Overriding the base-class hook
    // keeps the shape identical to every other error we return.
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
 
        var problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.UNPROCESSABLE_ENTITY, "The request body failed validation");
        problem.setType(BASE.resolve("validation-failed"));
        problem.setTitle("Validation failed");
        problem.setProperty("correlationId", MDC.get("correlationId"));
        problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
                .map(fe -> Map.of(
                        "field", fe.getField(),
                        "message", Objects.toString(fe.getDefaultMessage(), "invalid"),
                        "rejectedValue", String.valueOf(fe.getRejectedValue())))
                .toList());
 
        return ResponseEntity.unprocessableEntity().body(problem);
    }
 
    // The catch-all. Log everything, return nothing useful to an attacker.
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
        String correlationId = MDC.get("correlationId");
        log.error("unhandled exception [{}] on {} {}",
                  correlationId, request.getMethod(), request.getRequestURI(), ex);
 
        var problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "An unexpected error occurred. Quote the correlation id when contacting support.");
        problem.setType(BASE.resolve("internal-error"));
        problem.setTitle("Internal server error");
        problem.setProperty("correlationId", correlationId);
        return problem;
    }
}

The resulting body is machine-readable and stable:

422 Unprocessable Entity
{
  "type": "https://api.acme.com/errors/validation-failed",
  "title": "Validation failed",
  "status": 422,
  "detail": "The request body failed validation",
  "instance": "/api/v1/orders",
  "correlationId": "b7f3a1c2-9e04-4d1a-8f52-1d0c9a7e5b31",
  "errors": [
    { "field": "quantity", "message": "must be greater than 0", "rejectedValue": "-3" }
  ]
}

Why a standard shape is worth the effort

Most teams eventually invent an error envelope. It usually starts as a message string, grows a code, then a details map, and ends up documented in a wiki page that drifts out of date. RFC 7807 short-circuits that whole evolution by specifying the fields in advance: a type URI identifying the error class, a human-readable title, the numeric status, a detail explaining this specific occurrence, and an instance URI pointing at the request that failed. Any field you need beyond those goes in as an extension property.

The payoff is not aesthetic. Because the media type is application/problem+json, generic HTTP clients, API gateways and observability tools recognise the payload without configuration. Client generators can produce a typed error class. And when a second team builds a second service, they do not have to negotiate a new envelope — they use the same one, and consumers of both services write the error-handling code once.

The one field that repays attention is type. Treat it as a stable identifier that clients may branch on, in the way they branch on status codes. Once published, changing it is a breaking change, so choose names that describe the cause rather than the current implementation: insufficient-funds will outlive payment-service-error-42.

Distinguishing client mistakes from server faults

The most useful question when mapping an exception is: could the caller have avoided this by sending a different request? If yes, it belongs in the 4xx range and the response should say precisely what to change. If no — a database is unreachable, a downstream service is timing out, a bug threw a NullPointerException — it is a 5xx, and the response should say nothing specific at all beyond a correlation id.

That split also decides what gets logged and at what level. A 4xx is routine traffic; logging it at ERROR turns your alerting into noise the first time a client sends a malformed request in a loop. Log 4xx at DEBUG or INFO with enough context to answer questions, and reserve ERROR for genuine faults where somebody needs to look. A useful heuristic: if an alert fires and the correct response is "the client should fix their request", the log level was wrong.

There is a third category worth handling explicitly — failures that are neither the client's fault nor a bug, such as an optimistic-locking conflict or a rate limit. These deserve their own status codes (409 and 429) and, crucially, a hint about what to do next. A 429 without a Retry-After header tells a client it failed but not when to try again, so it will guess, and it will guess badly.

Status codes that mean something

SituationStatusNotes
Unparseable JSON, wrong content type400HttpMessageNotReadableException
Missing required query parameter400MissingServletRequestParameterException
Not authenticated401Include WWW-Authenticate
Authenticated but not permitted403Do not reveal whether the resource exists
Resource does not exist404
Wrong HTTP method405Include Allow
Version conflict, duplicate key409
Parsed fine, values invalid422Field-level errors here
Rate limited429Include Retry-After
Dependency unavailable503Include Retry-After when known

A 403 that says "order 4711 belongs to another customer" confirms that order 4711 exists. For resources whose existence is itself sensitive, return 404 for both cases.

Correlation ids

CorrelationIdFilter.java
@Component
public class CorrelationIdFilter extends OncePerRequestFilter {
 
    private static final String HEADER = "X-Correlation-Id";
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String id = Optional.ofNullable(request.getHeader(HEADER))
                            .filter(s -> !s.isBlank())
                            .orElseGet(() -> UUID.randomUUID().toString());
        MDC.put("correlationId", id);
        response.setHeader(HEADER, id);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();   // the thread goes back to a pool — always clear
        }
    }
}

Now a user reporting "it failed with id b7f3a1c2" gives support an exact log lookup, and the response itself never had to contain a stack trace.

Documenting the errors

Every type URI should resolve to a page explaining what causes that error and how to fix it. It costs an afternoon and removes a recurring category of support ticket. Declare the responses in OpenAPI too, so generated clients know the shape:

OrderController.java
@Operation(summary = "Fetch an order")
@ApiResponses({
    @ApiResponse(responseCode = "200", description = "Found"),
    @ApiResponse(responseCode = "404", description = "No such order",
        content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
})
@GetMapping("/{id}")
public OrderResponse get(@PathVariable String id) { }

What to take away

One exception hierarchy, one advice, one response shape, everywhere. Use ProblemDetail so the shape is a published standard rather than local folklore, put a correlation id in every error, and keep internals out of the payload.

Frequently Asked Questions

Should I return 400 or 422 for validation failures?
Use 400 when the request itself is malformed — unparseable JSON, a missing required parameter, a wrong content type. Use 422 when the request parsed correctly but the values violate business or field rules. The distinction tells a client whether to fix its serialisation or fix the data.
Why does my @ExceptionHandler not fire?
Three usual reasons. The exception was thrown from a filter, which runs outside the DispatcherServlet and so outside @ControllerAdvice. It was already caught and wrapped somewhere in the call chain. Or a more specific handler for a supertype matched first — Spring picks the closest match in the hierarchy.
How much detail should an error response contain?
Enough for a client developer to fix the call, never enough to help an attacker. Field names and validation messages are fine. Stack traces, SQL fragments, internal hostnames and class names are not. Log the detail with a correlation id and return the id.

Related tutorials