Senior 6 min · March 11, 2026

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.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • @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
Plain-English First

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.

io/thecodeforge/exception/GlobalExceptionHandler.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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);
    }
}
Output
// ForgeResourceNotFoundException thrown for GET /api/v1/users/502:
{
"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 Is a Safety Net, Not a Bouncer
  • @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
Production Insight
A team's @RestControllerAdvice was declared in a package named io.thecodeforge.handlers, but @SpringBootApplication was in io.thecodeforge.app — a sibling package, not a parent. @ComponentScan only scans the package of the main class and its sub-packages. The advice class compiled cleanly, the annotations were correct, but Spring never registered it as a bean. Every unhandled exception returned Spring Boot's default HTML error page to a JSON-only frontend client. The frontend's JSON parser crashed on every error. The team spent three days assuming the issue was in their frontend error handling code before someone added debug=true and noticed the advice class was absent from the bean list.
Key Takeaway
@RestControllerAdvice intercepts exceptions from any controller via AOP — it is the centralized error response factory for your entire web layer.
Spring matches the most specific @ExceptionHandler first — structure your advice class with specific handlers at the top and the Exception.class catch-all at the bottom.
Include a traceId in every 500 response — it links the client-visible error to the server log entry without exposing any internal implementation detail.
Choosing the Right Exception Handling Strategy
IfResource not found — entity missing from database or external service returned 404
UseThrow a custom ForgeResourceNotFoundException — handler maps to HTTP 404 with the resource type and ID in the message
IfInput validation failure — @Valid DTO constraint violated (@NotNull, @Email, @Size)
UseHandle MethodArgumentNotValidException — extract all field errors into a map keyed by field name, return HTTP 400
IfBusiness rule violation — insufficient balance, duplicate entry, illegal state transition
UseThrow a custom ForgeBusinessRuleException with a machine-readable errorCode — handler maps to HTTP 422 Unprocessable Entity
IfConflict — concurrent modification, optimistic lock failure, resource already exists
UseThrow a custom ForgeConflictException — handler maps to HTTP 409 Conflict with details about what conflicted
IfUnexpected system error — database down, null pointer, network timeout
UseCatch-all Exception.class handler — log full trace at ERROR level with traceId, return sanitized 500 with traceId for support reference
IfAuthentication or authorization failure
UseHandle AccessDeniedException (403) and AuthenticationException (401) — return security-appropriate messages that do not hint at internal structure

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.

io/thecodeforge/exception/ForgeException.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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"
//         );
//     }
// }
Output
// GET /api/v1/products/9999 — product does not exist:
{
"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();
Checked vs Unchecked Exceptions in Spring MVC
Spring MVC's @ExceptionHandler intercepts both checked and unchecked exceptions — but unchecked exceptions (extending RuntimeException) are far more practical in the service layer. With checked exceptions, every method in the call chain from the repository up to the controller must declare throws ForgeResourceNotFoundException in its signature, or catch and wrap it. This adds noise to every method signature without adding safety. Extend RuntimeException for all application-specific exceptions and let them propagate silently through the stack until @RestControllerAdvice catches them.
Production Insight
A team used generic IllegalArgumentException and IllegalStateException throughout their service layer, throwing them with plain English messages. The global handler could not distinguish between 'argument was invalid' (which should be 400) and 'state was illegal due to a business rule' (which should be 422). Both mapped to 500 because the catch-all handled them. The frontend received a 500 error for what were actually client input errors — the user was told to contact support for problems they could fix themselves. After introducing ForgeBusinessRuleException and ForgeValidationException as distinct types, the handler correctly returned 422 and 400 respectively, and the frontend could display appropriate recovery instructions.
Key Takeaway
Custom exception classes with errorCode fields transform your global handler from a catch-all into a precise routing system — each exception type maps to exactly the right HTTP status and response structure.
Extend RuntimeException for all application-specific exceptions — checked exceptions require method signature declarations throughout the call chain without adding meaningful safety in a Spring MVC context.
The errorCode field serves two audiences: the frontend uses it for conditional behavior without parsing human-readable messages, and the support team uses it for categorizing incidents and querying logs.

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.

io/thecodeforge/exception/GlobalExceptionHandler.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 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;
}
Output
// POST /api/v1/users with body: {"email": "not-an-email", "password": "abc", "confirmPassword": "xyz"}
{
"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
Multiple @RestControllerAdvice Classes Create Ordering Problems
The original code in this guide split ValidationHandler into a separate @RestControllerAdvice class from GlobalExceptionHandler. This works — until it does not. Spring does not guarantee the evaluation order of multiple advice classes without explicit @Order annotations. The behavior can differ between Spring Boot patch versions and between JVM implementations. I have debugged incidents where a Spring Boot patch upgrade changed which advice class handled a specific exception type because the ordering guarantees shifted. Consolidate all exception handlers into a single @RestControllerAdvice class. The compiler enforces that you cannot have two methods with the same @ExceptionHandler type in the same class, which eliminates the ambiguity entirely. If the class grows large, organize handlers into private groups with comments — that is a readability problem, not an architecture problem.
Production Insight
A team maintained two @RestControllerAdvice classes: one for business exceptions and one for validation. Both worked correctly for months. After a Spring Boot minor version upgrade, the validation handler started receiving ForgeBusinessRuleException before the business handler because the class loading order shifted. Business exceptions that should have returned 422 started returning 400. The frontend team noticed the inconsistency in their error tracking dashboard and filed a bug. The root cause was the absence of @Order annotations. The durable fix was merging both classes — two hours of consolidation work that eliminated the ordering dependency permanently.
Key Takeaway
Handle both field-level errors (getFieldErrors) and class-level errors (getGlobalErrors) in the validation handler — missing global errors silently drops @PasswordMatches and similar class-level constraint failures.
Consolidate all handlers in one @RestControllerAdvice class — multiple advice classes require @Order to be stable and are a source of upgrade-related regressions.
@Valid on the controller parameter is the trigger — its absence means constraints on the DTO class are defined but never evaluated, and MethodArgumentNotValidException is never thrown.

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.

io/thecodeforge/exception/GlobalExceptionHandler.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 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()
    );
}
Output
// GET /api/v1/nonexistent-path (no controller mapping):
// 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
Security Exception Messages Should Be Deliberately Vague
Access denied and authentication failure responses should not describe what permission was checked, what role was required, or what the user's current authorities are. That information helps an attacker understand your permission model and target privilege escalation. Return 'You do not have permission to perform this action' for 403 and 'Authentication is required' for 401 — nothing more specific than that. The developer can find details in the server logs. The attacker should not be able to infer your role structure from error responses.
Production Insight
A team's API was returning Spring Boot's default error response for all unmapped URLs — a JSON object with 'status', 'error', 'message', and 'path' in a different structure from their custom error DTO. The frontend team was handling two different error formats: one for controller-level errors from @RestControllerAdvice and one for unmapped URL errors from BasicErrorController. Every time a developer made a typo in an API path on the frontend, the error handler crashed because it received an unexpected JSON structure. Adding NoHandlerFoundException handling in the global advice class unified the format and the frontend error handler reduced from 80 lines to 30.
Key Takeaway
NoHandlerFoundException and HttpRequestMethodNotSupportedException are HTTP-level errors that your global handler should explicitly cover — otherwise Spring's BasicErrorController returns a different JSON format that breaks frontend error parsers.
Enable spring.mvc.throw-exception-if-no-handler-found=true to convert unmapped URL requests into catchable exceptions rather than routing them to BasicErrorController.
Security exception messages should be deliberately vague — 'Access denied' without specifics, not 'User lacks ADMIN role' — to prevent permission model disclosure.

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.

