Spring Boot Exception Handling

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:

  1. Resource Not Found: When a user requests a non-existent resource (e.g., customer ID doesn't exist)
  2. Validation Failures: When request data fails validation rules (e.g., invalid email format)
  3. Authentication/Authorization Issues: When users attempt unauthorized actions
  4. External Service Failures: When third-party APIs your application depends on are unavailable
  5. 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!

Post a Comment

Previous Post Next Post