Spring Boot Exception Handling: @ControllerAdvice, @ExceptionHandler, ErrorAttributes
Master Spring Boot exception handling with @ControllerAdvice, @ExceptionHandler, ErrorAttributes, and security best practices for production APIs.
Master Spring Boot exception handling with @ControllerAdvice, @ExceptionHandler, ErrorAttributes, and security best practices for production APIs. The guide uses practical examples to explain why exception handling matters, the exception handling flow and shows how to apply the ideas in a Spring Boot project. It closes with common pitfalls and production checks so you can apply the pattern with fewer surprises.
Spring Boot Exception Handling: @ControllerAdvice, @ExceptionHandler, ErrorAttributes
Every REST API crashes eventually. A database connection drops, a validation check fails, an unexpected null pointer bubbles up. What separates professional APIs from amateur ones is not whether they fail, but how they handle failure. Spring Boot gives you a powerful toolkit for exception handling, and in this post, I will walk you through every piece of it.
Why Exception Handling Matters
Introduction
Exception handling in Spring Boot turns failures into consistent, useful HTTP responses instead of leaking implementation details or leaving clients to interpret arbitrary errors. This guide covers controller-level and global handlers, response formats, validation failures, exception mapping, logging, and the trade-offs between local recovery and centralized handling.
The Exception Handling Flow
Understanding how Spring Boot processes exceptions is essential before diving into the specific annotations.
graph TD
A[HTTP Request] --> B{Controller Method}
B -->|Success| C[Return Response]
B -->|Throws Exception| D[DispatcherServlet]
D --> E["@ExceptionHandler Methods"]
E --> F["@ControllerAdvice Global Handler"]
F --> G{"Exception Type Match?"}
G -->|Yes| H[Handle Exception]
G -->|No| I[Base Exception Handlers]
H --> J[ErrorAttributes Applied]
I --> J
J --> K[HTTP Response]
C --> K
When an exception is thrown inside a controller, Spring’s DispatcherServlet catches it and searches for a matching @ExceptionHandler method. If the controller does not define one, the search expands to any @ControllerAdvice bean in the application context. The matched handler processes the exception, optionally modifies the response using ErrorAttributes, and returns the final HTTP response.
@ExceptionHandler: Per-Controller Control
The @ExceptionHandler annotation lets you define exception handling logic directly inside a controller. This works well for controller-specific error responses.
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
"USER_NOT_FOUND",
ex.getMessage(),
HttpStatus.NOT_FOUND.value()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}
The method signature of @ExceptionHandler is flexible. Spring automatically injects the thrown exception, the HTTP request, and even the WebRequest if you declare them as parameters.
When to Use @ExceptionHandler
- Controller-specific error responses that do not apply elsewhere
- Keeping error handling close to the code that produces the errors
- Quick prototyping before extracting to a global handler
When NOT to Use @ExceptionHandler
- Business exceptions that multiple controllers need to handle the same way
- When you find yourself copying the same handler method across controllers
- For exceptions that should be handled uniformly across the entire API
Copying handler methods across controllers is a maintenance nightmare. If you need to change the error format in one place, you have to change it everywhere.
@ControllerAdvice: Global Exception Handling
The @ControllerAdvice annotation transforms any bean into a global exception handler. All @ExceptionHandler methods in that class become applicable to every controller in the application.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ApiError> handleUserNotFound(UserNotFoundException ex) {
ApiError error = new ApiError(
"USER_NOT_FOUND",
ex.getMessage(),
Instant.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
List<String> details = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.toList();
ApiError error = new ApiError(
"VALIDATION_ERROR",
"Request validation failed",
Instant.now(),
details
);
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleGeneric(Exception ex) {
// Log the actual exception, never expose it
log.error("Unhandled exception", ex);
ApiError error = new ApiError(
"INTERNAL_ERROR",
"An unexpected error occurred",
Instant.now()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
Selective Application with Attributes
@ControllerAdvice is powerful, but sometimes you want it to apply only to specific controllers or packages.
// Apply only to controllers in this package
@ControllerAdvice("com.example.api.controllers")
// Apply only to controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
// Apply only to specific controller classes
@ControllerAdvice(assignableTypes = {UserController.class, OrderController.class})
This granular control is useful in large applications where different modules need different error handling strategies.
@ResponseStatus: Declarative HTTP Status Codes
The simplest way to set an HTTP status on an exception is annotating the exception class itself with @ResponseStatus.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long id) {
super("User with id " + id + " not found");
}
}
When this exception is thrown and not caught by any @ExceptionHandler, Spring automatically returns a 404 with an empty response body. It works, but it is limited. You cannot add a custom body, log the exception, or apply any additional logic.
Combining @ResponseStatus with @ExceptionHandler
A better pattern is using @ResponseStatus purely to declare intent on the exception class while handling the actual response construction in @ControllerAdvice.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
private final String resourceName;
private final String fieldName;
private final Object fieldValue;
public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
super(String.format("%s not found with %s: '%s'", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
// getters
}
This separation of concerns keeps your exception classes focused on data while the handler focuses on response formatting.
Customizing Error Responses with ErrorAttributes
ErrorAttributes controls what data appears in the default error response. The default implementation includes timestamp, status, error, message, and path. You can customize or extend this.
Extending DefaultErrorAttributes
@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
Map<String, Object> errorAttributes = super.getErrorAttributes(webRequest, options);
// Add custom fields
errorAttributes.put("correlationId", UUID.randomUUID().toString());
errorAttributes.put("version", "v1");
// Remove fields you do not want to expose
errorAttributes.remove("trace");
return errorAttributes;
}
}
Creating a Fully Custom ErrorAttributes Implementation
@Component
public class CustomErrorAttributes implements ErrorAttributes {
private final ErrorLoggingService errorLoggingService;
@Override
public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
Throwable exception = getError(webRequest);
Map<String, Object> errorAttributes = new HashMap<>();
errorAttributes.put("timestamp", Instant.now().toString());
errorAttributes.put("status", getStatus(webRequest).value());
errorAttributes.put("error", getStatus(webRequest).getReasonPhrase());
errorAttributes.put("message", exception != null ? exception.getMessage() : "No message");
errorAttributes.put("path", webRequest.getDescription(false).replace("uri=", ""));
if (options.isIncluded(Include.STACK_TRACE)) {
// Only include stack trace in development
if (environment.getActiveProfiles().contains("dev")) {
errorAttributes.put("trace", getStackTrace(exception));
}
}
// Always log for correlation
if (exception != null) {
String correlationId = errorLoggingService.log(exception);
errorAttributes.put("correlationId", correlationId);
}
return errorAttributes;
}
@Override
public Throwable getError(WebRequest webRequest) {
return (Throwable) webRequest.getAttribute("javax.servlet.error.exception", SCOPE_REQUEST);
}
private HttpStatus getStatus(WebRequest request) {
Integer status = (Integer) request.getAttribute("javax.servlet.error.status_code", SCOPE_REQUEST);
return status != null ? HttpStatus.valueOf(status) : HttpStatus.INTERNAL_SERVER_ERROR;
}
}
Register your custom ErrorAttributes by declaring it as a @Component bean. Spring Boot’s ErrorMvcAutoConfiguration will automatically pick it up.
Failure Scenarios
Even with solid exception handling in place, things can still go wrong. Here are the failure scenarios you need to anticipate.
Handler Throws an Exception
If your @ExceptionHandler method itself throws an exception, Spring falls back to its default error handling. This creates a confusing situation where the original error is lost.
Mitigation: Wrap your handler logic in try-catch and have a fallback that returns a safe generic error.
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleSafe(Exception ex) {
try {
// Risky processing
return processException(ex);
} catch (Exception inner) {
log.error("Error in exception handler", inner);
return ResponseEntity.status(500).body(ApiError.generic());
}
}
ResponseStatusException Loses Custom Body
ResponseStatusException is the programmatic equivalent of @ResponseStatus, but it does not automatically work with @ControllerAdvice exception matching.
// This will NOT be caught by @ExceptionHandler(ResponseStatusException.class)
// if it was thrown from within a @ExceptionHandler itself
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
Mitigation: Use custom exception classes instead of ResponseStatusException for anything that needs centralized handling.
Circular Exception Handling
If handler A throws exception B, and handler B throws exception A, you get a stack overflow.
Mitigation: Never throw exceptions from within @ExceptionHandler methods. Always convert them to ResponseEntity returns.
Missing Handler for Common Exceptions
MethodArgumentNotValidException, HttpMessageNotReadableException, and MissingServletRequestParameterException are thrown by Spring MVC automatically. If you do not handle them, clients get opaque 400 errors with no useful details.
Mitigation: Add handlers for these common validation exceptions in your global handler.
When to Use / When NOT to Use
When to Use @ExceptionHandler (Per-Controller)
Use @ExceptionHandler when an error response is specific to one controller and does not make sense anywhere else. If a particular controller needs to format an exception differently because the consumers of that endpoint expect a unique error structure, keep it local. This approach also works well during early prototyping before you know what the global pattern should look like.
When NOT to Use @ExceptionHandler (Per-Controller)
As soon as you find yourself copying the same @ExceptionHandler method to multiple controllers, refactor to @ControllerAdvice. Duplicated handler code is a maintenance liability — when the error format needs to change, you have to update every copy. If the same exception type needs to be handled the same way across multiple endpoints, that is a signal to move it global.
When to Use @ControllerAdvice (Global)
Use @ControllerAdvice for exceptions that make sense to handle uniformly across the entire API — business exceptions like UserNotFoundException, validation errors from MethodArgumentNotValidException, or any error format that should be consistent regardless of which controller originated the request.
When to Use @ResponseStatus on Exception Classes
Use @ResponseStatus on custom exception classes when you need a specific HTTP status code and nothing more. It is a zero-code way to map an exception to a status. Avoid it when you need a custom response body, when you want to log the exception, or when the error response depends on context that only a handler method can provide.
When to Use ErrorAttributes Customization
Use ErrorAttributes when you need every error response — including those from Spring’s default BasicErrorController — to follow a consistent structure across your entire API. If you want all clients to receive a uniform error envelope with fields like timestamp, status, error, message, and correlationId, customize ErrorAttributes rather than relying on per-handler construction.
When to Use ErrorController Implementation
Use a custom ErrorController when you need full control over the error page rendering or when the default JSON error format does not meet your needs at all. This is a lower-level approach than ErrorAttributes and is rarely needed.
Trade-off Table
| Approach | Pros | Cons |
|---|---|---|
@ExceptionHandler per controller |
Simple, local reasoning | Code duplication across controllers |
@ControllerAdvice global |
Single place to maintain | May need splitting in large apps |
@ResponseStatus on exception |
Declarative, zero code | No custom body, limited logic |
ErrorAttributes customization |
Consistent error format | Involves Spring internals |
ErrorController implementation |
Full control over error page | More code, easier to get wrong |
Security Considerations
This section is non-negotiable for production systems.
Never Leak Stack Traces
The default Spring Boot error page includes the full stack trace in development. If that page ever reaches a production client, you have disclosed your entire class hierarchy, library versions, and internal logic.
@Component
public class ProductionErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(WebRequest request, ErrorAttributeOptions options) {
Map<String, Object> attrs = super.getErrorAttributes(request, options);
// Never expose trace in production
if (!environment.getActiveProfiles().contains("dev")) {
attrs.remove("trace");
attrs.remove("errors");
}
return attrs;
}
}
Validate Error Message Content
If you pass user input directly into error messages, you open the door to log injection attacks. A malicious user submits a request with X-Requested-With: <script>alert(1)</script> and if you echo that header into a log-prefixed error message, you have a cross-site scripting vector.
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ApiError> handleBadRequest(BadRequestException ex) {
String sanitized = sanitize(ex.getMessage()); // Strip HTML/script tags
return ResponseEntity.badRequest().body(new ApiError("BAD_REQUEST", sanitized));
}
Do Not Expose Internal Paths
The path field in error attributes reveals your internal URL structure. In some deployments, this alone can help an attacker map your application’s internal routing.
@Override
public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
Map<String, Object> attrs = super.getErrorAttributes(webRequest, options);
// Replace path with a generic indicator in production
if (!environment.getActiveProfiles().contains("dev")) {
attrs.put("path", "redacted");
}
return attrs;
}
Common Pitfalls / Anti-Patterns
Handling the wrong exception type. Make sure your @ExceptionHandler parameter matches the actual exception being thrown. Generic Exception.class handlers should always be last in the chain.
Ignoring the HTTP method context. A 404 on a GET request has different semantics than a 404 on a DELETE. Your error messages should reflect what failed, not just that it failed.
Returning null from a handler. If your handler returns null, Spring treats it as if no handler matched and continues searching. This leads to confusing behavior and potentially recursive handler searches.
Forgetting to mark exceptions as unchecked. Checked exceptions require throws declarations and clutter your controller signatures. Extend RuntimeException for most business exceptions so handlers can catch them without ceremony.
Not testing your error paths. Unit tests that only exercise the happy path miss the most critical part of your API. Write tests that verify each exception handler returns the correct status code and body.
Observability Checklist
A well-observed API lets you diagnose production issues in minutes, not hours.
- Every exception handler logs the exception with a correlation ID
- Error responses include the correlation ID so clients can report it
- Metric counter incremented for each error type
- Alerting configured for spike in 5xx errors
- Alerting configured for spike in 4xx client errors (may indicate abuse)
- Health check endpoint reports degraded state when error rate is high
- Distributed trace ID propagated through error logging
- Error attributes include request ID for log correlation
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleLoggedException(Exception ex, WebRequest request) {
String correlationId = UUID.randomUUID().toString();
String traceId = request.getHeader("X-Trace-Id");
log.error("Unhandled exception. correlationId={}, traceId={}, exception={}",
correlationId, traceId, ex.getClass().getName(), ex);
metrics.increment("error.unhandled", "type", ex.getClass().getSimpleName());
return ResponseEntity.status(500).body(
new ApiError("INTERNAL_ERROR", "An error occurred. Reference: " + correlationId)
);
}
Quick Recap Checklist
- Use
@ControllerAdvicefor global exception handling across all controllers - Reserve
@ExceptionHandlerfor controller-specific cases only - Separate exception classes from response formatting logic
- Customize
ErrorAttributesfor consistent error response structure - Never expose stack traces, internal paths, or class names in production
- Add correlation IDs to every error response
- Handle Spring MVC’s automatic exceptions (
MethodArgumentNotValidException, etc.) - Write handler methods that never throw exceptions
- Test every error path with unit tests
- Log exceptions server-side with enough context to reproduce the issue
Related Posts
- Java Custom Exceptions — Learn how to design exception classes that carry meaningful context
- Java Exception Best Practices — Broader patterns for working with exceptions in Java
- RESTful API Design — Design principles that complement robust error handling
- API Gateway Patterns — Where exception handling fits in your overall API architecture
Interview Questions
There is no functional difference. @RestControllerAdvice is simply a compound annotation that combines @ControllerAdvice with @ResponseBody. Under the hood, it is the same ControllerAdvice mechanism. The @ResponseBody annotation means that every return value from exception handler methods is automatically serialized to JSON, which is what you want for REST APIs. If you use plain @ControllerAdvice, you would need to wrap your return values in ResponseEntity to achieve the same effect.
When no custom handler matches, Spring falls back to BasicErrorController, which is auto-configured by Spring Boot's ErrorMvcAutoConfiguration. This controller has two methods: one that renders an HTML error page for browser requests, and one that renders a JSON response for API clients. It uses DefaultErrorAttributes to build the error attributes, which includes properties like timestamp, status, error, message, and path. Understanding this fallback is important because if your custom handler throws an uncaught exception, the client may receive a response from BasicErrorController instead of your intended error format.
You can inspect the HttpServletRequest in your @ExceptionHandler method and check the Accept header or other client indicators. Alternatively, use Spring's ContentNegotiationStrategy to determine the requested media type. For a cleaner implementation, create separate @ControllerAdvice classes targeted at different controller packages using the annotations or assignableTypes attribute. This way your web controllers get HTML-friendly errors while your mobile API controllers get compact JSON errors with no extraneous fields.
ErrorAttributeOptions controls which fields are included in the error response when you extend DefaultErrorAttributes. It has flags for INCLUDE_TIMESTAMP, INCLUDE_MESSAGE, INCLUDE_STACK_TRACE, INCLUDE_BINDING_ERRORS, and INCLUDE_EXCEPTION. You would use this when you need fine-grained control over the error response content. For example, you might want to exclude the stack trace in production while including it in development, or you might want to omit the message field for security-sensitive endpoints while keeping it for internal services.
The golden rule is that exception handler methods should never throw exceptions. They should always return a ResponseEntity. If your handler logic itself needs to throw an exception (for example, if a service layer call fails while you are building an error response), wrap it in a try-catch and return a safe fallback response. A common pattern is to have a private helper method buildErrorResponse that is fully self-contained and guaranteed not to throw. Additionally, always order your exception handlers from most specific to least specific, with a generic Exception.class handler last as a safety net that should never trigger if your specific handlers are complete.
RFC 7807 defines a standardized error response format with five fields: type (a URI identifying the problem type), title (a short human-readable description), status (the HTTP status code), detail (a human-readable explanation specific to this occurrence), and instance (a URI that identifies the specific occurrence). Spring Boot supports this natively through ProblemDetail and ErrorResponse. You can return ProblemDetail directly from your exception handler, and Spring will serialize it correctly. The advantage over ad-hoc error responses is that clients can programmatically parse the type URI to understand the error category without relying on fragile string matching of error messages.
MethodArgumentNotValidException is thrown when bean validation on a request body fails, typically from @Valid annotated parameters in @RequestBody methods. ConstraintViolationException is thrown when validation fails on method parameters or path variables, typically from @Validated at the class level with @Valid on method parameters. The key practical difference is that MethodArgumentNotValidException gives you access to FieldError objects through getBindingResult(), letting you return per-field error messages. ConstraintViolationException gives you a set of ConstraintViolation objects that describe which parameter failed and why. Both need to be handled separately in your @ControllerAdvice.
By default, Spring's transaction infrastructure rolls back only on unchecked exceptions (RuntimeException and its subclasses) and Error. Checked exceptions do not trigger rollback by default. If your exception handler catches an exception that occurs within a transactional method, the transaction has already been rolled back by the time your handler executes. This means you cannot use exception handling to "rescue" a transaction that would otherwise roll back. If you need transactional behavior tied to your exception handling logic, use @Transactional(rollbackFor = YourException.class) to explicitly declare which exceptions should trigger rollback. Also note that @ExceptionHandler methods themselves are not transactional, so you cannot use them to initiate or commit transactions.
@WebMvcTest loads only the web layer (controllers and @ControllerAdvice), making it ideal for testing exception handlers in isolation. You use MockMvc to perform requests and verify responses. A typical pattern is mockMvc.perform(get("/users/999")).andExpect(status().isNotFound()).andExpect(jsonPath("$.errorCode").value("USER_NOT_FOUND")). You can also use @MockBean to mock service layer dependencies and configure them to throw specific exceptions, then verify your handler produces the expected response. This approach tests the full request-to-response flow including your exception handler, without starting the entire application context.
Inject MessageSource into your @ControllerAdvice and use it to resolve messages by code. For validation errors, Spring's FieldError already carries the error code from your validation annotation, which you can look up through messageSource.getMessage() with the appropriate locale. For business exceptions, define message codes in your messages.properties file matching your exception class names or custom codes. Pass the HttpServletRequest to your handler method to extract the Accept-Language header and resolve the correct locale. This approach lets you return errors in the client's language without changing your handler logic.
SimpleMappingExceptionResolver is an older Spring exception handling mechanism that maps exception types to view names (JSPs or templates) for rendered HTML responses. It is configured as a bean and applies globally without annotations. The key difference from @ExceptionHandler is that it is view-based rather than API-based — it is designed for server-side rendered applications where you want to forward to an error page. For REST APIs returning JSON, @ExceptionHandler and @ControllerAdvice are superior because they give you full control over the response body. Use SimpleMappingExceptionResolver only when you have a legacy JSP-based application that needs exception-to-page mapping.
WebFlux does not use the same exception handling model as MVC because it is built on reactive streams and non-blocking I/O. Instead of @ExceptionHandler, WebFlux uses WebExceptionHandler (part of the HandlerExceptionHandler interface). You implement WebExceptionHandler and register it as a bean. The handling happens on the reactive pipeline, so you return Mono<ServerResponse> instead of ResponseEntity. Another key difference is that @ControllerAdvice works differently in WebFlux — the reactive equivalent is @ControllerAdvice with @ExceptionHandler methods that return Mono or Flux. Understanding this distinction is critical when migrating exception handling logic between blocking and reactive applications.
A correlation ID is a unique identifier (typically a UUID) attached to every incoming request, propagated through all service calls via the X-Correlation-ID HTTP header. Your API gateway generates it if absent and forwards it to backend services. Each service includes it in every log entry, so when an exception occurs, you can search your logging system by correlation ID and retrieve the entire request chain across all services. In Spring Boot, you typically use a Filter to extract or generate the correlation ID and store it in a ThreadLocal (or MDC for structured logging). Your @ControllerAdvice reads this ID and includes it in the error response, allowing clients to report it when contacting support.
Define a root application exception (like ApplicationException) extending RuntimeException. Create intermediate exception categories: ResourceNotFoundException for 404s, ValidationException for 400s, AuthorizationException for 403s, and BusinessRuleException for domain logic violations. Each concrete exception carries domain-specific data — for example, UserNotFoundException extends ResourceNotFoundException and carries the user ID. This hierarchy lets your @ControllerAdvice have coarse-grained handlers at the intermediate level while allowing fine-grained handlers for specific exceptions where needed. Avoid the anti-pattern of a single generic exception class, as it makes it impossible to handle different failure modes appropriately.
@ExceptionHandler methods accept several automatically injected parameters: the thrown exception itself (Exception, RuntimeException, or a specific subtype), HttpServletRequest for access to request headers and attributes, WebRequest for attribute access across request scopes, HttpServletResponse for direct response manipulation, Locale for the client's locale, InputStream and OutputStream for raw body access, and Model for view-based applications. Spring resolves these parameters by type, so you can declare only what you need. The exception parameter is always required — it tells Spring which exception type this handler is for. Using fewer parameters keeps handlers focused; resist the temptation to inject everything "just in case."
Exceptions thrown in filters (before the request reaches the controller) do not go through @ExceptionHandler methods. They are caught by Spring's DispatcherServlet error handling, which delegates to registered HandlerExceptionResolver implementations. If a filter sets an attribute on the request with the exception, your @ExceptionHandler can retrieve it via WebRequest.getAttribute("javax.servlet.error.exception", SCOPE_REQUEST). This is why centralized logging and error formatting in filters is separate from @ControllerAdvice — they operate at different layers. For REST APIs, consider using ControllerAdvice to handle both controller exceptions and filter-level errors by implementing ErrorController or by registering a custom HandlerExceptionResolver that stores exceptions for later retrieval.
Implement a custom ErrorController when you need full control over the error rendering logic — including the ability to serve different error formats based on content negotiation, redirect to error pages in certain scenarios, or completely replace the default error rendering pipeline. ErrorAttributes customization is sufficient for most cases because it lets you modify the error response body while leaving the routing and controller logic to Spring. The key distinction is that ErrorController controls the "what happens after an error occurs" pipeline, while ErrorAttributes only controls "what fields go into the error response." If your customization needs are limited to changing field names or adding correlation IDs, ErrorAttributes is the right tool. If you need to change the HTTP status, add headers, or serve different content types, you need ErrorController.
ErrorMvcAutoConfiguration is the Spring Boot auto-configuration class that sets up the default error handling machinery. It contributes a BasicErrorController (the fallback error handler), a DefaultErrorAttributes (which builds the error attribute map), a DefaultExceptionHandlerResolver, and error page configuration. When you add @ControllerAdvice beans, they take precedence over BasicErrorController for matching exceptions. One counterintuitive behavior: ErrorMvcAutoConfiguration is disabled when you add a bean of type ErrorController, not when you add ErrorAttributes. Understanding this precedence chain helps you debug why an unexpected error page appears instead of your custom JSON error response.
Exceptions thrown inside @Async methods are not propagated to the calling thread's exception handling infrastructure. Instead, they are caught by Spring's AsyncUncaughtExceptionHandler, which you can customize by implementing that interface and declaring it as a bean. The default handler logs the exception but produces no client-facing error response. For REST APIs, the challenge is that the async thread that threw the exception has no access to the HTTP response. The recommended pattern is to have your @Async method store the exception result in a result-holding object (like a CompletableFuture with an exception) that the calling controller can inspect and convert into an appropriate error response. This separates the async work's error tracking from the API's error response construction.
Global exception handling via @ControllerAdvice provides consistency — all clients receive errors in the same format, making your API predictable and easier to consume. It centralizes logging, correlation ID injection, and security filtering for errors in one place. The downside is reduced visibility: when something breaks, you have to trace from the controller to the handler, and in large applications @ControllerAdvice classes can become large and difficult to maintain. Local @ExceptionHandler methods are visible immediately when reading the controller, making them easier to understand in isolation. They are appropriate when different controllers genuinely need different error formats. The practical compromise is to use @ControllerAdvice for truly global concerns (logging, correlation IDs, security sanitization) while allowing controller-specific handlers for endpoint-specific error nuances.
Further Reading
These deep dives cover advanced topics that build on the exception handling foundation covered above.
- Validation Exception Handling —
MethodArgumentNotValidExceptionandConstraintViolationExceptionhave different sources (body vs parameter validation). Understanding which validator fires when helps you write precise handlers for each case. - Exception Handling in Reactive Streams — WebFlux uses a different exception handling model than MVC.
WebExceptionHandlerreplaces@ExceptionHandlerin reactive applications, and the propagation semantics are different. - Integration with API Gateways — When your Spring Boot service sits behind a gateway like Spring Cloud Gateway or Zuul, error handling crosses process boundaries. The gateway may transform your error response, so design for it.
- RFC 7807 Problem Details — This standard defines a structured error response format (
type,title,status,detail,instance). Spring supports it natively withProblemDetailandErrorResponse. - Internationalization of Error Messages — Use Spring’s
MessageSourceto return localized error messages. Users in different locales should see errors in their language, not just English. - Exception Handling and Transaction Rollback — By default,
@ExceptionHandlermethods do not trigger transaction rollback. If your handler runs within a transaction and you need rollback behavior, use@Transactional(rollbackFor = …). - Testing Exception Handlers — Use
@WebMvcTestwithMockMvcto test handlers in isolation.MockMvc.perform(get(“/users/1”)).andExpect(status().isNotFound())verifies the handler response without spinning up the full application context. - Centralized Logging with Correlation IDs — Propagate a
X-Correlation-IDheader from API Gateway through to your logs. Every exception log should include this ID so you can trace a request across service boundaries.
Conclusion
Spring Boot’s exception handling toolkit gives you everything you need to build APIs that fail gracefully. The key layers are @ExceptionHandler for per-controller cases, @ControllerAdvice for global consistency, ErrorAttributes for response structure, and @ResponseStatus for declarative status codes on exception classes. Combine these tools with proper logging, correlation IDs, and a security-first mindset where stack traces and internal paths never reach clients.
A well-designed exception handling strategy means your API always returns predictable, documented error responses — regardless of what goes wrong internally. That predictability is what separates production-grade APIs from prototypes.
Category
Related Posts
Spring Boot Build Tools: Maven & Gradle
Configure Maven and Gradle for Spring Boot projects—plugins, dependency management, packaging JARs and WARs, and build automation essentials.
Embedded Web Servers in Spring Boot: Tomcat, Jetty, Undertow
Configure embedded servers in Spring Boot: compare Tomcat, Jetty, and Undertow, tune thread pools, enable access logs, and switch implementations.
JUnit 5 & Jupiter: Lifecycle, Nested & Parameterized Tests
Explore JUnit 5 Jupiter features: master test lifecycle annotations, organize tests with @Nested, and parameterize tests with @CsvSource and @MethodSource.