io/thecodeforge/exception/GlobalExceptionHandlerTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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));
    }
}
Output
// Test execution output:
// 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
Assert That Stack Traces Are Absent, Not Just That the Status Is Correct
The most critical security assertion in exception handler tests is verifying that the response does NOT contain internal exception details. jsonPath("$.trace").doesNotExist() and jsonPath("$.exception").doesNotExist() verify that Spring Boot's default error attributes — which include the full exception class name and message — are not leaking through. Spring Boot's default error response includes a 'trace' field when server.error.include-stacktrace=always is set. Verify this is not present in your custom response format, not just that your expected fields are present.
Production Insight
A team's exception handler tests only asserted HTTP status codes — they did not verify response body structure. A Spring Boot upgrade changed the default error attributes format, which caused BasicErrorController to start including a 'trace' field in responses that the team's exception handler was not suppressing. Security scanning caught a full stack trace being returned to clients in production — including JVM version, library versions, and internal package structure. The fix was straightforward, but the disclosure had already occurred. Tests that assert specific fields are present and assert that 'trace' and 'exception' are absent would have caught this immediately in CI.
Key Takeaway
Test exception handlers with @WebMvcTest — fast, focused, and exercises the full AOP interception chain without loading the full application context.
Assert that stack traces are absent from responses, not just that expected fields are present — a missing assertion on jsonPath("$.trace").doesNotExist() allows security-relevant information to leak silently.
Test the exception propagation path: mock the service to throw the exception, verify the handler produces the correct response — this catches exception swallowing that unit tests on the handler class directly cannot detect.
● Production incidentPOST-MORTEMseverity: high

