Skip to content
Home Java Spring Boot Global Exception Handling: The Complete Guide

Spring Boot Global Exception Handling: The Complete Guide

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Spring Boot → Topic 7 of 15
Master Spring Boot Exception Handling using @RestControllerAdvice and @ExceptionHandler.
⚙️ Intermediate — basic Java knowledge assumed
In this tutorial, you'll learn
Master Spring Boot Exception Handling using @RestControllerAdvice and @ExceptionHandler.
  • Global exception handling decouples error response formatting from business logic — controller methods contain only the happy path, the advice class contains all error handling. This is the Single Responsibility Principle applied at the web layer.
  • Use @RestControllerAdvice to produce a unified JSON error structure from every endpoint — a consistent contract that frontend teams can depend on without defensive code for multiple response shapes.
  • Standardize every error response with the same fields: timestamp (UTC Instant), status (numeric HTTP code), error (short label), message (human-readable), errorCode (machine-readable for frontend behavior decisions), path (request URI), and traceId on 5xx responses for support correlation.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
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
Production IncidentThe 200 OK for a Failed Payment — Swallowed Exceptions in the Service LayerA payment service caught a database exception in a try-catch block and returned a default object instead of re-throwing. The API returned 200 OK for every failed payment. Customers were charged but the system recorded no transaction.
SymptomCustomer 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.
AssumptionThe 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 causeA 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.
FixThe 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 responseA 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 wrongAlways write integration tests that assert specific error HTTP status codes for failure scenarios — unit tests on the happy path do not catch exception swallowingCustom 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 messageSingle-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.
Client receives 200 OK for a failed operation instead of 4xx or 5xxSearch 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.
Client receives a raw Java stack trace instead of a structured JSON error responseVerify 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.
MethodArgumentNotValidException returns Spring's default error format instead of your custom formatAdd 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.
Multiple @RestControllerAdvice classes — handler execution order is unpredictable across deploymentsAdd @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.
Custom exception is not caught by the specific @ExceptionHandler — falls through to catch-all or Spring defaultVerify 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.
Validation errors return 200 OK with an empty body instead of 400 with field-level errorsVerify 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.

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.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
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)
Mental Model
@RestControllerAdvice Is a Safety Net, Not a Bouncer
The advice class sits behind all controllers and catches anything that falls through — it does not prevent exceptions from happening, it standardizes what the client receives when they do.
  • @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.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
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.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// 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.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// 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.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
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.
🗂 Traditional Try-Catch vs. Global @RestControllerAdvice
Global exception handling decouples error response formatting from business logic. The trade-off is centralized control for all endpoints versus per-method flexibility for special cases.
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

  • Global exception handling decouples error response formatting from business logic — controller methods contain only the happy path, the advice class contains all error handling. This is the Single Responsibility Principle applied at the web layer.
  • Use @RestControllerAdvice to produce a unified JSON error structure from every endpoint — a consistent contract that frontend teams can depend on without defensive code for multiple response shapes.
  • Standardize every error response with the same fields: timestamp (UTC Instant), status (numeric HTTP code), error (short label), message (human-readable), errorCode (machine-readable for frontend behavior decisions), path (request URI), and traceId on 5xx responses for support correlation.
  • Never catch exceptions in the service layer without re-throwing — a swallowed exception returns 200 OK for a failed operation, which is operationally worse than a 500 and harder to detect through monitoring.
  • Always log the full exception with the SLF4J ex parameter (log.error("msg", ex)) before returning a sanitized response — WARN for 4xx, ERROR for 5xx. The client never sees the trace; the developer always finds it in the logs.
  • Test exception handlers with @WebMvcTest and assert both presence of expected fields and absence of stack trace fields — jsonPath("$.trace").doesNotExist() is as important as jsonPath("$.status").value(404).

