Module: Exception Handling

Custom Exceptions

Introduction to Custom Exceptions

While Spring Boot provides robust exception handling mechanisms, sometimes you need to define your own exceptions specific to your application's business logic. This allows for more precise error identification, handling, and reporting. Custom exceptions improve code readability and maintainability by clearly signaling specific error conditions within your application.

Why Create Custom Exceptions?

  • Specificity: Built-in exceptions are often too generic. Custom exceptions represent specific errors within your domain.
  • Clarity: They make your code easier to understand. Seeing ProductNotFoundException immediately tells you what went wrong, compared to a generic Exception.
  • Granular Handling: You can catch and handle custom exceptions differently than general exceptions, allowing for tailored responses.
  • Maintainability: As your application grows, custom exceptions help organize and manage error handling logic.

Creating a Custom Exception

Creating a custom exception in Java is straightforward. You typically extend the Exception class (for checked exceptions) or RuntimeException (for unchecked exceptions).

Checked vs. Unchecked Exceptions:

  • Checked Exceptions: Must be explicitly handled (using try-catch blocks or declared in the method signature using throws). They represent recoverable errors.
  • Unchecked Exceptions: Don't need to be handled explicitly. They usually represent programming errors (like NullPointerException) or unrecoverable conditions.

For most business logic errors, extending RuntimeException is a good choice, as it keeps your code cleaner by avoiding the need for excessive throws declarations.

Example:

Let's create a custom exception called ResourceNotFoundException:

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }

    public ResourceNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
}

Explanation:

  • We extend RuntimeException making it an unchecked exception.
  • We provide constructors to accept a message (for describing the error) and an optional cause (for chaining exceptions). The super(message) call passes the message to the RuntimeException constructor.

Using the Custom Exception

Now, let's use this exception in a Spring Boot service:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public Product getProductById(Long id) {
        return productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product with ID " + id + " not found"));
    }
}

Explanation:

  • The getProductById method attempts to retrieve a product by its ID.
  • If the product is not found ( productRepository.findById(id) returns an empty Optional), we throw our ResourceNotFoundException with a descriptive message. We use a lambda expression () -> new ResourceNotFoundException(...) to create the exception instance.

Handling the Custom Exception Globally

To handle the ResourceNotFoundException (and other custom exceptions) globally, we can use a @ControllerAdvice and @ExceptionHandler.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public @ResponseBody ErrorResponse handleResourceNotFoundException(ResourceNotFoundException ex) {
        return new ErrorResponse(HttpStatus.NOT_FOUND.value(), ex.getMessage());
    }

    // You can add more @ExceptionHandler methods for other custom exceptions
}

// Simple Error Response class
class ErrorResponse {
    private int status;
    private String message;

    public ErrorResponse(int status, String message) {
        this.status = status;
        this.message = message;
    }

    public int getStatus() {
        return status;
    }

    public String getMessage() {
        return message;
    }
}

Explanation:

  • @ControllerAdvice: Marks this class as a controller advice, allowing it to handle exceptions globally.
  • @ExceptionHandler(ResourceNotFoundException.class): Specifies that this method should handle ResourceNotFoundException exceptions.
  • @ResponseStatus(HttpStatus.NOT_FOUND): Sets the HTTP status code to 404 (Not Found).
  • @ResponseBody: Indicates that the return value should be serialized directly into the response body.
  • ErrorResponse: A simple class to encapsulate the error status and message. You can customize this to include more details as needed.

Testing the Custom Exception

You can write a unit test to verify that the exception is thrown correctly and handled by the global exception handler.

@SpringBootTest
@RunWith(SpringRunner.class)
public class ProductServiceTest {

    @Autowired
    private ProductService productService;

    @MockBean
    private ProductRepository productRepository;

    @Test(expected = ResourceNotFoundException.class)
    public void testGetProductById_ProductNotFound() {
        // Mock the repository to return an empty Optional
        when(productRepository.findById(1L)).thenReturn(Optional.empty());

        productService.getProductById(1L);
    }
}

Explanation:

  • @SpringBootTest: Indicates that this is a Spring Boot integration test.
  • @RunWith(SpringRunner.class): Uses the Spring Runner to manage the test context.
  • @Autowired: Injects the ProductService and mocks the ProductRepository.
  • @MockBean: Replaces the actual ProductRepository with a mock implementation.
  • when(productRepository.findById(1L)).thenReturn(Optional.empty()): Configures the mock repository to return an empty Optional when findById(1L) is called.
  • expected = ResourceNotFoundException.class: Asserts that a ResourceNotFoundException is thrown when productService.getProductById(1L) is called.

Best Practices

  • Meaningful Names: Choose exception names that clearly describe the error condition.
  • Specific Messages: Provide informative error messages that help developers diagnose the problem.
  • Exception Chaining: Use the cause parameter in the constructor to chain exceptions, preserving the original error information.
  • Avoid Overuse: Don't create custom exceptions for every possible error condition. Use built-in exceptions when appropriate.
  • Document Your Exceptions: Clearly document your custom exceptions so that other developers understand how to use and handle them.

This tutorial provides a foundation for creating and handling custom exceptions in Spring Boot. By following these principles, you can build more robust, maintainable, and user-friendly applications.