Reliability is an asset of big financial and analytics platforms. When we redesigned an enterprise platform, we learned that the main thing that made customers unhappy wasn’t performance or the user interface; it was faults that weren’t fixed that destroyed processes. Centralized exception management techniques, such as utilizing @ControllerAdvice in Spring Boot or similar patterns in other frameworks, ensure that systems behave as expected when they are under stress. This discipline is similar to a larger leadership principle: plan for failure before it happens.
Why This Matters
When you build REST APIs in Spring Boot, you’ll quickly face this problem:
“How do I handle errors neatly without writing repetitive try-catch blocks everywhere?”
Imagine having 50+ endpoints — each could fail with a Null Pointer Exception, invalid input, or a missing resource. n Instead of returning messy stack traces, you want consistent, meaningful, and client-friendly error responses.
That’s where @ControllerAdvice comes in — it centralizes all error handling in one place. n Let’s see how to build this step by step, with a real-world example.
Use Case — User Management REST API
We’ll create a simple User API that supports:
-
Fetching users
-
Creating users
-
Simulating failures for testing
Our goal → build a unified global error-handling layer that returns clean JSON responses.
Step 1: Project Setup
Dependencies (Maven):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
This brings in web + validation support.
Then create your main app:
@SpringBootApplication
public class ExceptionHandlerDemoApplication {
public static void main(String[] args) {
SpringApplication.run(ExceptionHandlerDemoApplication.class, args);
}
}
Step 2: Define the Entity — User.java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
private int id;
@NotBlank(message = "Name cannot be blank")
private String name;
@Min(value = 18, message = "Age must be at least 18")
private int age;
}
**Why: n **We add simple validations so Spring can trigger Method Argument Not Valid Exception automatically when input is invalid.
Step 3: Custom Exception for Missing Data
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
public class InvalidRequestException extends RuntimeException {
public InvalidRequestException(String message) {
super(message);
}
}
**Why: n **To represent a “user not found” situation cleanly instead of generic exceptions.
Step 4: Build the Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
// Simple GET that throws ResourceNotFoundException for id > 100
@GetMapping("/{id}")
public String getUser(@PathVariable("id") @Min(1) Integer id) {
if (id > 100) {
throw new ResourceNotFoundException("User with id " + id + " not found");
}
return "User-" + id;
}
// Create user example to demonstrate validation
public static record CreateUserRequest(
@NotBlank(message = "name is required") String name,
@Min(value = 18, message = "age must be >= 18") int age) {}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public String createUser(@RequestBody @Valid CreateUserRequest body) {
if ("bad".equalsIgnoreCase(body.name())) {
throw new InvalidRequestException("Name 'bad' is not allowed");
}
return "created:" + body.name();
}
// Endpoint to force a server error for demo
@GetMapping("/boom")
public void boom() {
throw new IllegalStateException("simulated server error");
}
}
**Why: n **We create realistic scenarios — not found, validation errors, and runtime errors — that our global handler will manage.
Step 5: Create a Standard Error Model
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse {
private OffsetDateTime timestamp;
private int status;
private String error;
private String message;
private String path;
private List<FieldError> fieldErrors;}
**Why: n **All APIs should return errors in the same structure — this improves monitoring and debugging in production.
Step 6: Implement @ControllerAdvice (Global Handler)
@ControllerAdvice
public class GlobalExceptionHandler {
private final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Handle custom validation exceptions
@ExceptionHandler(InvalidRequestException.class)
public ResponseEntity<ErrorResponse> handleInvalidRequest(InvalidRequestException ex, HttpServletRequest req) {
log.debug("InvalidRequestException: {}", ex.getMessage());
ErrorResponse body = new ErrorResponse(OffsetDateTime.now(), HttpStatus.BAD_REQUEST.value(),
HttpStatus.BAD_REQUEST.getReasonPhrase(), ex.getMessage(), req.getRequestURI());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
// Resource not found -> 404
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) {
log.debug("ResourceNotFoundException: {}", ex.getMessage());
ErrorResponse body = new ErrorResponse(OffsetDateTime.now(), HttpStatus.NOT_FOUND.value(),
HttpStatus.NOT_FOUND.getReasonPhrase(), ex.getMessage(), req.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
// Validation errors from @Valid on request bodies
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
log.debug("Validation failed: {}", ex.getMessage());
List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new ErrorResponse.FieldError(fe.getField(), fe.getRejectedValue(), fe.getDefaultMessage()))
.collect(Collectors.toList());
ErrorResponse body = new ErrorResponse(OffsetDateTime.now(), HttpStatus.BAD_REQUEST.value(),
HttpStatus.BAD_REQUEST.getReasonPhrase(), "Validation failed", req.getRequestURI());
body.setFieldErrors(fieldErrors);
return ResponseEntity.badRequest().body(body);
}
// Type mismatch for method args (?id=abc)
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex, HttpServletRequest req) {
log.debug("Type mismatch: {}", ex.getMessage());
ErrorResponse body = new ErrorResponse(OffsetDateTime.now(), HttpStatus.BAD_REQUEST.value(),
HttpStatus.BAD_REQUEST.getReasonPhrase(), ex.getMessage(), req.getRequestURI());
return ResponseEntity.badRequest().body(body);
}
// No handler found (404 for unmatched endpoints)
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ErrorResponse> handleNoHandler(NoHandlerFoundException ex, HttpServletRequest req) {
log.debug("NoHandlerFound: {} {}", ex.getHttpMethod(), ex.getRequestURL());
ErrorResponse body = new ErrorResponse(OffsetDateTime.now(), HttpStatus.NOT_FOUND.value(),
HttpStatus.NOT_FOUND.getReasonPhrase(), "Endpoint not found", req.getRequestURL().toString());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
// Generic fallback
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception ex, HttpServletRequest req) {
log.error("Unhandled exception: ", ex);
ErrorResponse body = new ErrorResponse(OffsetDateTime.now(), HttpStatus.INTERNAL_SERVER_ERROR.value(),
HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "An internal error occurred", req.getRequestURI());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}
Why:
- @ControllerAdvice → Makes it global across all controllers.
- @ExceptionHandler → Catches specific exception types.
- The buildResponse() helper keeps the code DRY and clean.
Step 7: Test Scenarios
GET /api/users/1
Response code: 200 -Success
** **
GET /api/users/999
Response code: 404-Resource Not Found
**
**
Validation Error
POST /api/users
{“name”:””, “age”: 15}
Response:
**
**
Unexpected Error
GET /api/users/boom
**
**
Why This Approach Works in Real Projects
| Problem | Solution |
|:—:|:—:|
| Too many try-catch blocks | Centralized handling with @ControllerAdvice |
| Inconsistent responses | Unified ErrorResponse structure |
| Hard to debug | Standardized messages with timestamps and paths |
| Client confusion | Clear, meaningful messages for each failure type |
Real-World Usage Scenarios
- Banking APIs: Ensure validation errors (like invalid account number) don’t crash the service.
- E-commerce: Handle product-not-found or payment errors gracefully.
- Data Microservices: Return structured messages when input data fails validation.
- API Gateway: Consistent responses across multiple microservices.
Final Thoughts
By combining @ControllerAdvice, @ExceptionHandler, and a simple Error Response model, you get:
- Clean code
- Consistent API experience
- Easier debugging in production
It’s one of the simplest yet most powerful design patterns in Spring Boot development.
