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.
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
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
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
@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:
{
"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
| Situation | Status | Notes |
|---|---|---|
| Unparseable JSON, wrong content type | 400 | HttpMessageNotReadableException |
| Missing required query parameter | 400 | MissingServletRequestParameterException |
| Not authenticated | 401 | Include WWW-Authenticate |
| Authenticated but not permitted | 403 | Do not reveal whether the resource exists |
| Resource does not exist | 404 | |
| Wrong HTTP method | 405 | Include Allow |
| Version conflict, duplicate key | 409 | |
| Parsed fine, values invalid | 422 | Field-level errors here |
| Rate limited | 429 | Include Retry-After |
| Dependency unavailable | 503 | Include 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
@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:
@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?
Why does my @ExceptionHandler not fire?
How much detail should an error response contain?
Related tutorials
- Spring Boot Testing MasterclassA test strategy that stays fast: when to use @SpringBootTest versus a slice, real databases with Testcontainers and @ServiceConnection, stubbing HTTP with WireMock, and context caching.
- Caching Strategies in Spring BootSpring cache abstraction in practice: @Cacheable key design, choosing between Caffeine and Redis, per-cache TTLs, cache stampedes, and a two-level cache that survives a Redis outage.
- Actuator & Observability EndpointsEvery Actuator endpoint worth exposing, writing custom health indicators for Kubernetes probes, adding Micrometer metrics that answer real questions, and securing it all.
- Scheduling & Async ProcessingScheduled tasks and async methods done properly: fixedRate versus fixedDelay, sizing executors, exception handling that does not silently swallow, and distributed locking with ShedLock.