The 200 OK for a Failed Payment — Swallowed Exceptions in the Service Layer

Symptom
Customer support started receiving complaints that payments were being taken from bank accounts but orders were never created. The payment endpoint logs showed 200 OK on every single call. No exceptions appeared anywhere in the application logs. The frontend displayed 'Payment Successful' for every request regardless of what the database was actually doing. The issue had been in production for 6 hours before customer complaints surfaced it — the monitoring was clean because 200 is not an alert condition.
Assumption
The engineering team's first assumption was a bug in the external payment gateway integration — the gateway was returning success codes for transactions it was actually rejecting. They spent three hours auditing the gateway client, comparing request and response payloads, and checking the gateway's own dashboard. The gateway was working correctly. It was rejecting certain cards with decline codes that the integration was receiving and processing without issue. The problem was entirely internal.
Root cause
A developer had added a try-catch block in the payment service method two weeks earlier during a hotfix for an unrelated database timeout issue. The catch block caught SQLException broadly, logged a single-line warning without the stack trace, and returned a new PaymentResponse object initialized with status=SUCCESS as a 'safe default.' The intent was to prevent the application from crashing during the timeout incident. The consequence was that any database failure — including the constraint violations that happen when a duplicate payment is detected — was silently converted into a success response. @RestControllerAdvice never saw these exceptions because they never propagated past the service layer. The controller received a success object and returned HTTP 200 with a success body.
Fix
The try-catch was removed from the service layer entirely. Database exceptions were allowed to propagate to the controller. @RestControllerAdvice caught them and returned HTTP 500 with a structured error response. A custom ForgePaymentException was created for payment-specific failures with a machine-readable error code (PAYMENT_DECLINED, PAYMENT_DUPLICATE) so the frontend could handle different failure modes distinctly. Integration tests were added that specifically assert the HTTP status code for failure scenarios — tests that would have caught the original swallowed exception immediately. A code review rule was added requiring explicit justification for any try-catch in the service layer.
Key lesson
  • 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
