Good Spring Boot exception handling means your API fails predictably. Out of the box, an unhandled exception gives the client either a wall of HTML (the Whitelabel Error Page) or a JSON blob that leaks your package names and stack trace. Both are bad: ugly for your frontend, and a small gift to attackers. The fix is to handle errors in one place with @RestControllerAdvice and return a consistent error body. Here’s the version I actually ship.

1. The error response body

Start with a DTO your frontend can rely on — same shape for every error, every time:

package com.coderboi.demo.api;

import java.time.Instant;

public record ApiError(String message, int status, String path, Instant timestamp) {}

A record is perfect here: immutable, no boilerplate, serializes straight to JSON. The contract is what matters — your client should be able to read message, status, and path without guessing.

2. The global exception handler

@RestControllerAdvice applies across every controller. Catch what you care about specifically, and keep one catch-all so nothing escapes as a stack trace:

package com.coderboi.demo.web;

import com.coderboi.demo.api.ApiError;
import jakarta.servlet.http.HttpServletRequest;
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;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ApiError> handleBadRequest(
            IllegalArgumentException ex,
            HttpServletRequest request) {
        var error = new ApiError(
                ex.getMessage(),
                HttpStatus.BAD_REQUEST.value(),
                request.getRequestURI(),
                Instant.now()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleEverythingElse(
            Exception ex,
            HttpServletRequest request) {
        var error = new ApiError(
                "Something went wrong. We logged it. Maybe.",
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                request.getRequestURI(),
                Instant.now()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

Now a bad request returns {"message":"...","status":400,"path":"/api/...","timestamp":"..."} instead of chaos. Two rules make this robust:

  • The catch-all message is generic on purpose. Never echo ex.getMessage() from the 500 handler — that’s how internal details leak. Log the real exception server-side; tell the client only “something went wrong.”
  • Specific beats general. Spring picks the handler for the most specific matching exception type, so your IllegalArgumentException handler wins over the Exception catch-all.

3. Handle validation errors properly

The most common “400” isn’t a thrown IllegalArgumentException — it’s bean validation failing on an @Valid request body, which throws MethodArgumentNotValidException. Handle it so clients learn which field was wrong:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
    Map<String, String> errors = new HashMap<>();
    ex.getBindingResult().getFieldErrors()
        .forEach(err -> errors.put(err.getField(), err.getDefaultMessage()));
    return ResponseEntity.badRequest().body(errors);
}

This is the difference between “400 Bad Request” (useless) and “email must be a valid address” (actionable).

4. Custom exceptions for your domain

Throw meaningful exceptions from your services and map each to a status:

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) { super(message); }
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
    var error = new ApiError(ex.getMessage(), HttpStatus.NOT_FOUND.value(),
            request.getRequestURI(), Instant.now());
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}

Your service does throw new ResourceNotFoundException("Order " + id + " not found") and the advice turns it into a clean 404. Business logic stays readable; HTTP concerns live in one place.

5. The standards-based option: ProblemDetail

Spring Boot 3 ships ProblemDetail and ErrorResponse based on RFC 9457 (Problem Details for HTTP APIs). If you’d rather follow the standard than roll your own DTO, extend ResponseEntityExceptionHandler and return ProblemDetail objects with type, title, status, detail, and instance. It interops well with clients that already understand the format. A custom DTO (like ApiError above) gives you full control; ProblemDetail gives you a standard. Pick one and be consistent.

Common pitfalls

  • Leaking ex.getMessage() on 500s. Generic message to the client; full detail to the logs.
  • No catch-all. One unmapped exception and you’re back to the Whitelabel page.
  • Swallowing exceptions silently. Always log before returning — a clean 500 with no log entry is a debugging nightmare.
  • Inconsistent error shapes. If /orders and /users return different error JSON, every client integration hurts. One DTO, everywhere.
  • Wrong status codes. Validation = 400, auth = 401/403, missing = 404, your-fault = 500. Returning 200 with an error body breaks every HTTP client.

FAQ

@ControllerAdvice vs @RestControllerAdvice — what's the difference?
@RestControllerAdvice = @ControllerAdvice + @ResponseBody, so handler return values are serialized to JSON automatically. Use it for REST APIs.
Does the advice apply to every controller?
Yes, by default it’s global. You can scope it with @RestControllerAdvice(basePackages = ...) or by annotation if you need per-area handling.
How do I hide stack traces but still debug?
Return a generic body to the client and log the full exception (with a correlation/trace id) server-side. Never put stack traces in the response.
Should I use ProblemDetail or a custom DTO?
ProblemDetail if you want the RFC 9457 standard and client interop; a custom record if you want a bespoke shape. Don’t mix both in one API.

Key takeaway: Centralize Spring Boot exception handling in a single @RestControllerAdvice: specific handlers for known cases (validation, not-found, auth), a generic catch-all that logs internally and never leaks details, and one consistent error body. Pair it with proper status codes and you’ll never debug a Whitelabel page again.

Securing the same API? The clean 401/403 responses from Spring Boot JWT auth flow through this exact handler.