Spring Boot Global Exception Handling
The “Spaghetti Catch” Anti-Pattern
If I review a Junior Developer’s code and see a try-catch block inside a @RestController, I immediately request changes.
Why? Because mixing business logic with error handling logic is the fastest way to create unmaintainable “Spaghetti Code.” In 2026, your controllers should be “Happy Paths” only. They should assume everything works perfectly. If something breaks, the infrastructure should catch it, format it, and inform the client.
This guide covers the Global Exception Handling pattern using Spring Boot 3.4+. We will move beyond the legacy ErrorResponse POJOs and adopt the industry-standard RFC 7807 ProblemDetail specification which is now native to Spring Framework 6.
1. The Architecture: Centralized vs. Distributed Handling
To architect a robust API, you must separate Detection from Reporting.
- Detection: Happens deep in your Service layer (e.g., “User ID 500 not found”).
- Reporting: Happens at the API layer (e.g., “Return HTTP 404 with JSON”).
Instead of catching the error in the service and returning a null, simply throw a Runtime Exception. A central “Watchtower” (The @ControllerAdvice) will spot that exception flying through the air, catch it, and transform it into a standardized JSON response.
2. The New Standard: RFC 7807 (ProblemDetail)
Before Spring Boot 3, everyone wrote their own ErrorResponse class.
- Developer A returned:
{ "error": "Not Found" } - Developer B returned:
{ "message": "Missing ID", "code": 404 } - The Frontend Team: Hated us.
In 2026, we use RFC 7807. It is a standardized JSON format for API problems. Spring 6 supports it natively via the ProblemDetail class.
Standard Output:
{
"type": "https://api.mysite.com/errors/user-not-found",
"title": "User Not Found",
"status": 404,
"detail": "User with ID 123 does not exist in our records.",
"instance": "/users/123",
"traceId": "65b93d3d7f0223"
}
3. Implementation: The @ControllerAdvice
Let’s build the “Watchtower.” This class sits globally across your application and intercepts exceptions from any controller.
Step 1: The Global Handler Class
Create a class annotated with @RestControllerAdvice. This is an Aspect-Oriented Programming (AOP) component.
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.net.URI;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// 1. Handle Specific Custom Exceptions
@ExceptionHandler(UserNotFoundException.class)
ProblemDetail handleUserNotFoundException(UserNotFoundException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problemDetail.setTitle("User Not Found");
problemDetail.setType(URI.create("https://api.mysite.com/errors/not-found"));
return problemDetail;
}
// 2. Handle Logic/State Exceptions
@ExceptionHandler(InsufficientFundsException.class)
ProblemDetail handleInsufficientFunds(InsufficientFundsException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
problemDetail.setTitle("Transaction Failed");
problemDetail.setProperty("currentBalance", ex.getCurrentBalance()); // Adding custom fields!
return problemDetail;
}
// 3. Handle Everything Else (The Safety Net)
@ExceptionHandler(Exception.class)
ProblemDetail handleGlobalException(Exception ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An internal error occurred.");
problemDetail.setTitle("Internal Server Error");
return problemDetail;
}
}
Step 2: Custom Exceptions (Keep them Simple)
Do not bloat your exceptions. They are just signal flares.
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long id) {
super("User with ID " + id + " does not exist.");
}
}
4. Handling Validation Errors (The Tricky Part)
When a user sends bad JSON (e.g., missing email), Spring throws a MethodArgumentNotValidException. We need to override the default behavior to list which fields failed.
Add this method to your GlobalExceptionHandler:
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed for request.");
problemDetail.setTitle("Validation Error");
// Collect all field errors into a Map
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
problemDetail.setProperty("fieldErrors", errors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail);
}
5. Security Exceptions: The Common Pitfall
Architect’s Note: @ControllerAdvice typically does not catch exceptions thrown by Spring Security filters (like 401 Unauthorized or 403 Forbidden) because those happen before the Controller is even reached.
To handle those globally using the same JSON format, you must configure an AuthenticationEntryPoint.
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
String json = """
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "%s"
}
""".formatted(authException.getMessage());
response.getWriter().write(json);
}
}
6. FAQ – Senior Developer Edition
Q: Should I return a 200 OK with an error code inside the JSON? A: Never. This is the “GraphQL style” but it breaks REST semantics. If a resource is not found, the HTTP status must be 404. If you return 200 OK for an error, intermediate proxies and caches will cache that error page as a success, causing massive production issues.
Q: How do I include the Trace ID in the error response? A: If you are using Micrometer Tracing (the default in Spring Boot 3), ProblemDetail handles this well. You can extend the ProblemDetail or simply add a property: problemDetail.setProperty("traceId", Tracer.currentSpan().context().traceIdString());. This allows Ops teams to copy the ID from the JSON response and paste it directly into Grafana/Splunk.
Q: Is ProblemDetail mandatory in Spring Boot 3? A: It is not mandatory (you can still use POJOs), but it is enabled by default. If you throw a ResponseStatusException, Spring will render it as a ProblemDetail JSON automatically.
Conclusion: The “Zero-Catch” Policy
In 2026, your goal as an architect is to implement a Zero-Catch Policy in your business logic.
- Service throws Exception.
- Controller ignores it (lets it bubble up).
GlobalExceptionHandlercatches, logs, and formats it.
This results in cleaner code, consistent API responses, and a frontend team that no longer sends you angry Slack messages about parsing random error strings.

For over 15 years, I have worked as a hands-on Java Architect and Senior Engineer, specializing in building and scaling high-performance, enterprise-level applications. My career has been focused primarily within the FinTech, Telecommunications, or E-commerce sector, where I’ve led teams in designing systems that handle millions of transactions per day.
Checkout my profile here : AUTHOR https://simplifiedlearningblog.com/author/