Spring Boot Exception Handling: A Production-Grade Guide
Every Spring Boot REST API starts the same way: a few controllers, a happy path, and exceptions handled wherever they happen to blow up. Then production hits. A NullPointerException leaks a stack trace to the client. A validation error returns a 500 instead of a 422. The downstream mobile team files three bugs in one sprint, all about inconsistent error formats.
I’ve seen this pattern repeat across banking compliance platforms, logistics APIs, and healthcare systems. The fix isn’t complicated — it’s just never prioritized early enough.
This guide walks through the production-grade exception handling strategy I use on every Spring Boot project.
The Problem with Default Spring Boot Error Handling
Out of the box, Spring Boot returns error responses through its BasicErrorController. The response looks something like this:
{
"timestamp": "2026-09-25T10:15:30.123+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/api/users/123"
}
This is problematic for three reasons:
- Inconsistent format. Validation errors, security errors, and business errors all return different shapes.
- Information leakage. Stack traces and internal class names leak to clients in non-production profiles.
- No error codes. The consuming team has no machine-readable way to differentiate between “user not found” and “user account locked.”
Step 1: Define a Standard Error Response
Before writing any exception handling code, agree on a single response envelope:
public record ApiError(
String code,
String message,
int status,
Instant timestamp,
String path,
List<FieldError> fieldErrors
) {
public record FieldError(String field, String message) {}
public static ApiError of(String code, String message, int status, String path) {
return new ApiError(code, message, status, Instant.now(), path, List.of());
}
public static ApiError withFieldErrors(String code, String message, int status,
String path, List<FieldError> fieldErrors) {
return new ApiError(code, message, status, Instant.now(), path, fieldErrors);
}
}
Every error — whether it’s a 400, 401, 403, 404, 422, or 500 — returns this exact shape. The consuming team never has to guess.
Step 2: Global Exception Handler with @ControllerAdvice
The core of the strategy is a single @RestControllerAdvice class that catches every exception type and maps it to the standard ApiError:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// Business logic errors — 404, 409, etc.
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(
ResourceNotFoundException ex, HttpServletRequest request) {
log.warn("Resource not found: {}", ex.getMessage());
return ResponseEntity.status(404)
.body(ApiError.of("RESOURCE_NOT_FOUND", ex.getMessage(),
404, request.getRequestURI()));
}
// Bean Validation errors — @Valid failures
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest request) {
List<ApiError.FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.map(fe -> new ApiError.FieldError(fe.getField(), fe.getDefaultMessage()))
.toList();
return ResponseEntity.status(422)
.body(ApiError.withFieldErrors("VALIDATION_FAILED",
"Request validation failed", 422,
request.getRequestURI(), fieldErrors));
}
// Security — access denied
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> handleAccessDenied(
AccessDeniedException ex, HttpServletRequest request) {
log.warn("Access denied on {}: {}", request.getRequestURI(), ex.getMessage());
return ResponseEntity.status(403)
.body(ApiError.of("ACCESS_DENIED", "You do not have permission",
403, request.getRequestURI()));
}
// Catch-all — never leak stack traces
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnexpected(
Exception ex, HttpServletRequest request) {
log.error("Unexpected error on {}", request.getRequestURI(), ex);
return ResponseEntity.status(500)
.body(ApiError.of("INTERNAL_ERROR",
"An unexpected error occurred. Please try again later.",
500, request.getRequestURI()));
}
}
The catch-all at the bottom is critical. In banking and healthcare systems, leaking an internal stack trace isn’t just bad UX — it’s a security vulnerability flagged by OWASP and tools like Checkmarx.
Step 3: Custom Business Exceptions with Error Codes
Define a base exception that carries a machine-readable error code:
public abstract class BusinessException extends RuntimeException {
private final String errorCode;
private final int httpStatus;
protected BusinessException(String errorCode, String message, int httpStatus) {
super(message);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
}
// getters
}
// Specific exceptions
public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String resource, Object id) {
super("RESOURCE_NOT_FOUND",
String.format("%s with id '%s' not found", resource, id), 404);
}
}
public class DuplicateResourceException extends BusinessException {
public DuplicateResourceException(String resource, String field, Object value) {
super("DUPLICATE_RESOURCE",
String.format("%s with %s '%s' already exists", resource, field, value),
409);
}
}
Then simplify your @ControllerAdvice to handle the entire hierarchy:
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiError> handleBusiness(
BusinessException ex, HttpServletRequest request) {
log.warn("Business error [{}]: {}", ex.getErrorCode(), ex.getMessage());
return ResponseEntity.status(ex.getHttpStatus())
.body(ApiError.of(ex.getErrorCode(), ex.getMessage(),
ex.getHttpStatus(), request.getRequestURI()));
}
Step 4: Handle Spring Security Exceptions Correctly
Spring Security exceptions (AuthenticationException, AccessDeniedException) are thrown before reaching your controllers. A @ControllerAdvice will not catch them by default.
You need to configure custom entry points:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(401);
new ObjectMapper().writeValue(response.getOutputStream(),
ApiError.of("UNAUTHORIZED", "Authentication required",
401, request.getRequestURI()));
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(403);
new ObjectMapper().writeValue(response.getOutputStream(),
ApiError.of("ACCESS_DENIED", "Insufficient permissions",
403, request.getRequestURI()));
})
)
// ... other configuration
.build();
}
This is the pattern I’ve used on every banking and compliance API. Without it, Spring Security returns HTML login pages or opaque 403 responses that break every API client.
What This Gives You
After implementing this pattern:
- Every error response has the same JSON shape. Mobile, frontend, and third-party teams integrate once and never revisit.
- No stack traces leak to clients. OWASP and Checkmarx scans pass clean.
- Machine-readable error codes (
RESOURCE_NOT_FOUND,VALIDATION_FAILED) allow the consuming team to build proper error handling on their side. - Validation errors return field-level detail so forms can highlight exactly what’s wrong.
This isn’t clever. It’s boring, predictable infrastructure code — and that’s exactly what production systems need.