Production debug guideWhen global exception handling behaves unexpectedly, here is the diagnostic sequence I use — ordered by frequency of occurrence.6 entries
Symptom · 01
Client receives 200 OK for a failed operation instead of 4xx or 5xx
Fix
Search the service layer for try-catch blocks that return default objects instead of re-throwing. grep -rn 'catch.Exception' src/main/java --include='.java' | grep -v 'throw\|log'. Every catch block that does not re-throw the exception or throw a different exception is a potential 200 OK trap. The @RestControllerAdvice only intercepts exceptions that propagate all the way to the controller — anything caught before that point is invisible to it.
Symptom · 02
Client receives a raw Java stack trace instead of a structured JSON error response
Fix
Verify your @RestControllerAdvice has a catch-all @ExceptionHandler(Exception.class) method. Without it, Spring Boot's default BasicErrorController handles unmatched exceptions and returns its own format — which in some configurations includes the full exception message and trace. Also verify the advice class is in the component scan path: if @SpringBootApplication is in io.thecodeforge.app and your advice is in com.other.exception, Spring never registers it.
Symptom · 03
MethodArgumentNotValidException returns Spring's default error format instead of your custom format
Fix
Add a specific @ExceptionHandler(MethodArgumentNotValidException.class) in your @RestControllerAdvice. Without this handler, Spring's BasicErrorController intercepts validation failures and returns its own JSON structure with a 'errors' array that differs from your custom format. Verify the controller method parameter has @Valid or @Validated annotation — without either, Spring does not invoke the validator at all and MethodArgumentNotValidException is never thrown regardless of your handler.
Symptom · 04
Multiple @RestControllerAdvice classes — handler execution order is unpredictable across deployments
Fix
Add @Order(Ordered.HIGHEST_PRECEDENCE) to the most specific handler class. The more durable fix is consolidating into a single @RestControllerAdvice class — ordering between two advice classes is not guaranteed without explicit @Order, and the behavior can differ between Spring Boot patch versions. If consolidation is not feasible immediately, verify the @Order values are distinct and document why each class exists.
Symptom · 05
Custom exception is not caught by the specific @ExceptionHandler — falls through to catch-all or Spring default
Fix
Verify three things in sequence: (1) the @ExceptionHandler annotation specifies the exact exception class, not a superclass that would also match other exceptions, (2) the exception class is in the component scan path and is not in a module that the advice class cannot see, (3) the exception is actually the type you think it is — wrap the exception catch in a log statement that prints ex.getClass().getName() to confirm the runtime type before assuming the compile-time type.
Symptom · 06
Validation errors return 200 OK with an empty body instead of 400 with field-level errors
Fix
Verify the controller method parameter has @Valid or @Validated before @RequestBody. These annotations are the trigger that tells Spring to invoke the constraint validator before calling the method. Without them, Spring passes the deserialized object directly to the method without checking any @NotNull, @Email, or @Size constraints. The DTO constraints are defined but never evaluated.
Traditional Try-Catch vs. Global @RestControllerAdvice
AspectTraditional Try-CatchGlobal @RestControllerAdvice
Code CleanlinessTry-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 performedController methods contain only the happy path — exception handling is completely removed from business logic and centralized in one class
ConsistencyError response format varies by developer, controller, and when the code was written — frontend must handle multiple response shapes for the same failure categorySingle source of truth for all API error responses — every exception of the same type returns the same JSON structure from every endpoint
MaintenanceHigh: adding a new error field or changing the timestamp format requires updating every try-catch block across every controller — easy to miss oneLow: change one handler method to update the error format for every endpoint that throws that exception type — one change, immediate consistency
HTTP Status MappingManual and error-prone: response.setStatus(404) or ResponseEntity.status(404) written individually in each catch block — one wrong value returns the wrong statusDeclarative and centralized: @ResponseStatus or ResponseEntity on the handler method defines the status once for all occurrences of that exception type
Exception VisibilityExceptions caught in catch blocks are invisible to logging infrastructure unless explicitly logged — easy to accidentally swallow without loggingCentralized handlers are the single logging point — impossible to forget logging because it is in one place, and the pattern is applied consistently
Frontend ContractFrontend engineers must discover the error format for each endpoint individually — no guarantee that two endpoints have the same structureFrontend engineers write one error handler for the entire API — the contract is defined in one class and applies universally

Key takeaways

1
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.
2
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.
3
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.
4
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.
5
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.
6
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

6 patterns
×

Forgetting to log the exception in the global handler before returning the sanitized response