⚠ Common Mistakes to Avoid

    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 Questions on This Topic

  • QExplain the difference between @ControllerAdvice and @RestControllerAdvice. When would you use each?Mid-levelReveal
    @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.
  • QHow does Spring Boot decide which @ExceptionHandler to use when multiple handlers could match a thrown exception?SeniorReveal
    Spring's ExceptionHandlerExceptionResolver selects the most specific match based on the exception class hierarchy. If ForgeResourceNotFoundException extends ForgeException extends RuntimeException extends Exception, and you have handlers for ForgeResourceNotFoundException and Exception, Spring selects ForgeResourceNotFoundException because it is the closest match in the class hierarchy to the actual thrown type. The matching algorithm evaluates handlers within the same @RestControllerAdvice class first. If no match is found in that class, it checks other advice classes. Within the same class, if two handlers match at the same specificity level — which only happens if you have two handlers for exception types at the same distance from the thrown type — the behavior is undefined. This situation should be avoided by ensuring each handler covers a distinct exception type. Across multiple @RestControllerAdvice classes without @Order, the selection order is not deterministic. With @Order, the class with the lowest ordinal value (highest precedence) is checked first. In practice, the safest design is a single @RestControllerAdvice class where handler specificity is the only selection criterion, making the behavior fully predictable.
  • QWhat is ResponseStatusException and when would you use it instead of creating a custom exception class?Mid-levelReveal
    ResponseStatusException is a programmatic alternative to custom exception classes that carries an HTTP status and an optional reason phrase. Throwing new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found") produces a 404 with the message — no custom class required, no @ExceptionHandler needed. Use it for simple, one-off scenarios where creating a dedicated exception class is disproportionate to the use case — a prototype, a utility endpoint, or a genuinely unique case that will not recur. The advantage is zero setup: no new class, no handler method. Avoid it as a general pattern for three reasons: (1) it bypasses your @RestControllerAdvice and returns Spring's default error format rather than your custom error DTO, so the response structure differs from every other error your API returns, (2) it couples the HTTP status decision to the service layer, which should not know about HTTP, and (3) it provides no machine-readable errorCode field, making it harder for frontends to implement specific recovery behavior. For anything beyond a truly one-off case, a custom exception class with a dedicated @ExceptionHandler produces a more consistent and maintainable result.
  • QHow do you handle requests for URLs that match no controller mapping — the classic unmapped 404 scenario?SeniorReveal
    @RestControllerAdvice and @ExceptionHandler only intercept exceptions thrown within the Spring MVC dispatch chain. A request for a URL that matches no controller mapping produces a NoHandlerFoundException — but only if you configure it to do so. By default, Spring Boot routes unmapped URLs to its own WhiteLabel error page or BasicErrorController, bypassing your advice class entirely. To route unmapped URL errors through your @RestControllerAdvice: set spring.mvc.throw-exception-if-no-handler-found=true and spring.web.resources.add-mappings=false in application.properties. The second property prevents Spring's static resource handler from absorbing unmapped requests before NoHandlerFoundException can be thrown. Then add @ExceptionHandler(NoHandlerFoundException.class) to your advice class. An alternative is implementing the ErrorController interface, which replaces BasicErrorController entirely. This gives you control over the /error fallback endpoint that handles all unresolved errors including filter-level exceptions, but it requires more implementation code and does not integrate as naturally with the @ExceptionHandler model. For filter-level exceptions — authentication failures from Spring Security filters, rate limiting responses — neither approach works because these happen before the DispatcherServlet processes the request. These require custom filter-level error handling or Spring Security's AuthenticationEntryPoint and AccessDeniedHandler configuration.
  • QDescribe the ErrorController interface and how it differs from @ExceptionHandler in scope and timing.SeniorReveal
    ErrorController is a Spring Boot interface that takes over the /error endpoint — the fallback that the servlet container forwards to when an error occurs that Spring MVC cannot resolve. Implementing ErrorController replaces Spring Boot's default BasicErrorController. @ExceptionHandler operates within the Spring MVC dispatch chain — it intercepts exceptions thrown by controller methods, interceptors, and handler adapters. It runs after the DispatcherServlet has received the request and matched it to a controller. ErrorController operates at the servlet container level — it catches errors that occur before or outside the Spring MVC dispatch chain: requests for unmapped URLs forwarded by the servlet container, filter-level exceptions, and errors that Spring's HandlerExceptionResolver chain could not resolve. In a production REST API you typically need both: @ExceptionHandler (via @RestControllerAdvice) for the vast majority of application-level exceptions, and ErrorController for the edge cases that fall through the MVC chain entirely. Without ErrorController, unmapped URLs and filter-level errors return Spring Boot's default format which may differ structurally from your custom error DTO, causing frontend parsing failures for these edge cases.

