Definition
@RestControllerAdvice(or@ControllerAdvice) is a class-level annotation that acts as a global exception handler for all controllers in the application.@ExceptionHandler(XException.class)on a method catches a specific exception type thrown by any controller and returns a structured error response.@ResponseStatus(HttpStatus.NOT_FOUND)on the handler method sets the HTTP status code.ResponseStatusException(Spring 5) is an inline alternative:throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Trade not found").- The result: controllers have zero try-catch blocks. All exceptions propagate to the global handler, which returns consistent error responses across the entire API.
Instead of every teller at National Bank of India having their own error procedure, the bank has a central complaints desk (@ControllerAdvice). Any complaint from any counter goes to this desk. The desk handles each type: insufficient funds returns 400, trade not found returns 404, and system error returns 500. Tellers just do their job without worrying about error handling.
Understanding the Concepts
What is @RestControllerAdvice?
@RestControllerAdvice is a class-level annotation that combines @ControllerAdvice and @ResponseBody. It acts as a global exception handler for all @RestController classes in the application.
package com.bank.web;
import java.time.Instant;
import java.math.BigDecimal;
// ErrorResponse DTO for consistent API error format
public class ErrorResponse {
private final String error;
private final String message;
private final int status;
private final Instant timestamp;
public ErrorResponse(String error, String message, int status) {
this.error = error;
this.message = message;
this.status = status;
this.timestamp = Instant.now();
}
public String getError() { return error; }
public String getMessage() { return message; }
public int getStatus() { return status; }
public Instant getTimestamp() { return timestamp; }
public static void main(String[] args) {
ErrorResponse response = new ErrorResponse("NOT_FOUND", "Trade TRD-001 not found", 404);
System.out.println("Error: " + response.getError());
System.out.println("Message: " + response.getMessage());
System.out.println("Status: " + response.getStatus());
}
}
Error: NOT_FOUND
Message: Trade TRD-001 not found
Status: 404
How do you implement a global exception handler?
Create one class annotated with @RestControllerAdvice containing @ExceptionHandler methods for each exception type.
package com.bank.web;
// @RestControllerAdvice
// public class GlobalExceptionHandler {
//
// @ExceptionHandler(TradeNotFoundException.class)
// @ResponseStatus(HttpStatus.NOT_FOUND)
// public ErrorResponse handleNotFound(TradeNotFoundException e) {
// return new ErrorResponse("NOT_FOUND", e.getMessage(), 404);
// }
//
// @ExceptionHandler(InsufficientBalanceException.class)
// @ResponseStatus(HttpStatus.BAD_REQUEST)
// public ErrorResponse handleInsufficientBalance(InsufficientBalanceException e) {
// return new ErrorResponse("INSUFFICIENT_BALANCE", e.getMessage(), 400);
// }
//
// @ExceptionHandler(IllegalArgumentException.class)
// @ResponseStatus(HttpStatus.BAD_REQUEST)
// public ErrorResponse handleBadRequest(IllegalArgumentException e) {
// return new ErrorResponse("BAD_REQUEST", e.getMessage(), 400);
// }
//
// @ExceptionHandler(Exception.class)
// @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
// public ErrorResponse handleGeneric(Exception e) {
// log.error("Unhandled exception", e);
// return new ErrorResponse("INTERNAL_ERROR", "Something went wrong", 500);
// // Never expose internal details to the client
// }
// }
public class GlobalHandlerDemo {
public static void main(String[] args) {
System.out.println("Global Exception Handler mapping:");
System.out.println(" TradeNotFoundException -> 404 NOT_FOUND");
System.out.println(" InsufficientBalanceException -> 400 BAD_REQUEST");
System.out.println(" IllegalArgumentException -> 400 BAD_REQUEST");
System.out.println(" Exception (catch-all) -> 500 INTERNAL_ERROR");
System.out.println();
System.out.println("Controllers: ZERO try-catch blocks");
}
}
Global Exception Handler mapping:
TradeNotFoundException -> 404 NOT_FOUND
InsufficientBalanceException -> 400 BAD_REQUEST
IllegalArgumentException -> 400 BAD_REQUEST
Exception (catch-all) -> 500 INTERNAL_ERROR
Controllers: ZERO try-catch blocks
How do controllers look with global handling?
Controllers have no try-catch blocks. They call the service, and any exception propagates to the global handler.
package com.bank.web;
// @RestController
// @RequestMapping("/api/v1/trades")
// public class TradeController {
//
// @GetMapping("/{id}")
// public Trade getTrade(@PathVariable String id) {
// return tradeService.findById(id); // ZERO try-catch
// // TradeNotFoundException -> GlobalHandler -> 404
// }
//
// @PostMapping
// public Trade createTrade(@Valid @RequestBody TradeRequest request) {
// return tradeService.create(request); // ZERO try-catch
// // ValidationException -> GlobalHandler -> 422
// // InsufficientBalance -> GlobalHandler -> 400
// }
//
// @PostMapping("/{id}/settle")
// public TradeResult settle(@PathVariable String id) {
// return tradeService.settle(id); // ZERO try-catch
// // Any exception -> GlobalHandler -> appropriate HTTP status
// }
// }
public class CleanControllerDemo {
public static void main(String[] args) {
System.out.println("Controller methods: ZERO try-catch");
System.out.println(" GET /trades/{id} -> tradeService.findById(id)");
System.out.println(" POST /trades -> tradeService.create(request)");
System.out.println(" POST /trades/{id}/settle -> tradeService.settle(id)");
System.out.println();
System.out.println("All exceptions propagate to @ControllerAdvice");
}
}
Controller methods: ZERO try-catch
GET /trades/{id} -> tradeService.findById(id)
POST /trades -> tradeService.create(request)
POST /trades/{id}/settle -> tradeService.settle(id)
All exceptions propagate to @ControllerAdvice
What is ResponseStatusException?
ResponseStatusException (Spring 5) is an inline alternative for simple cases where you do not need a custom exception class.
package com.bank.demo;
// import org.springframework.http.HttpStatus;
// import org.springframework.web.server.ResponseStatusException;
// @GetMapping("/{id}")
// public Trade getTrade(@PathVariable String id) {
// return tradeService.findById(id)
// .orElseThrow(() -> new ResponseStatusException(
// HttpStatus.NOT_FOUND, "Trade not found: " + id));
// }
// No custom exception class needed. Status + message in one throw.
public class ResponseStatusExceptionDemo {
public static void main(String[] args) {
System.out.println("ResponseStatusException usage:");
System.out.println(" throw new ResponseStatusException(NOT_FOUND, msg)");
System.out.println(" Quick, inline, no custom class needed");
System.out.println(" But: less structured than @ExceptionHandler");
}
}
ResponseStatusException usage:
throw new ResponseStatusException(NOT_FOUND, msg)
Quick, inline, no custom class needed
But: less structured than @ExceptionHandler
How does @ExceptionHandler compare to ResponseStatusException?
| Feature | @ExceptionHandler | ResponseStatusException |
|---|---|---|
| Location | Centralized in @ControllerAdvice | Inline in controller/service |
| Response body | Full custom ErrorResponse DTO | Spring's default error body |
| Reusability | One handler for all controllers | Per-throw-site |
| Custom exception class | Required (TradeNotFoundException) | Not required |
| Best for | Production APIs with consistent format | Quick prototypes, simple cases |
What are common mistakes in Spring exception handling?
package com.bank.demo;
public class SpringExceptionMistakes {
// WRONG: try-catch in every controller method
// @GetMapping("/{id}")
// public ResponseEntity<Trade> getTrade(@PathVariable String id) {
// try {
// return ResponseEntity.ok(tradeService.findById(id));
// } catch (TradeNotFoundException e) {
// return ResponseEntity.notFound().build();
// } catch (Exception e) {
// return ResponseEntity.internalServerError().build();
// }
// }
// 200 try-catch blocks across 15 controllers. Inconsistent.
// CORRECT: let exceptions propagate to @ControllerAdvice
// WRONG: exposing internal details in error response
// return new ErrorResponse("ERROR", exception.toString(), 500);
// Leaks: class names, SQL queries, stack traces
// CORRECT: generic message for 500 errors
// return new ErrorResponse("INTERNAL_ERROR", "Something went wrong", 500);
public static void main(String[] args) {
System.out.println("Common mistakes:");
System.out.println("1. try-catch in every controller method");
System.out.println("2. Exposing SQL errors to API clients");
System.out.println("3. Missing catch-all handler for Exception.class");
System.out.println("4. Not logging the full exception in catch-all");
}
}
Common mistakes:
1. try-catch in every controller method
2. Exposing SQL errors to API clients
3. Missing catch-all handler for Exception.class
4. Not logging the full exception in catch-all
What should a senior engineer know?
- Handler ordering. Spring resolves handlers from most specific to least specific, similar to catch block ordering. TradeNotFoundException before Exception.
- Problem Details (RFC 7807/9457). Spring 6+ supports ProblemDetail responses for standards-compliant error formats. Use
ProblemDetail.forStatusAndDetail()in handlers. - @ControllerAdvice(basePackages = "..."). You can scope a handler to specific packages or controller classes. Useful for multi-module applications.
- @ExceptionHandler priority. If multiple @ControllerAdvice classes handle the same exception, use @Order to control priority.
- Correlation IDs. Include a request ID (from MDC or Spring Cloud Sleuth) in the ErrorResponse for distributed tracing.
What are common interview follow-up questions?
- Difference between @ControllerAdvice and @RestControllerAdvice? (@RestControllerAdvice adds @ResponseBody.)
- Can @ExceptionHandler be in a controller? (Yes, it handles only that controller's exceptions.)
- How do you handle validation errors? (@ExceptionHandler for MethodArgumentNotValidException.)
- What HTTP status for validation failure? (400 or 422 depending on convention.)
Visual Overview
Request
│
▼
Controller.settle(id)
│
│ exception thrown
▼
@RestControllerAdvice
GlobalExceptionHandler
│
├── TradeNotFoundException ──▶ 404 + ErrorResponse
├── InsufficientBalance ──▶ 400 + ErrorResponse
├── IllegalArgumentException ──▶ 400 + ErrorResponse
├── MethodArgumentNotValid ──▶ 422 + ErrorResponse
└── Exception (catch-all) ──▶ 500 + ErrorResponse
│
▼
JSON Response to Client
{
"error": "NOT_FOUND",
"message": "Trade TRD-001 not found",
"status": 404,
"timestamp": "2024-01-15T10:30:00Z"
}
Key Takeaway
@RestControllerAdvice with @ExceptionHandler methods is the Spring Boot standard for global exception handling. Each handler catches a specific exception type from any controller and returns a structured ErrorResponse with the correct HTTP status. Controllers have zero try-catch blocks. They call the service, and any exception propagates automatically to the global handler.
At National Bank of India, Pankaj's API uses one GlobalExceptionHandler class that maps TradeNotFoundException to 404, InsufficientBalanceException to 400, IllegalArgumentException to 400, and the catch-all Exception to 500. Before this pattern, there were 200 try-catch blocks across 15 controllers with inconsistent error formats. After: one handler class, consistent JSON responses, and controllers focused purely on business logic.
Best practices: order handlers from most specific to least specific. Log the full stack trace in the catch-all handler with log.error("msg", e). Never expose SQL errors, class names, or stack traces to the API client. Include a correlation ID in the error response for distributed tracing.
Remember one sentence: @RestControllerAdvice centralizes exception handling so controllers have zero try-catch and every error returns a consistent structured response.
---