Symptom
A 500 error is returned to the client with a traceId but no matching log entry exists on the server. Support teams cannot find the cause of the error. Debugging requires reproducing the issue rather than reading an existing log entry. The developer cannot diagnose the bug because the sanitized response contains no internal details and the server logs contain no record of what happened.
Fix
Add logger.error("Unhandled exception [traceId={}, path={}]", traceId, request.getRequestURI(), 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

Symptom
API returns 200 OK for failed operations. The frontend displays success messages for requests that failed at the database level. No errors appear in application logs. Data corruption occurs because failed writes appear to have succeeded. This is the most dangerous common mistake because monitoring systems see only 200s and raise no alerts.
Fix
Remove catch blocks from the service layer unless you are explicitly converting exceptions (catching SQLException and re-throwing as ForgeDatabaseException is legitimate — catching it and returning a default object is not). Let exceptions propagate naturally to the controller where @RestControllerAdvice handles them. If exception conversion is needed, always re-throw: catch (SQLException e) { throw new ForgeDatabaseException("Query failed", "ERR_DB_001", e); }. The original cause should be passed to the new exception's constructor so the stack trace chain is preserved in logs.
×

Returning raw stack traces to the client in production

Symptom
Client receives a full Java stack trace with class names (io.thecodeforge.service.PaymentService), method names, line numbers, Spring library versions, JVM version, and internal package structure. Security scanners flag this as an information disclosure vulnerability. Attackers use this information to identify the specific library versions in use and target known CVEs.
Fix
The @ExceptionHandler(Exception.class) catch-all handler must return only a sanitized message like 'An unexpected error occurred. Contact support with reference: [traceId]'. Include the traceId so support can find the server-side stack trace but never include the trace itself in the response. Verify server.error.include-stacktrace is NOT set to always in application.properties — the default (never) is correct for production.
×

Missing @Valid annotation on controller method parameters

Symptom
DTO constraints (@NotNull, @Email, @Size, @Pattern) are defined on the DTO class and look correct. Invalid data passes through to the service layer without rejection. MethodArgumentNotValidException is never thrown. Invalid emails reach the database, missing required fields cause NullPointerException deeper in the stack, and the exception handler for MethodArgumentNotValidException is tested but never invoked in practice.
Fix
Add @Valid before every @RequestBody parameter that carries constraints: public ResponseEntity<?> create(@Valid @RequestBody UserRegistrationDto dto). Without @Valid, Spring deserializes the JSON into the DTO object but does not invoke the Bean Validation engine. All constraint annotations on the DTO class are ignored. For path variables and query parameters, use @Validated at the class level on the controller and apply constraints directly on the method parameters.
×

Not using specific custom exceptions — throwing generic RuntimeException or IllegalArgumentException

Symptom
Every error returns the same generic 500 response. The frontend cannot distinguish between 'user not found' (should be 404), 'validation failed' (should be 400), 'duplicate entry' (should be 409), and 'database down' (should be 500). All four return identical HTTP 500 with 'An unexpected error occurred.' Frontend engineers cannot implement specific recovery flows — every error shows the same generic message to the user.
Fix
Create a custom exception class for each error category that maps to a distinct HTTP status and distinct frontend behavior. Minimum: ForgeResourceNotFoundException (404), ForgeValidationException (400 for programmatic validation outside @Valid), ForgeConflictException (409), ForgeBusinessRuleException (422). The exception class name and errorCode field together give the global handler enough information to return the correct status and the frontend enough information to display the correct recovery instructions.
×

Placing @RestControllerAdvice in a package outside the component scan path

Symptom
Exception handlers are defined, annotated, and syntactically correct but never invoked. Every exception returns Spring Boot's default error format — sometimes HTML for REST clients, sometimes a different JSON structure. Developers add more exception handlers thinking the problem is a missing handler type, making no progress.
Fix
Verify the advice class is in the same package tree as @SpringBootApplication. If @SpringBootApplication is in io.thecodeforge.app, the advice must be in io.thecodeforge.app or any sub-package. If the advice is in a separate module or library, use @ComponentScan to include its package explicitly, or use @Import to register it directly. Confirm registration with debug=true — the advice class should appear in the bean list at startup.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between @ControllerAdvice and @RestControllerAdvi...
Q02SENIOR
How does Spring Boot decide which @ExceptionHandler to use when multiple...
Q03SENIOR
What is ResponseStatusException and when would you use it instead of cre...
Q04SENIOR
How do you handle requests for URLs that match no controller mapping — t...
Q05SENIOR
Describe the ErrorController interface and how it differs from @Exceptio...
Q01 of 05SENIOR

Explain the difference between @ControllerAdvice and @RestControllerAdvice. When would you use each?

ANSWER
@ControllerAdvice is a specialization of @Component that marks a class as a global exception handler applicable across the entire application. Handler methods return view names by default — suitable for traditional MVC applications that render Thymeleaf templates or JSP views where an error results in rendering an error page. @RestControllerAdvice is a composed annotation that combines @ControllerAdvice and @ResponseBody. Every handler method automatically serializes its return value to JSON (or XML, depending on content negotiation) and writes it directly to the HTTP response body. No view resolution occurs. For REST APIs — which is the vast majority of Spring Boot applications built today — @RestControllerAdvice is the correct choice. A practical way to remember the distinction: @ControllerAdvice is for 'show an error page,' @RestControllerAdvice is for 'return an error JSON.' If your application serves both server-rendered HTML and REST endpoints, you may use both — @ControllerAdvice for controller-level view controllers and @RestControllerAdvice for @RestController endpoints — but the scope can be narrowed with the assignableTypes, basePackages, or annotations attributes on each annotation.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is the difference between @ControllerAdvice and @RestControllerAdvice?
02
How does Spring choose which @ExceptionHandler to call when an exception is thrown?
03
Can I have multiple @RestControllerAdvice classes in one application?
04
How do I handle validation errors with @RestControllerAdvice and return field-level detail to the frontend?
05
What should a production-ready error response contain?
06
Why does my custom exception handler sometimes not catch exceptions thrown in the service layer?
🔥

That's Spring Boot. Mark it forged?

6 min read · try the examples if you haven't

Previous
Spring Boot with MySQL and JPA
7 / 15 · Spring Boot
Next
Spring Boot Validation with Bean Validation API