Frequently Asked Questions

What is the difference between @ControllerAdvice and @RestControllerAdvice?

@ControllerAdvice marks a class as a global handler where methods return view names for server-rendered HTML responses — suitable for traditional MVC applications with Thymeleaf or JSP. @RestControllerAdvice combines @ControllerAdvice with @ResponseBody, so every handler method automatically serializes its return value to JSON and writes it to the HTTP response body. For REST APIs, always use @RestControllerAdvice — using @ControllerAdvice on a REST API advice class requires adding @ResponseBody to every individual handler method, which is exactly what @RestControllerAdvice eliminates.

How does Spring choose which @ExceptionHandler to call when an exception is thrown?

Spring selects the most specific match based on the exception class hierarchy. If ForgeResourceNotFoundException extends RuntimeException and you have handlers for both ForgeResourceNotFoundException and Exception, Spring calls the ForgeResourceNotFoundException handler because it is closer to the actual thrown type in the class hierarchy. Within the same advice class, the algorithm is deterministic — most specific wins. Across multiple advice classes without @Order annotations, the selection order is not guaranteed. Consolidating all handlers into one @RestControllerAdvice class eliminates cross-class ordering ambiguity entirely.

Can I have multiple @RestControllerAdvice classes in one application?

Technically yes, but Spring does not guarantee execution order without explicit @Order annotations. The ordering can change between Spring Boot minor versions, making the behavior of multiple advice classes fragile across upgrades. Best practice: consolidate all exception handlers into a single @RestControllerAdvice class. The compiler prevents two methods with the same @ExceptionHandler type in the same class, which eliminates ordering ambiguity. If the class grows large, organize handlers into clearly commented groups within the single class — that is a readability concern, not a reason to split into multiple advice classes.

How do I handle validation errors with @RestControllerAdvice and return field-level detail to the frontend?

Add @ExceptionHandler(MethodArgumentNotValidException.class) to your advice class. Extract field errors from ex.getBindingResult().getFieldErrors() and map them to a Map<String, String> of field name to error message. Also extract class-level errors from ex.getBindingResult().getGlobalErrors() for constraints like @PasswordMatches that apply to the whole object rather than a single field — these are commonly missed. Return both in the response body with HTTP 400. The controller method parameter must have @Valid or @Validated annotation — without it, Spring never invokes the Bean Validation engine and MethodArgumentNotValidException is never thrown.

What should a production-ready error response contain?

A production error response should contain six fields at minimum: (1) timestamp — an ISO 8601 UTC instant (use Instant.now().toString(), not LocalDateTime, which is server-local), (2) status — the numeric HTTP status code for programmatic parsing, (3) error — a short, consistent label like 'Resource Not Found' or 'Validation Failed', (4) message — a human-readable description suitable for display, (5) path — the request URI that produced the error, (6) errorCode — a machine-readable identifier like ERR_RES_404 or PAYMENT_DUPLICATE for frontend conditional logic and support ticket categorization. For 500 responses, add a traceId — a UUID that links the client-visible error to the server-side log entry. Never include stack traces, class names, library versions, or line numbers.

Why does my custom exception handler sometimes not catch exceptions thrown in the service layer?

Two common causes. First and most frequently: the exception is caught somewhere in the call chain between the service method and the controller and not re-thrown. A try-catch in the service layer that returns a default value or a different object type swallows the exception before it reaches the controller, and @RestControllerAdvice never sees it. Search for catch blocks that do not contain a throw statement. Second: the exception type thrown at runtime is not the type you expect. A @Transactional method wraps exceptions differently — Spring may wrap your exception in a different exception type. Log ex.getClass().getName() in the catch-all handler to confirm the actual runtime type before assuming the handler is not registered.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousSpring Boot with MySQL and JPANext →Spring Boot Validation with Bean Validation API
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged