
Error handling is a fundamental part of building resilient and user-friendly APIs. Instead of repeating try-catch
blocks across your controllers and services, Spring Boot provides a clean way to handle exceptions globally using @ControllerAdvice
and @ExceptionHandler
.
In this post, you’ll learn how to handle exceptions globally in Spring Boot, create custom exception classes, return structured error responses, and apply best practices for 2025.
❌ Why Global Exception Handling Matters
Without proper exception handling:
- Users receive generic or confusing error messages
- You leak technical details (stack traces) in production
- There’s duplicated code everywhere
- HTTP responses aren’t consistent or helpful
Spring Boot’s approach enables centralized error handling, helping you keep code clean and maintainable.
📦 Project Setup
If you’re starting fresh, include these dependencies:
spring-boot-starter-web
spring-boot-starter-validation
(optional but recommended)
📘 New to Spring Boot? Read Getting Started with Spring Boot 3
🧱 Step 1: Create a Custom Exception
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
You can create multiple exception classes to represent different error types (e.g., BadRequestException
, UnauthorizedException
, etc.).
🎯 Step 2: Create a Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleResourceNotFound(ResourceNotFoundException ex) {
ApiError error = new ApiError(HttpStatus.NOT_FOUND, ex.getMessage());
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleGeneralException(Exception ex) {
ApiError error = new ApiError(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
🧾 Step 3: Create an Error Response Model
public class ApiError {
private int status;
private String error;
private LocalDateTime timestamp;
public ApiError(HttpStatus status, String error) {
this.status = status.value();
this.error = error;
this.timestamp = LocalDateTime.now();
}
// Getters and setters omitted for brevity
}
This ensures every error response your API returns has a consistent structure.
🧪 Example Controller
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
// Simulating a missing product
throw new ResourceNotFoundException("Product with ID " + id + " not found");
}
}
🧪 Sample Output:
{
"status": 404,
"error": "Product with ID 1 not found",
"timestamp": "2025-05-13T12:01:32.042"
}
📌 Best Practices
- Return meaningful messages for client errors (400s)
- Avoid exposing internal stack traces or class names in production
- Customize responses based on exception type or request path
- Use logging (e.g., SLF4J) to capture full errors internally
- Return proper HTTP status codes (e.g., 400, 404, 500)
🔒 Bonus: Handle Validation Exceptions
Add support for @Valid
or @Validated
request body errors:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidationError(MethodArgumentNotValidException ex) {
String errorMsg = ex.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.joining(", "));
ApiError error = new ApiError(HttpStatus.BAD_REQUEST, errorMsg);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
This helps return clear messages like:
{
"status": 400,
"error": "email: must not be blank, age: must be greater than 18"
}
🧠 Final Thoughts
Handling exceptions globally in Spring Boot is clean, scalable, and highly recommended. It improves your API’s consistency, user experience, and overall reliability — especially in production environments.
By using @RestControllerAdvice
, custom exceptions, and structured responses, you can handle all edge cases in one place — and keep your controller logic focused on what it should be doing: delivering business functionality.