Spring Boot Exception Handling: Best Practices and Examples
Introduction
If you've been developing Spring Boot applications, you've probably encountered exceptions. They're inevitable in any software development, but how you handle them can make or break your application's user experience and maintainability.
In this post, I'll share what I've learned over years of developing Spring Boot applications. We'll explore practical exception handling techniques that you can implement right away to make your applications more robust and user-friendly.
Exception handling isn't just about preventing your application from crashing; it's about gracefully managing errors, providing meaningful feedback to users, and maintaining a clean codebase. Let's dive in!
Usages
Proper exception handling in Spring Boot applications serves several critical purposes:
- Better User Experience: Users receive meaningful error messages instead of cryptic stack traces
- Security Enhancement: Prevents exposure of sensitive information through stack traces
- Consistent Error Responses: Creates a standardized error response format across your API
- Simplified Debugging: Well-structured exceptions make troubleshooting easier
- Code Clarity: Separates business logic from error handling logic
Let's look at some real-world scenarios where exception handling proves crucial:
- Resource Not Found: When a user requests a non-existent resource (e.g., customer ID doesn't exist)
- Validation Failures: When request data fails validation rules (e.g., invalid email format)
- Authentication/Authorization Issues: When users attempt unauthorized actions
- External Service Failures: When third-party APIs your application depends on are unavailable
- Data Integrity Problems: When database constraints are violated (e.g., duplicate entries)
Code Example
Let's build a complete working example of exception handling in a Spring Boot REST API. We'll create a simple product management API with proper exception handling.
1. Define Custom Exceptions
public class ProductNotFoundException extends RuntimeException { public ProductNotFoundException(Long id) { super("Product not found with id: " + id); } } public class ProductValidationException extends RuntimeException { public ProductValidationException(String message) { super(message); } }
2. Create a Standard Error Response Model
public class ErrorResponse { private LocalDateTime timestamp; private int status; private String error; private String message; private String path; // Constructor, getters, and setters public ErrorResponse(int status, String error, String message, String path) { this.timestamp = LocalDateTime.now(); this.status = status; this.error = error; this.message = message; this.path = path; } // Getters and setters omitted for brevity }
3. Implement a Global Exception Handler
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ProductNotFoundException.class) public ResponseEntity<ErrorResponse> handleProductNotFoundException( ProductNotFoundException ex, WebRequest request) { ErrorResponse errorResponse = new ErrorResponse( HttpStatus.NOT_FOUND.value(), "Not Found", ex.getMessage(), ((ServletWebRequest)request).getRequest().getRequestURI() ); return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); } @ExceptionHandler(ProductValidationException.class) public ResponseEntity<ErrorResponse> handleValidationException( ProductValidationException ex, WebRequest request) { ErrorResponse errorResponse = new ErrorResponse( HttpStatus.BAD_REQUEST.value(), "Bad Request", ex.getMessage(), ((ServletWebRequest)request).getRequest().getRequestURI() ); return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidationExceptions( MethodArgumentNotValidException ex, WebRequest request) { List<String> errors = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.toList()); ErrorResponse errorResponse = new ErrorResponse( HttpStatus.BAD_REQUEST.value(), "Validation Error", String.join(", ", errors), ((ServletWebRequest)request).getRequest().getRequestURI() ); return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleAllUncaughtException( Exception ex, WebRequest request) { ErrorResponse errorResponse = new ErrorResponse( HttpStatus.INTERNAL_SERVER_ERROR.value(), "Internal Server Error", "An unexpected error occurred", ((ServletWebRequest)request).getRequest().getRequestURI() ); // Log the full stack trace for debugging ex.printStackTrace(); return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); } }
4. Controller with Exception Throws
@RestController @RequestMapping("/api/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @GetMapping("/{id}") public ResponseEntity<Product> getProductById(@PathVariable Long id) { Product product = productService.getProductById(id); // If product not found, service will throw ProductNotFoundException return ResponseEntity.ok(product); } @PostMapping public ResponseEntity<Product> createProduct(@Valid @RequestBody Product product) { // @Valid annotation triggers validation - will throw MethodArgumentNotValidException Product savedProduct = productService.createProduct(product); return new ResponseEntity<>(savedProduct, HttpStatus.CREATED); } // More endpoints... }
5. Service Implementation
@Service public class ProductServiceImpl implements ProductService { private final ProductRepository productRepository; public ProductServiceImpl(ProductRepository productRepository) { this.productRepository = productRepository; } @Override public Product getProductById(Long id) { return productRepository.findById(id) .orElseThrow(() -> new ProductNotFoundException(id)); } @Override public Product createProduct(Product product) { // Business validation if (product.getPrice() < 0) { throw new ProductValidationException("Product price cannot be negative"); } // Check for duplicate product name if (productRepository.existsByName(product.getName())) { throw new ProductValidationException("Product with name '" + product.getName() + "' already exists"); } return productRepository.save(product); } // Other methods... }
Explanation
Let's break down the key components of our exception handling implementation:
Custom Exceptions
We've created two custom exceptions: ProductNotFoundException
and ProductValidationException
. These exceptions are specific to our domain and provide clear context about what went wrong.
Error Response Model
The ErrorResponse
class standardizes how errors are presented to API consumers. It includes:
- Timestamp: When the error occurred
- Status: HTTP status code
- Error: Short error description
- Message: Detailed error message
- Path: The API endpoint where the error occurred
Global Exception Handler
The @RestControllerAdvice
annotation creates a global exception handler that catches exceptions thrown anywhere in the application. We define several @ExceptionHandler
methods for different exception types:
- Domain-specific exceptions (
ProductNotFoundException
,ProductValidationException
) - Spring-specific exceptions (
MethodArgumentNotValidException
for validation errors) - A catch-all handler for unexpected exceptions
Controller and Service
The controller uses Spring's @Valid
annotation to trigger validation. If validation fails, Spring throws MethodArgumentNotValidException
which our global handler catches.
In the service layer, we throw our custom domain exceptions when business rules are violated. These exceptions bubble up and are caught by the appropriate handler.
Best Practices
Based on my experience working with Spring Boot in production environments, here are key best practices for exception handling:
1. Create Specific Exception Types
Define domain-specific exceptions rather than reusing generic ones. This makes your code more readable and helps categorize different error scenarios.
// Good - Clear and specific throw new InsufficientFundsException(accountId); // Avoid - Generic and unclear throw new RuntimeException("Account " + accountId + " doesn't have enough funds");
2. Use @RestControllerAdvice for Global Handling
Centralize exception handling with @RestControllerAdvice
rather than handling exceptions in individual controllers. This promotes consistency and reduces duplication.
3. Return Proper HTTP Status Codes
Match exceptions with appropriate HTTP status codes:
- 404 Not Found: Resource doesn't exist
- 400 Bad Request: Invalid input
- 401 Unauthorized: Authentication failure
- 403 Forbidden: Authorization failure
- 409 Conflict: Resource conflict
- 500 Internal Server Error: Unexpected exceptions
4. Include Essential Information in Error Responses
Provide enough information to help API consumers understand and fix the issue, but avoid exposing sensitive details:
- Include a unique error code or identifier
- Provide a human-readable message
- Add a timestamp
- Include the request path
5. Log Exceptions Appropriately
Log exceptions with different severity levels based on their importance:
@ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleAllUncaughtException( Exception ex, WebRequest request) { // Log with appropriate level log.error("Unhandled exception", ex); // Return standardized response return new ResponseEntity<>(new ErrorResponse(...), HttpStatus.INTERNAL_SERVER_ERROR); }
6. Use Validation for Request Data
Leverage Bean Validation (@Valid
) with appropriate constraints like @NotNull
, @Min
, etc., to validate input before processing:
public class ProductRequest { @NotBlank(message = "Product name is required") private String name; @Min(value = 0, message = "Price must be positive") private double price; // Getters and setters }
7. Handle Database Exceptions
Catch and translate database-specific exceptions into meaningful application exceptions:
@ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity<ErrorResponse> handleDataIntegrityViolation( DataIntegrityViolationException ex, WebRequest request) { String message = "Database constraint violation"; // Determine if it's a duplicate key if (ex.getCause() instanceof ConstraintViolationException) { message = "Duplicate entry detected"; } // Return formatted error return new ResponseEntity<>( new ErrorResponse(HttpStatus.CONFLICT.value(), "Conflict", message, request.getContextPath()), HttpStatus.CONFLICT ); }
8. Test Your Exception Handling
Write unit tests specifically for exception scenarios:
@Test void whenProductNotFound_thenReturn404() { // Given Long nonExistentId = 999L; when(productRepository.findById(nonExistentId)).thenReturn(Optional.empty()); // When/Then mockMvc.perform(get("/api/products/" + nonExistentId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.message").value("Product not found with id: " + nonExistentId)); }
Conclusion
Effective exception handling is a hallmark of production-ready Spring Boot applications. By implementing the patterns and practices we've covered, you'll create more robust, maintainable, and user-friendly APIs.
Remember these key takeaways:
- Use custom exceptions to model domain-specific error scenarios
- Centralize exception handling with
@RestControllerAdvice
- Create consistent, informative error responses
- Match exceptions with appropriate HTTP status codes
- Log exceptions with proper severity levels
- Test your exception handling thoroughly
Exception handling isn't the most exciting part of development, but it's one of those details that separates amateur projects from professional-grade applications. Take the time to implement it properly, and your users (and future self) will thank you.
Have you implemented exception handling in your Spring Boot projects? What approaches have worked best for you? Let me know in the comments below!