Spring Boot Exception Handling — 200 OK for Failed Payment
SQLException caught in service layer returned 200 OK on payment failure, hiding errors from @RestControllerAdvice for 6 hours.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- @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
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.
Think of Spring Boot Exception Handling as a centralized '911 Dispatch Center' for your application. In a standard Java app, if something goes wrong in a specific room — a method — that room has to handle the emergency itself with a messy try-catch block scattered through the business logic. Every room reinvents the same wheel with its own error format and its own ideas about what to tell the caller.
With Spring Boot's Global Exception Handling, the rooms do not need to manage emergencies at all. If a fire breaks out anywhere, they throw the problem out the window. The Dispatch Center — @RestControllerAdvice — sees it, identifies what kind of emergency it is, and sends exactly the right response back to the client: a 404 for a missing resource, a 400 for bad input, a 500 for something genuinely broken. One place, one format, no surprises.
The real-world payoff is that your frontend team gets consistent, parseable JSON for every error scenario. They write one error-handling function instead of twelve different ones for twelve different response shapes. That consistency is not just convenient — it is what separates an API that teams want to build on from one they dread.
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); } }
- @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" // ); // } // }
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; }
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() ); }
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)); } }
The Error Response Contract: Why Your API Needs a Standard Shape
After a production outage, the first thing you'll notice is chaos in your error logs. Every exception handler returns a different shape. Controllers throw maps, strings, or — worst case — stack traces. That forces clients to parse every error endpoint differently.
Define a single error DTO. Use a Java record that carries timestamp, status, error code, message, and path. Your @RestControllerAdvice should return this for every exception. Clients write one parser. Your logging pipeline parses structure, not text.
Start by building a record called ApiError. Add a static factory. Then every handler method returns ResponseEntity<ApiError>. If you need field-level validation errors, add a details map or a list of nested error objects. Consistency is not optional — it's the contract your API makes with every consumer.
// io.thecodeforge public record ApiError( LocalDateTime timestamp, int status, String error, String message, String path, Map<String, List<String>> details ) { public static ApiError of(HttpStatus status, String message, String path) { return new ApiError( LocalDateTime.now(), status.value(), status.getReasonPhrase(), message, path, Map.of() ); } }
Throwable or Exception from a handler — you'll leak internal types to clients. Always map to your own error codes.Exception Priorities: Why Order Matters in Handler Resolution
A junior dev once stacked five @ExceptionHandler methods in one advice class. They assumed Spring picked the best match. It did — until the handler for Exception swallowed everything. The CustomerNotFoundException never fired because Exception caught it first.
Spring resolves handlers by specificity — most specific exception type wins. But if you accidentally wire a broader exception before a narrow one, or use generic @ExceptionHandler(Exception.class) as a catch-all, you silence every custom exception.
Structure your advice class intentionally. Put specific exceptions first. Use a single catch-all handler that logs the unexpected exception and returns a 500 with a generic message. Never let a broad handler shadow fine-grained ones. Test the resolution order explicitly in your integration tests.
// io.thecodeforge @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(CustomerNotFoundException.class) public ResponseEntity<ApiError> handleNotFound(CustomerNotFoundException ex, HttpServletRequest request) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(ApiError.of(HttpStatus.NOT_FOUND, ex.getMessage(), request.getRequestURI())); } @ExceptionHandler(ValidationException.class) public ResponseEntity<ApiError> handleValidation(ValidationException ex, HttpServletRequest request) { ApiError error = ApiError.of(HttpStatus.BAD_REQUEST, "Validation failed", request.getRequestURI()); error.details().putAll(ex.getErrors()); return ResponseEntity.badRequest().body(error); } @ExceptionHandler(Exception.class) public ResponseEntity<ApiError> handleGeneric(Exception ex, HttpServletRequest request) { log.error("Unhandled exception", ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiError.of(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred", request.getRequestURI())); } }
@ExceptionHandler(RuntimeException.class) last doesn't guarantee safety if you have a broader handler like Exception.class earlier in another advice bean. Only one advice class wins per exception.The 200 OK for a Failed Payment — Swallowed Exceptions in the Service Layer
- Never catch exceptions in the service layer without re-throwing them — a swallowed exception is invisible to @RestControllerAdvice and the client receives a misleading success response
- A 200 OK response for a failed operation is operationally worse than a 500 error — at least 500 alerts your monitoring system and tells the frontend something went wrong
- Always write integration tests that assert specific error HTTP status codes for failure scenarios — unit tests on the happy path do not catch exception swallowing
- Custom exceptions with machine-readable error codes (PAYMENT_DECLINED, PAYMENT_DUPLICATE) are easier to handle globally and give the frontend actionable information rather than a generic error message
- Single-line warning logs without stack traces are not sufficient for debugging production failures — always log the full exception with ex parameter in the SLF4J call
| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| io | /** | The Architecture of @RestControllerAdvice |
| io | /** | Custom Exception Classes |
| io | @ExceptionHandler(MethodArgumentNotValidException.class) | Handling Validation Errors |
| io | @ExceptionHandler(NoHandlerFoundException.class) | Handling Unmapped URLs and Servlet-Level Errors |
| io | /** | Testing Exception Handlers |
| ApiError.java | public record ApiError( | The Error Response Contract |
| GlobalExceptionHandler.java | @RestControllerAdvice | Exception Priorities |
Key takeaways
Common mistakes to avoid
6 patternsForgetting to log the exception in the global handler before returning the sanitized response
RI(), ex) before returning the sanitized response. The ex parameter as the last argument to the SLF4J logging method captures the full stack trace in the log. Note the difference: log.error("msg", ex) logs the trace, log.error("msg: " + ex.getMessage()) does not. Use WARN for 4xx (client errors, not system failures) and ERROR for 5xx (system failures requiring investigation).Catching exceptions in the service layer without re-throwing — the swallowed exception pattern
Returning raw stack traces to the client in production
Missing @Valid annotation on controller method parameters
Not using specific custom exceptions — throwing generic RuntimeException or IllegalArgumentException
Placing @RestControllerAdvice in a package outside the component scan path
Interview Questions on This Topic
Explain the difference between @ControllerAdvice and @RestControllerAdvice. When would you use each?
How does Spring Boot decide which @ExceptionHandler to use when multiple handlers could match a thrown exception?
What is ResponseStatusException and when would you use it instead of creating a custom exception class?
How do you handle requests for URLs that match no controller mapping — the classic unmapped 404 scenario?
Describe the ErrorController interface and how it differs from @ExceptionHandler in scope and timing.
Frequently Asked Questions
@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.
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.
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.
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.
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.
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.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't