Spring Boot Global Exception Handling: The Complete Guide
- Global exception handling decouples error response formatting from business logic — controller methods contain only the happy path, the advice class contains all error handling. This is the Single Responsibility Principle applied at the web layer.
- Use @RestControllerAdvice to produce a unified JSON error structure from every endpoint — a consistent contract that frontend teams can depend on without defensive code for multiple response shapes.
- Standardize every error response with the same fields: timestamp (UTC Instant), status (numeric HTTP code), error (short label), message (human-readable), errorCode (machine-readable for frontend behavior decisions), path (request URI), and traceId on 5xx responses for support correlation.
- @RestControllerAdvice intercepts exceptions thrown by any controller and maps them to standardized JSON responses with correct HTTP status codes
- @ExceptionHandler matches specific exception types to handler methods — Spring uses the most specific match first (subclass before superclass)
- MethodArgumentNotValidException handles @Valid DTO failures — extract field errors into a map for frontend field-level error display
- Never return raw stack traces to clients — exposes internal class structure, library versions, and security vulnerabilities
- Always log the exception internally with SLF4J before returning a sanitized response — the developer needs the trace, the client does not
- Catching exceptions in the service layer without re-throwing prevents @RestControllerAdvice from ever seeing them — client gets misleading 200 OK
Production Incident
Production Debug GuideWhen global exception handling behaves unexpectedly, here is the diagnostic sequence I use — ordered by frequency of occurrence.
In professional API development, consistency is not a nice-to-have — it is a contract. If one endpoint returns a stack trace on error and another returns a plain string message and a third returns a structured JSON object, your frontend engineers are writing defensive code for every possible shape instead of building features. That inconsistency compounds. Every new endpoint is a new guess about what the error response will look like.
Spring Boot exception handling provides a sophisticated, centralized mechanism to intercept failures across your entire web layer and standardize every error response through a single class. But the mechanism is only as good as the discipline around it.
Inconsistent error responses cause real production problems that I have personally debugged. A frontend receiving a raw Java stack trace cannot parse it — the error message renderer fails, the user sees a white screen with garbled text. A service-layer try-catch that swallows exceptions returns 200 OK for a failed payment operation — the frontend thinks the transaction succeeded, the database records nothing, and customers call support confused about why their order never arrived. These are not hypothetical scenarios.
This guide implements a production-grade Global Exception Handler that manages custom business exceptions, handles complex JSR-303 validation errors with field-level detail, and deliberately masks internal system details from external consumers. By the end, you will have a single class that handles every error your API can throw — with the right HTTP status, the right JSON structure, and the right amount of information for each audience.
The Architecture of @RestControllerAdvice: What Is Actually Happening
Before writing a single exception handler, it is worth understanding exactly what @RestControllerAdvice does and when it runs. Spring Boot uses AOP to weave a proxy around every class annotated with @RestController or @Controller. When a controller method throws an exception, Spring's DispatcherServlet catches it and delegates to the HandlerExceptionResolver chain. @RestControllerAdvice integrates into this chain through ExceptionHandlerExceptionResolver, which scans advice classes for @ExceptionHandler methods that match the thrown exception type.
The matching algorithm matters. If you throw ForgeResourceNotFoundException (which extends RuntimeException) and you have handlers for both Exception.class and ForgeResourceNotFoundException.class, Spring selects the most specific match — ForgeResourceNotFoundException wins. This is not just a convenience feature; it is the mechanism that lets you have precise behavior for specific exceptions while maintaining a catch-all safety net for everything unexpected.
The advice class must be in the component scan path. This is the gotcha that catches teams who put their exception handler in a shared module or a sub-package that @SpringBootApplication does not scan. The class compiles, the annotations are present, and Spring silently ignores it because it was never registered as a bean.
Every handler method should follow a consistent pattern: accept the exception as a parameter, accept an optional HttpServletRequest for extracting the request path, log the full exception internally at the appropriate level, build a structured error response, and return it wrapped in a ResponseEntity with the correct HTTP status.
package io.thecodeforge.exception; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; /** * io.thecodeforge: Single source of truth for all API error responses. * * Design decisions explained: * - LinkedHashMap preserves insertion order in the JSON response — fields appear * in a predictable sequence that the frontend team can depend on * - Instant instead of LocalDateTime for timestamp — Instant is UTC and unambiguous * in multi-timezone environments; LocalDateTime is server-local and misleading * - traceId in every response — a UUID that appears in both the response and the * server logs, enabling support teams to find the exact stack trace for any incident * - HttpServletRequest parameter — extracts the actual request path without * hardcoding it, works for any endpoint without modification */ @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); // ── Business Exceptions ────────────────────────────────────────────────── @ExceptionHandler(ForgeResourceNotFoundException.class) public ResponseEntity<Map<String, Object>> handleResourceNotFound( ForgeResourceNotFoundException ex, HttpServletRequest request) { // 4xx: WARN level — not a system failure, a client error log.warn("Resource not found [path={}]: {}", request.getRequestURI(), ex.getMessage()); return buildErrorResponse( HttpStatus.NOT_FOUND, "Resource Not Found", ex.getMessage(), "ERR_RES_404", request.getRequestURI() ); } @ExceptionHandler(ForgeBusinessRuleException.class) public ResponseEntity<Map<String, Object>> handleBusinessRuleViolation( ForgeBusinessRuleException ex, HttpServletRequest request) { // Business rule violations are expected failure paths — WARN, not ERROR log.warn("Business rule violation [path={}, code={}]: {}", request.getRequestURI(), ex.getErrorCode(), ex.getMessage()); return buildErrorResponse( HttpStatus.UNPROCESSABLE_ENTITY, "Business Rule Violation", ex.getMessage(), ex.getErrorCode(), request.getRequestURI() ); } // ── Catch-All Safety Net ───────────────────────────────────────────────── @ExceptionHandler(Exception.class) public ResponseEntity<Map<String, Object>> handleGeneralException( Exception ex, HttpServletRequest request) { // 5xx: ERROR level with full stack trace — this is a system failure // The traceId links this log line to the response the client received String traceId = UUID.randomUUID().toString(); log.error("Unhandled exception [traceId={}, path={}]", traceId, request.getRequestURI(), ex); // Return a sanitized response — no class names, no stack frames, no library versions Map<String, Object> body = new LinkedHashMap<>(); body.put("timestamp", Instant.now().toString()); body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); body.put("error", "Internal Server Error"); body.put("message", "An unexpected error occurred. Contact support with reference: " + traceId); body.put("traceId", traceId); body.put("path", request.getRequestURI()); return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR); } // ── Shared Builder ─────────────────────────────────────────────────────── private ResponseEntity<Map<String, Object>> buildErrorResponse( HttpStatus status, String error, String message, String errorCode, String path) { Map<String, Object> body = new LinkedHashMap<>(); body.put("timestamp", Instant.now().toString()); body.put("status", status.value()); body.put("error", error); body.put("message", message); body.put("errorCode", errorCode); body.put("path", path); return new ResponseEntity<>(body, status); } }
{
"timestamp": "2026-03-19T11:28:24.341Z",
"status": 404,
"error": "Resource Not Found",
"message": "User with ID 502 not found",
"errorCode": "ERR_RES_404",
"path": "/api/v1/users/502"
}
// Unhandled NullPointerException thrown for POST /api/v1/orders:
{
"timestamp": "2026-03-19T11:29:01.892Z",
"status": 500,
"error": "Internal Server Error",
"message": "An unexpected error occurred. Contact support with reference: a3f7c2d1-8b4e-4f9a-b1c2-d3e4f5a6b7c8",
"traceId": "a3f7c2d1-8b4e-4f9a-b1c2-d3e4f5a6b7c8",
"path": "/api/v1/orders"
}
// Server log for the 500 above (full trace, not sent to client):
// ERROR GlobalExceptionHandler - Unhandled exception [traceId=a3f7c2d1-..., path=/api/v1/orders]
// java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null
// at io.thecodeforge.service.OrderService.processItem(OrderService.java:87)
// at io.thecodeforge.service.OrderService.createOrder(OrderService.java:43)
// at io.thecodeforge.controller.OrderController.create(OrderController.java:31)
- @RestControllerAdvice = @ControllerAdvice + @ResponseBody — every handler method automatically serializes its return value to JSON without needing @ResponseBody on each method individually
- Spring matches the most specific @ExceptionHandler first — ForgeResourceNotFoundException handler wins over Exception handler when a ForgeResourceNotFoundException is thrown
- The catch-all @ExceptionHandler(Exception.class) must exist — without it, unmatched exceptions fall through to Spring Boot's BasicErrorController which returns an inconsistent format
- Always log the full stack trace with the exception as the SLF4J parameter (log.error("msg", ex)) not as a string (log.error("msg: " + ex)) — the latter does not capture the trace
- Include a traceId in every 500 response — a UUID that correlates the client-visible error with the server-side log entry, making support investigations tractable
Custom Exception Classes: The Foundation of Precise Error Handling
Catching RuntimeException globally and returning a generic 500 is not exception handling — it is exception suppression with extra steps. The foundation of a useful global handler is a hierarchy of custom exception classes that carry semantic meaning. When the service layer throws ForgeResourceNotFoundException, the handler knows immediately that this is a 404 scenario, not a 500. When it throws ForgeBusinessRuleException with errorCode=PAYMENT_DUPLICATE, the handler and the frontend both have actionable information.
I typically establish a thin exception hierarchy: a base ForgeException that extends RuntimeException and carries an errorCode field, with domain-specific subclasses for the common HTTP status categories. RuntimeException is the right base class — checked exceptions in Spring MVC require you to declare them on every method signature in the call chain, which defeats the point of having a global handler.
The errorCode field is worth the design overhead. A machine-readable code like PAYMENT_DECLINED or USER_NOT_FOUND lets the frontend make behavior decisions (show a retry button for declines, redirect to registration for missing users) without parsing human-readable messages that change with every UI copy update. It also makes support ticket categorization trivial — a spike in ERR_PAYMENT_002 is a payment gateway issue, not a general system problem.
package io.thecodeforge.exception; /** * io.thecodeforge: Base exception for all application-specific exceptions. * * Design: extends RuntimeException so exceptions propagate through the call * chain without requiring checked exception declarations on every method. * The errorCode field provides a machine-readable identifier for the frontend * and support teams — distinct from the human-readable message. */ public abstract class ForgeException extends RuntimeException { private final String errorCode; protected ForgeException(String message, String errorCode) { super(message); this.errorCode = errorCode; } protected ForgeException(String message, String errorCode, Throwable cause) { super(message, cause); this.errorCode = errorCode; } public String getErrorCode() { return errorCode; } } // ── Domain-specific exceptions ─────────────────────────────────────────────── // ForgeResourceNotFoundException.java package io.thecodeforge.exception; public class ForgeResourceNotFoundException extends ForgeException { public ForgeResourceNotFoundException(String resourceType, Object resourceId) { super( String.format("%s with ID '%s' was not found", resourceType, resourceId), "ERR_RES_404" ); } } // ForgeBusinessRuleException.java package io.thecodeforge.exception; public class ForgeBusinessRuleException extends ForgeException { public ForgeBusinessRuleException(String message, String errorCode) { super(message, errorCode); } } // ForgeConflictException.java package io.thecodeforge.exception; public class ForgeConflictException extends ForgeException { public ForgeConflictException(String message, String errorCode) { super(message, errorCode); } } // Usage in service layer — no try-catch, exceptions propagate naturally: // public Product getProduct(Long id) { // return productRepository.findById(id) // .orElseThrow(() -> new ForgeResourceNotFoundException("Product", id)); // } // // public void processPayment(PaymentRequest request) { // if (isDuplicate(request)) { // throw new ForgeBusinessRuleException( // "A payment for this order has already been processed", // "PAYMENT_DUPLICATE" // ); // } // }
{
"timestamp": "2026-03-19T14:22:11.043Z",
"status": 404,
"error": "Resource Not Found",
"message": "Product with ID '9999' was not found",
"errorCode": "ERR_RES_404",
"path": "/api/v1/products/9999"
}
// POST /api/v1/payments — duplicate payment detected:
{
"timestamp": "2026-03-19T14:23:55.781Z",
"status": 422,
"error": "Business Rule Violation",
"message": "A payment for this order has already been processed",
"errorCode": "PAYMENT_DUPLICATE",
"path": "/api/v1/payments"
}
// Frontend can now make behavioral decisions based on errorCode:
// if (error.errorCode === 'PAYMENT_DUPLICATE') showRetryWithNewOrderButton();
// if (error.errorCode === 'ERR_RES_404') redirectToNotFoundPage();
Handling Validation Errors: Field-Level Feedback at Scale
MethodArgumentNotValidException is the most frequently triggered exception in most CRUD APIs. Every form submission, every API call with a request body, every endpoint that uses @Valid triggers it when constraints fail. Handling this inconsistently — or not handling it at all and letting Spring's default format leak through — is the most common source of frontend-backend contract friction I have seen across teams.
The handler's job here is precise: extract every field error from the BindingResult, map each field name to its human-readable constraint message, and return them all in a single 400 response. The frontend needs to know which fields failed, not just that 'the request was invalid.' A response that says 'email is invalid and password is too short' is actionable. A response that says 'validation failed' is not.
Two things trip up developers implementing this handler. First, a DTO can have both field-level constraint violations (@NotNull on a field) and class-level constraint violations (@PasswordMatches on the entire object). The BindingResult exposes both through getFieldErrors() and getGlobalErrors() respectively. Handle both in the same response or you will silently drop class-level validation failures. Second, the @Valid annotation on the controller parameter is the trigger. If it is missing, the DTO is deserialized and passed to the method without any constraint evaluation. The handler is wired up correctly but MethodArgumentNotValidException is never thrown — and no error is raised at all.
// Add this handler to GlobalExceptionHandler — do not create a separate @RestControllerAdvice // Having two @RestControllerAdvice classes for these two concerns introduces ordering ambiguity import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; import org.springframework.web.bind.MethodArgumentNotValidException; // Inside GlobalExceptionHandler class: @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, Object>> handleValidationException( MethodArgumentNotValidException ex, HttpServletRequest request) { // Field-level errors: @NotNull, @Email, @Size on individual fields Map<String, String> fieldErrors = new LinkedHashMap<>(); for (FieldError error : ex.getBindingResult().getFieldErrors()) { // putIfAbsent: keep the first error per field if multiple constraints fail simultaneously fieldErrors.putIfAbsent(error.getField(), error.getDefaultMessage()); } // Class-level errors: @PasswordMatches, @DateRangeValid on the whole object // These are often missed — they appear in getGlobalErrors(), not getFieldErrors() List<String> globalErrors = ex.getBindingResult().getGlobalErrors() .stream() .map(ObjectError::getDefaultMessage) .collect(Collectors.toList()); // 4xx: WARN level — client sent invalid input, not a system failure log.warn("Validation failed [path={}, fieldErrors={}, globalErrors={}]", request.getRequestURI(), fieldErrors.size(), globalErrors.size()); Map<String, Object> body = new LinkedHashMap<>(); body.put("timestamp", Instant.now().toString()); body.put("status", HttpStatus.BAD_REQUEST.value()); body.put("error", "Validation Failed"); body.put("message", "Request contains " + fieldErrors.size() + " validation error(s)"); body.put("fieldErrors", fieldErrors); if (!globalErrors.isEmpty()) { body.put("globalErrors", globalErrors); } body.put("path", request.getRequestURI()); return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); } // Controller — @Valid is the trigger, without it this handler is never invoked: @PostMapping("/users") public ResponseEntity<UserDto> createUser(@Valid @RequestBody UserDto dto) { // Spring validates dto before this method body executes // If validation fails, MethodArgumentNotValidException is thrown here // GlobalExceptionHandler intercepts it before the method body runs return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(dto)); } // DTO with mixed field and class-level constraints: @PasswordMatches // class-level constraint — goes to globalErrors public class UserRegistrationDto { @NotBlank(message = "Email is required") @Email(message = "Please provide a valid email address") private String email; @NotBlank(message = "Password is required") @Size(min = 8, message = "Password must be at least 8 characters") private String password; @NotBlank(message = "Password confirmation is required") private String confirmPassword; }
{
"timestamp": "2026-03-19T15:44:22.109Z",
"status": 400,
"error": "Validation Failed",
"message": "Request contains 2 validation error(s)",
"fieldErrors": {
"email": "Please provide a valid email address",
"password": "Password must be at least 8 characters"
},
"globalErrors": [
"Passwords do not match"
],
"path": "/api/v1/users"
}
// Frontend can now:
// 1. Show 'Please provide a valid email address' next to the email input
// 2. Show 'Password must be at least 8 characters' next to the password input
// 3. Show 'Passwords do not match' as a form-level error above the submit button
// Without field-level granularity, the user sees a generic error and does not know which field to fix
Handling Unmapped URLs and Servlet-Level Errors
There is a category of errors that @RestControllerAdvice never sees: requests for URLs that match no controller mapping, requests for HTTP methods not supported by a controller, and errors that occur in servlet filters before the request reaches the Spring MVC dispatch chain. These fall through to Spring Boot's BasicErrorController, which has its own response format that may differ from your custom error DTO.
For unmapped URLs — the classic 404 for a path no controller handles — there are two approaches. The first is enabling spring.mvc.throw-exception-if-no-handler-found=true and adding spring.web.resources.add-mappings=false, which converts these cases into NoHandlerFoundException that your @RestControllerAdvice can handle normally. The second is implementing the ErrorController interface, which takes over from BasicErrorController entirely and gives you control over the format of every error that reaches Spring Boot's /error fallback endpoint.
For filter-level errors — authentication failures, rate limiting responses, request size violations — neither approach works because the exception happens before the Spring MVC dispatcher processes the request. These require either a custom filter that catches and formats the error response itself, or SecurityExceptionHandler configuration if using Spring Security.
// Additional handlers to add to GlobalExceptionHandler // for coverage of HTTP-level exceptions import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; // ── HTTP-level exceptions (inside GlobalExceptionHandler class) ────────────── // Handles: GET /api/v1/users/999 where 999 does not exist in the URL pattern // Requires: spring.mvc.throw-exception-if-no-handler-found=true // spring.web.resources.add-mappings=false // in application.properties @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity<Map<String, Object>> handleNoHandlerFound( NoHandlerFoundException ex, HttpServletRequest request) { log.warn("No handler found [method={}, path={}]", ex.getHttpMethod(), request.getRequestURI()); return buildErrorResponse( HttpStatus.NOT_FOUND, "Endpoint Not Found", String.format("No endpoint found for %s %s", ex.getHttpMethod(), request.getRequestURI()), "ERR_ENDPOINT_404", request.getRequestURI() ); } // Handles: POST to an endpoint that only supports GET @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity<Map<String, Object>> handleMethodNotSupported( HttpRequestMethodNotSupportedException ex, HttpServletRequest request) { log.warn("Method not supported [method={}, path={}, supported={}]", ex.getMethod(), request.getRequestURI(), ex.getSupportedMethods()); return buildErrorResponse( HttpStatus.METHOD_NOT_ALLOWED, "Method Not Allowed", String.format("Method '%s' is not supported for this endpoint. Supported: %s", ex.getMethod(), String.join(", ", ex.getSupportedHttpMethods() .stream().map(Object::toString).collect(Collectors.toList()))), "ERR_METHOD_405", request.getRequestURI() ); } // Handles: Spring Security AccessDeniedException (authenticated user lacks permission) // Note: For Spring Security, you may also need to configure ExceptionTranslationFilter @ExceptionHandler(AccessDeniedException.class) public ResponseEntity<Map<String, Object>> handleAccessDenied( AccessDeniedException ex, HttpServletRequest request) { // Deliberate: do not log the user's identity or the specific permission that was checked // That information should not appear in application logs for compliance reasons log.warn("Access denied [path={}]", request.getRequestURI()); return buildErrorResponse( HttpStatus.FORBIDDEN, "Access Denied", "You do not have permission to perform this action", "ERR_ACCESS_403", request.getRequestURI() ); }
// Requires spring.mvc.throw-exception-if-no-handler-found=true
{
"timestamp": "2026-03-19T16:01:44.220Z",
"status": 404,
"error": "Endpoint Not Found",
"message": "No endpoint found for GET /api/v1/nonexistent-path",
"errorCode": "ERR_ENDPOINT_404",
"path": "/api/v1/nonexistent-path"
}
// POST /api/v1/users/1 (endpoint only supports GET):
{
"timestamp": "2026-03-19T16:02:18.557Z",
"status": 405,
"error": "Method Not Allowed",
"message": "Method 'POST' is not supported for this endpoint. Supported: GET, HEAD",
"errorCode": "ERR_METHOD_405",
"path": "/api/v1/users/1"
}
// application.properties entries required for NoHandlerFoundException:
// spring.mvc.throw-exception-if-no-handler-found=true
// spring.web.resources.add-mappings=false
Testing Exception Handlers: The Safety Net Needs Its Own Safety Net
Exception handlers are often the least-tested code in a Spring Boot application. Developers write unit tests for service logic and controller happy paths, but the error paths get minimal coverage. This is exactly backwards from a risk perspective. A broken happy path is immediately visible during development. A broken error handler silently returns the wrong status code or the wrong response format in production — often discovered when a customer reports a confusing error message or a frontend engineer notices an unexpected response shape in their network inspector.
Test exception handlers with @WebMvcTest, which loads only the web layer — controllers, advice classes, and their dependencies — without the full application context. This is faster than @SpringBootTest and more focused than unit testing the handler class directly. @WebMvcTest with MockMvc lets you simulate exact HTTP requests and assert HTTP status codes, response JSON structure, and specific field values in a single test.
Three test categories cover the majority of exception handler bugs: status code tests (verify the right HTTP status for each exception type), response structure tests (verify the JSON keys and types match what the frontend expects), and exception propagation tests (verify that service layer exceptions actually reach the handler rather than being swallowed). The third category is the most important and the most commonly omitted.
package io.thecodeforge.exception; import io.thecodeforge.controller.ProductController; import io.thecodeforge.service.ProductService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; /** * io.thecodeforge: Exception handler tests using @WebMvcTest. * * @WebMvcTest loads only the web layer — faster than @SpringBootTest. * MockMvc simulates HTTP requests without starting the actual server. * Tests verify HTTP status, JSON structure, and specific field values. */ @WebMvcTest(controllers = ProductController.class) class GlobalExceptionHandlerTest { @Autowired private MockMvc mockMvc; @MockBean private ProductService productService; @Test void shouldReturn404WithStructuredResponse_whenProductNotFound() throws Exception { // Arrange: service throws the exception that should produce 404 when(productService.getProduct(anyLong())) .thenThrow(new ForgeResourceNotFoundException("Product", 9999L)); // Act + Assert: verify status, content type, and every JSON field mockMvc.perform(get("/api/v1/products/9999") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.error").value("Resource Not Found")) .andExpect(jsonPath("$.errorCode").value("ERR_RES_404")) .andExpect(jsonPath("$.message").value(containsString("9999"))) .andExpect(jsonPath("$.timestamp").exists()) .andExpect(jsonPath("$.path").value("/api/v1/products/9999")) // Verify stack trace is NOT in the response — security requirement .andExpect(jsonPath("$.trace").doesNotExist()) .andExpect(jsonPath("$.exception").doesNotExist()); } @Test void shouldReturn400WithFieldErrors_whenDtoValidationFails() throws Exception { String invalidBody = "{\"email\": \"not-an-email\", \"password\": \"abc\"}"; mockMvc.perform(post("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) .content(invalidBody)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.error").value("Validation Failed")) .andExpect(jsonPath("$.fieldErrors.email").exists()) .andExpect(jsonPath("$.fieldErrors.password").exists()) // Verify both fields have error messages, not null .andExpect(jsonPath("$.fieldErrors.email").isString()) .andExpect(jsonPath("$.fieldErrors.password").isString()); } @Test void shouldReturn500WithTraceId_whenUnexpectedExceptionOccurs() throws Exception { // Simulate an unexpected runtime error in the service when(productService.getProduct(anyLong())) .thenThrow(new RuntimeException("Database connection pool exhausted")); mockMvc.perform(get("/api/v1/products/1") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.status").value(500)) // traceId must be present — support team needs it .andExpect(jsonPath("$.traceId").exists()) .andExpect(jsonPath("$.traceId").isString()) // Internal exception message must NOT be in the response — security requirement .andExpect(jsonPath("$.message") .value(not(containsString("Database connection pool exhausted")))) .andExpect(jsonPath("$.message") .value(containsString("Contact support with reference"))); } @Test void shouldReturn200_whenProductExists() throws Exception { // Verify the happy path still works — exception handling should not affect it when(productService.getProduct(1L)) .thenReturn(new ProductDto(1L, "Forge Drill", 149.50)); mockMvc.perform(get("/api/v1/products/1") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1)); } }
// GlobalExceptionHandlerTest > shouldReturn404WithStructuredResponse_whenProductNotFound PASSED
// GlobalExceptionHandlerTest > shouldReturn400WithFieldErrors_whenDtoValidationFails PASSED
// GlobalExceptionHandlerTest > shouldReturn500WithTraceId_whenUnexpectedExceptionOccurs PASSED
// GlobalExceptionHandlerTest > shouldReturn200_whenProductExists PASSED
//
// Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
// BUILD SUCCESS
//
// What these tests catch before production:
// - Exception swallowing in service layer (200 instead of 4xx/5xx)
// - Stack trace leaking into response (security violation)
// - Missing traceId in 500 responses (support cannot investigate)
// - Wrong HTTP status code for a specific exception type
// - Frontend-breaking JSON structure changes
| Aspect | Traditional Try-Catch | Global @RestControllerAdvice |
|---|---|---|
| Code Cleanliness | Try-catch blocks scattered through controller and service methods — business logic is interrupted by error formatting code that has nothing to do with the operation being performed | Controller methods contain only the happy path — exception handling is completely removed from business logic and centralized in one class |
| Consistency | Error response format varies by developer, controller, and when the code was written — frontend must handle multiple response shapes for the same failure category | Single source of truth for all API error responses — every exception of the same type returns the same JSON structure from every endpoint |
| Maintenance | High: adding a new error field or changing the timestamp format requires updating every try-catch block across every controller — easy to miss one | Low: change one handler method to update the error format for every endpoint that throws that exception type — one change, immediate consistency |
| HTTP Status Mapping | Manual and error-prone: response.setStatus(404) or ResponseEntity.status(404) written individually in each catch block — one wrong value returns the wrong status | Declarative and centralized: @ResponseStatus or ResponseEntity on the handler method defines the status once for all occurrences of that exception type |
| Exception Visibility | Exceptions caught in catch blocks are invisible to logging infrastructure unless explicitly logged — easy to accidentally swallow without logging | Centralized handlers are the single logging point — impossible to forget logging because it is in one place, and the pattern is applied consistently |
| Frontend Contract | Frontend engineers must discover the error format for each endpoint individually — no guarantee that two endpoints have the same structure | Frontend engineers write one error handler for the entire API — the contract is defined in one class and applies universally |
🎯 Key Takeaways
- Global exception handling decouples error response formatting from business logic — controller methods contain only the happy path, the advice class contains all error handling. This is the Single Responsibility Principle applied at the web layer.
- Use @RestControllerAdvice to produce a unified JSON error structure from every endpoint — a consistent contract that frontend teams can depend on without defensive code for multiple response shapes.
- Standardize every error response with the same fields: timestamp (UTC Instant), status (numeric HTTP code), error (short label), message (human-readable), errorCode (machine-readable for frontend behavior decisions), path (request URI), and traceId on 5xx responses for support correlation.
- Never catch exceptions in the service layer without re-throwing — a swallowed exception returns 200 OK for a failed operation, which is operationally worse than a 500 and harder to detect through monitoring.
- Always log the full exception with the SLF4J ex parameter (log.error("msg", ex)) before returning a sanitized response — WARN for 4xx, ERROR for 5xx. The client never sees the trace; the developer always finds it in the logs.
- Test exception handlers with @WebMvcTest and assert both presence of expected fields and absence of stack trace fields — jsonPath("$.trace").doesNotExist() is as important as jsonPath("$.status").value(404).
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QExplain the difference between @ControllerAdvice and @RestControllerAdvice. When would you use each?Mid-levelReveal
- QHow does Spring Boot decide which @ExceptionHandler to use when multiple handlers could match a thrown exception?SeniorReveal
- QWhat is ResponseStatusException and when would you use it instead of creating a custom exception class?Mid-levelReveal
- QHow do you handle requests for URLs that match no controller mapping — the classic unmapped 404 scenario?SeniorReveal
- QDescribe the ErrorController interface and how it differs from @ExceptionHandler in scope and timing.SeniorReveal
Frequently Asked Questions
What is the difference between @ControllerAdvice and @RestControllerAdvice?
@ControllerAdvice marks a class as a global handler where methods return view names for server-rendered HTML responses — suitable for traditional MVC applications with Thymeleaf or JSP. @RestControllerAdvice combines @ControllerAdvice with @ResponseBody, so every handler method automatically serializes its return value to JSON and writes it to the HTTP response body. For REST APIs, always use @RestControllerAdvice — using @ControllerAdvice on a REST API advice class requires adding @ResponseBody to every individual handler method, which is exactly what @RestControllerAdvice eliminates.
How does Spring choose which @ExceptionHandler to call when an exception is thrown?
Spring selects the most specific match based on the exception class hierarchy. If ForgeResourceNotFoundException extends RuntimeException and you have handlers for both ForgeResourceNotFoundException and Exception, Spring calls the ForgeResourceNotFoundException handler because it is closer to the actual thrown type in the class hierarchy. Within the same advice class, the algorithm is deterministic — most specific wins. Across multiple advice classes without @Order annotations, the selection order is not guaranteed. Consolidating all handlers into one @RestControllerAdvice class eliminates cross-class ordering ambiguity entirely.
Can I have multiple @RestControllerAdvice classes in one application?
Technically yes, but Spring does not guarantee execution order without explicit @Order annotations. The ordering can change between Spring Boot minor versions, making the behavior of multiple advice classes fragile across upgrades. Best practice: consolidate all exception handlers into a single @RestControllerAdvice class. The compiler prevents two methods with the same @ExceptionHandler type in the same class, which eliminates ordering ambiguity. If the class grows large, organize handlers into clearly commented groups within the single class — that is a readability concern, not a reason to split into multiple advice classes.
How do I handle validation errors with @RestControllerAdvice and return field-level detail to the frontend?
Add @ExceptionHandler(MethodArgumentNotValidException.class) to your advice class. Extract field errors from ex.getBindingResult().getFieldErrors() and map them to a Map<String, String> of field name to error message. Also extract class-level errors from ex.getBindingResult().getGlobalErrors() for constraints like @PasswordMatches that apply to the whole object rather than a single field — these are commonly missed. Return both in the response body with HTTP 400. The controller method parameter must have @Valid or @Validated annotation — without it, Spring never invokes the Bean Validation engine and MethodArgumentNotValidException is never thrown.
What should a production-ready error response contain?
A production error response should contain six fields at minimum: (1) timestamp — an ISO 8601 UTC instant (use Instant.now().toString(), not LocalDateTime, which is server-local), (2) status — the numeric HTTP status code for programmatic parsing, (3) error — a short, consistent label like 'Resource Not Found' or 'Validation Failed', (4) message — a human-readable description suitable for display, (5) path — the request URI that produced the error, (6) errorCode — a machine-readable identifier like ERR_RES_404 or PAYMENT_DUPLICATE for frontend conditional logic and support ticket categorization. For 500 responses, add a traceId — a UUID that links the client-visible error to the server-side log entry. Never include stack traces, class names, library versions, or line numbers.
Why does my custom exception handler sometimes not catch exceptions thrown in the service layer?
Two common causes. First and most frequently: the exception is caught somewhere in the call chain between the service method and the controller and not re-thrown. A try-catch in the service layer that returns a default value or a different object type swallows the exception before it reaches the controller, and @RestControllerAdvice never sees it. Search for catch blocks that do not contain a throw statement. Second: the exception type thrown at runtime is not the type you expect. A @Transactional method wraps exceptions differently — Spring may wrap your exception in a different exception type. Log ex.getClass().getName() in the catch-all handler to confirm the actual runtime type before assuming the handler is not registered.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.