Exception handling is a critical aspect of robust application development in Java and Spring Boot. This comprehensive guide covers everything you need to know about exceptions, from basic concepts to advanced microservice communication patterns.

Table of Contents

  1. Exception Hierarchy in Java
  2. Checked vs Unchecked Exceptions
  3. Try-Catch Blocks
  4. Multiple Catch Blocks
  5. Creating Custom Exceptions
  6. Partially vs Fully Checked Exceptions
  7. Global Exception Handler
  8. Handling Exceptions in Microservices with Feign Client

Exception Hierarchy in Java {#exception-hierarchy}

Java's exception hierarchy is built around the Throwable class, which serves as the root of all exception and error classes.

java.lang.Object
  └── java.lang.Throwable
      ├── java.lang.Error
      │   ├── OutOfMemoryError
      │   ├── StackOverflowError
      │   └── VirtualMachineError
      └── java.lang.Exception
          ├── IOException (Checked)
          ├── SQLException (Checked)
          ├── ClassNotFoundException (Checked)
          └── RuntimeException (Unchecked)
              ├── NullPointerException
              ├── IllegalArgumentException
              ├── IndexOutOfBoundsException
              └── NumberFormatException

Key Components:

  • Throwable: The superclass of all errors and exceptions
  • Error: Serious problems that applications should not try to catch
  • Exception: Conditions that applications might want to catch
  • RuntimeException: Exceptions that can be thrown during normal operation

Checked vs Unchecked Exceptions

Checked Exceptions

Checked exceptions are compile-time exceptions that must be handled or declared in the method signature.

Characteristics:

  • Must be caught or declared with throws
  • Checked at compile time
  • Extend Exception but not RuntimeException

Examples:

// IOException example
public void readFile(String filename) throws IOException {
    BufferedReader reader = new BufferedReader(new FileReader(filename));
    String line = reader.readLine();
    reader.close();
}
// SQLException example
public User getUserById(int id) throws SQLException {
    Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/db");
    PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
    stmt.setInt(1, id);
    ResultSet rs = stmt.executeQuery();
    // Process result...
    return user;
}

Unchecked Exceptions

Unchecked exceptions are runtime exceptions that don't need to be explicitly handled.

Characteristics:

  • Not checked at compile time
  • Extend RuntimeException
  • Usually indicate programming errors

Examples:

// NullPointerException
public void processUser(User user) {
    String name = user.getName(); // Can throw NPE if user is null
}
// IllegalArgumentException
public void setAge(int age) {
    if (age < 0) {
        throw new IllegalArgumentException("Age cannot be negative");
    }
}
// ArrayIndexOutOfBoundsException
public void accessArray() {
    int[] numbers = {1, 2, 3};
    int value = numbers[5]; // Throws exception
}

Try-Catch Blocks {#try-catch-blocks}

The try-catch block is the fundamental mechanism for handling exceptions in Java.

Basic Try-Catch

public void basicTryCatch() {
    try {
        int result = 10 / 0; // This will throw ArithmeticException
        System.out.println("Result: " + result);
    } catch (ArithmeticException e) {
        System.err.println("Cannot divide by zero: " + e.getMessage());
        // Log the exception
        logger.error("Division by zero error", e);
    }
}

Try-Catch-Finally

public void tryCatchFinally() {
    FileInputStream file = null;
    try {
        file = new FileInputStream("data.txt");
        // Process file
        int data = file.read();
    } catch (FileNotFoundException e) {
        System.err.println("File not found: " + e.getMessage());
    } catch (IOException e) {
        System.err.println("IO Error: " + e.getMessage());
    } finally {
        // This block always executes
        if (file != null) {
            try {
                file.close();
            } catch (IOException e) {
                System.err.println("Error closing file: " + e.getMessage());
            }
        }
    }
}

Try-With-Resources

public void tryWithResources() {
    try (FileInputStream file = new FileInputStream("data.txt");
         BufferedReader reader = new BufferedReader(new InputStreamReader(file))) {
        
        String line;
        while ((line = reader.readLine()) != null) {
            System.out.println(line);
        }
    } catch (IOException e) {
        System.err.println("Error reading file: " + e.getMessage());
    }
    // Resources are automatically closed
}

Multiple Catch Blocks {#multiple-catch-blocks}

You can handle different types of exceptions with multiple catch blocks.

Separate Catch Blocks

public void multipleCatchBlocks() {
    try {
        String numberStr = "abc";
        int number = Integer.parseInt(numberStr);
        int result = 100 / number;
        
        int[] array = new int[5];
        array[10] = result;
        
    } catch (NumberFormatException e) {
        System.err.println("Invalid number format: " + e.getMessage());
        // Handle number format exception
        
    } catch (ArithmeticException e) {
        System.err.println("Arithmetic error: " + e.getMessage());
        // Handle arithmetic exception
        
    } catch (ArrayIndexOutOfBoundsException e) {
        System.err.println("Array index error: " + e.getMessage());
        // Handle array index exception
        
    } catch (Exception e) {
        System.err.println("Unexpected error: " + e.getMessage());
        // Handle any other exception
    }
}

Multi-Catch Block (Java 7+)

public void multiCatchBlock() {
    try {
        // Some operations that might throw exceptions
        performRiskyOperation();
        
    } catch (IOException | SQLException | NumberFormatException e) {
        // Handle multiple exception types with the same logic
        System.err.println("Expected error occurred: " + e.getMessage());
        logError(e);
        
    } catch (Exception e) {
        System.err.println("Unexpected error: " + e.getMessage());
        // Handle unexpected exceptions
    }
}

Creating Custom Exceptions {#custom-exceptions}

Custom exceptions help create more meaningful error handling in your applications.

Custom Checked Exception

// Custom checked exception
public class UserNotFoundException extends Exception {
    private final String userId;
    
    public UserNotFoundException(String userId) {
        super("User not found with ID: " + userId);
        this.userId = userId;
    }
    
    public UserNotFoundException(String userId, String message) {
        super(message);
        this.userId = userId;
    }
    
    public UserNotFoundException(String userId, String message, Throwable cause) {
        super(message, cause);
        this.userId = userId;
    }
    
    public String getUserId() {
        return userId;
    }
}

Custom Unchecked Exception

// Custom unchecked exception
public class InvalidUserDataException extends RuntimeException {
    private final String field;
    private final Object value;
    
    public InvalidUserDataException(String field, Object value) {
        super(String.format("Invalid value '%s' for field '%s'", value, field));
        this.field = field;
        this.value = value;
    }
    
    public InvalidUserDataException(String field, Object value, String message) {
        super(message);
        this.field = field;
        this.value = value;
    }
    
    public InvalidUserDataException(String field, Object value, Throwable cause) {
        super(String.format("Invalid value '%s' for field '%s'", value, field), cause);
        this.field = field;
        this.value = value;
    }
    
    public String getField() {
        return field;
    }
    
    public Object getValue() {
        return value;
    }
}

Using Custom Exceptions

@Service
public class UserService {
    
    public User findUserById(String userId) throws UserNotFoundException {
        if (userId == null || userId.trim().isEmpty()) {
            throw new InvalidUserDataException("userId", userId, "User ID cannot be null or empty");
        }
        
        User user = userRepository.findById(userId);
        if (user == null) {
            throw new UserNotFoundException(userId);
        }
        
        return user;
    }
    
    public User createUser(UserRequest request) {
        if (request.getEmail() == null || !request.getEmail().contains("@")) {
            throw new InvalidUserDataException("email", request.getEmail(), "Invalid email format");
        }
        
        if (request.getAge() < 0 || request.getAge() > 150) {
            throw new InvalidUserDataException("age", request.getAge(), "Age must be between 0 and 150");
        }
        
        // Create user logic...
        return user;
    }
}

Partially vs Fully Checked Exceptions {#partially-vs-fully-checked}

Fully Checked Exception

A fully checked exception is a traditional checked exception where every method in the call chain must either handle the exception or declare it.

// Fully checked exception - must be handled or declared at every level
public class DataAccessException extends Exception {
    public DataAccessException(String message) {
        super(message);
    }
    
    public DataAccessException(String message, Throwable cause) {
        super(message, cause);
    }
}
// Repository layer - declares the exception
@Repository
public class UserRepository {
    public User findById(String id) throws DataAccessException {
        try {
            // Database operation
            return database.findUser(id);
        } catch (SQLException e) {
            throw new DataAccessException("Failed to find user", e);
        }
    }
}
// Service layer - must handle or declare
@Service
public class UserService {
    public User getUser(String id) throws DataAccessException {
        return userRepository.findById(id); // Must declare or handle
    }
}
// Controller layer - must handle or declare
@RestController
public class UserController {
    public ResponseEntity<User> getUser(@PathVariable String id) {
        try {
            User user = userService.getUser(id);
            return ResponseEntity.ok(user);
        } catch (DataAccessException e) {
            return ResponseEntity.status(500).build();
        }
    }
}

Partially Checked Exception (Spring's Approach)

Spring Framework uses partially checked exceptions where the framework provides unchecked alternatives to checked exceptions.

// Spring's DataAccessException is unchecked (RuntimeException)
@Repository
public class SpringUserRepository {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    public User findById(String id) {
        try {
            return jdbcTemplate.queryForObject(
                "SELECT * FROM users WHERE id = ?", 
                new Object[]{id}, 
                new UserRowMapper()
            );
        } catch (EmptyResultDataAccessException e) {
            // Spring converts SQLException to unchecked DataAccessException
            return null;
        }
        // No need to declare SQLException - Spring handles it
    }
}
// Service layer - clean without exception declarations
@Service
public class SpringUserService {
    public User getUser(String id) {
        User user = userRepository.findById(id);
        if (user == null) {
            throw new UserNotFoundException(id); // Custom unchecked exception
        }
        return user;
    }
}

Benefits of Each Approach

Fully Checked Exceptions:

  • Force explicit error handling
  • Clear contract about what can go wrong
  • Compile-time safety

Partially Checked Exceptions (Spring's approach):

  • Cleaner code without forced exception handling
  • Flexibility to handle exceptions where appropriate
  • Reduced boilerplate code

Global Exception Handler {#global-exception-handler}

Spring Boot provides @ControllerAdvice to handle exceptions globally across your application.

Creating a Global Exception Handler

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    // Handle custom checked exceptions
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        log.error("User not found: {}", e.getUserId(), e);
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.NOT_FOUND.value())
            .error("User Not Found")
            .message(e.getMessage())
            .path(getCurrentPath())
            .build();
            
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    
    // Handle custom unchecked exceptions
    @ExceptionHandler(InvalidUserDataException.class)
    public ResponseEntity<ErrorResponse> handleInvalidUserData(InvalidUserDataException e) {
        log.error("Invalid user data - Field: {}, Value: {}", e.getField(), e.getValue(), e);
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.BAD_REQUEST.value())
            .error("Invalid Data")
            .message(e.getMessage())
            .path(getCurrentPath())
            .details(Map.of("field", e.getField(), "value", e.getValue()))
            .build();
            
        return ResponseEntity.badRequest().body(error);
    }
    
    // Handle validation errors
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException e) {
        log.error("Validation error", e);
        
        Map<String, String> validationErrors = new HashMap<>();
        e.getBindingResult().getFieldErrors().forEach(error -> 
            validationErrors.put(error.getField(), error.getDefaultMessage())
        );
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.BAD_REQUEST.value())
            .error("Validation Failed")
            .message("Invalid input data")
            .path(getCurrentPath())
            .details(validationErrors)
            .build();
            
        return ResponseEntity.badRequest().body(error);
    }
    
    // Handle database-related exceptions
    @ExceptionHandler(DataAccessException.class)
    public ResponseEntity<ErrorResponse> handleDataAccessException(DataAccessException e) {
        log.error("Database error", e);
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
            .error("Database Error")
            .message("An error occurred while accessing the database")
            .path(getCurrentPath())
            .build();
            
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
    
    // Handle HTTP method not allowed
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<ErrorResponse> handleMethodNotAllowed(HttpRequestMethodNotSupportedException e) {
        log.error("Method not allowed", e);
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.METHOD_NOT_ALLOWED.value())
            .error("Method Not Allowed")
            .message(e.getMessage())
            .path(getCurrentPath())
            .build();
            
        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(error);
    }
    
    // Handle generic exceptions
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception e) {
        log.error("Unexpected error", e);
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
            .error("Internal Server Error")
            .message("An unexpected error occurred")
            .path(getCurrentPath())
            .build();
            
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
    
    private String getCurrentPath() {
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes instanceof ServletRequestAttributes) {
            HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
            return request.getRequestURI();
        }
        return "unknown";
    }
}

Error Response DTO

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse {
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;
    private Map<String, Object> details;
}

Handling Exceptions in Microservices with Feign Client {#microservice-exception-handling}

When working with microservices, handling exceptions across service boundaries requires special attention, especially when using Feign clients.

Setting up Feign Client with Exception Handling

1. Create Custom Exceptions for Service Communication

// Exception for service communication errors
public class ServiceCommunicationException extends RuntimeException {
    private final String serviceName;
    private final int statusCode;
    private final String responseBody;
    
    public ServiceCommunicationException(String serviceName, int statusCode, String message) {
        super(message);
        this.serviceName = serviceName;
        this.statusCode = statusCode;
        this.responseBody = null;
    }
    
    public ServiceCommunicationException(String serviceName, int statusCode, String message, String responseBody) {
        super(message);
        this.serviceName = serviceName;
        this.statusCode = statusCode;
        this.responseBody = responseBody;
    }
    
    // Getters...
}
// Exception for remote service errors
public class RemoteServiceException extends RuntimeException {
    private final String serviceName;
    private final String operation;
    private final ErrorResponse remoteError;
    
    public RemoteServiceException(String serviceName, String operation, ErrorResponse remoteError) {
        super(String.format("Remote service %s failed during %s: %s", serviceName, operation, remoteError.getMessage()));
        this.serviceName = serviceName;
        this.operation = operation;
        this.remoteError = remoteError;
    }
    
    // Getters...
}

2. Create Feign Error Decoder

@Component
public class CustomFeignErrorDecoder implements ErrorDecoder {
    
    private final ObjectMapper objectMapper;
    
    public CustomFeignErrorDecoder(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    
    @Override
    public Exception decode(String methodKey, Response response) {
        String serviceName = extractServiceName(methodKey);
        
        try {
            String responseBody = IOUtils.toString(response.body().asInputStream(), StandardCharsets.UTF_8);
            
            switch (response.status()) {
                case 400:
                    return handleBadRequest(serviceName, responseBody);
                case 404:
                    return handleNotFound(serviceName, responseBody);
                case 500:
                    return handleInternalServerError(serviceName, responseBody);
                default:
                    return handleGenericError(serviceName, response.status(), responseBody);
            }
            
        } catch (IOException e) {
            return new ServiceCommunicationException(serviceName, response.status(), 
                "Failed to decode error response", null);
        }
    }
    
    private Exception handleBadRequest(String serviceName, String responseBody) {
        try {
            ErrorResponse errorResponse = objectMapper.readValue(responseBody, ErrorResponse.class);
            return new RemoteServiceException(serviceName, "validation", errorResponse);
        } catch (Exception e) {
            return new ServiceCommunicationException(serviceName, 400, "Bad Request", responseBody);
        }
    }
    
    private Exception handleNotFound(String serviceName, String responseBody) {
        try {
            ErrorResponse errorResponse = objectMapper.readValue(responseBody, ErrorResponse.class);
            return new RemoteServiceException(serviceName, "lookup", errorResponse);
        } catch (Exception e) {
            return new ServiceCommunicationException(serviceName, 404, "Resource Not Found", responseBody);
        }
    }
    
    private Exception handleInternalServerError(String serviceName, String responseBody) {
        try {
            ErrorResponse errorResponse = objectMapper.readValue(responseBody, ErrorResponse.class);
            return new RemoteServiceException(serviceName, "processing", errorResponse);
        } catch (Exception e) {
            return new ServiceCommunicationException(serviceName, 500, "Internal Server Error", responseBody);
        }
    }
    
    private Exception handleGenericError(String serviceName, int status, String responseBody) {
        return new ServiceCommunicationException(serviceName, status, 
            "Service communication failed", responseBody);
    }
    
    private String extractServiceName(String methodKey) {
        // Extract service name from methodKey (e.g., "UserService#getUser(String)")
        if (methodKey.contains("#")) {
            return methodKey.substring(0, methodKey.indexOf("#"));
        }
        return "Unknown";
    }
}

3. Configure Feign Client

@FeignClient(name = "user-service", url = "${user-service.url}")
public interface UserServiceClient {
    
    @GetMapping("/api/users/{id}")
    UserDto getUser(@PathVariable("id") String id);
    
    @PostMapping("/api/users")
    UserDto createUser(@RequestBody CreateUserRequest request);
    
    @PutMapping("/api/users/{id}")
    UserDto updateUser(@PathVariable("id") String id, @RequestBody UpdateUserRequest request);
    
    @DeleteMapping("/api/users/{id}")
    void deleteUser(@PathVariable("id") String id);
}

4. Configuration Class

@Configuration
@EnableFeignClients
public class FeignConfig {
    
    @Bean
    public ErrorDecoder errorDecoder(ObjectMapper objectMapper) {
        return new CustomFeignErrorDecoder(objectMapper);
    }
    
    @Bean
    public Retryer retryer() {
        return new Retryer.Default(1000, 2000, 3);
    }
    
    @Bean
    public RequestInterceptor requestInterceptor() {
        return requestTemplate -> {
            // Add correlation ID for tracing
            String correlationId = UUID.randomUUID().toString();
            requestTemplate.header("X-Correlation-ID", correlationId);
            
            // Add authentication if needed
            String token = getAuthToken();
            if (token != null) {
                requestTemplate.header("Authorization", "Bearer " + token);
            }
        };
    }
    
    private String getAuthToken() {
        // Get token from security context or other source
        return null;
    }
}

5. Service Layer with Exception Handling

@Service
@Slf4j
public class OrderService {
    
    private final UserServiceClient userServiceClient;
    private final CircuitBreaker circuitBreaker;
    
    public OrderService(UserServiceClient userServiceClient, CircuitBreakerFactory circuitBreakerFactory) {
        this.userServiceClient = userServiceClient;
        this.circuitBreaker = circuitBreakerFactory.create("user-service");
    }
    
    public Order createOrder(CreateOrderRequest request) {
        try {
            // Validate user exists using Feign client with circuit breaker
            UserDto user = circuitBreaker.executeSupplier(() -> {
                try {
                    return userServiceClient.getUser(request.getUserId());
                } catch (RemoteServiceException e) {
                    log.error("Remote service error when getting user {}: {}", 
                        request.getUserId(), e.getMessage());
                    
                    if (e.getRemoteError() != null && e.getRemoteError().getStatus() == 404) {
                        throw new UserNotFoundException(request.getUserId());
                    }
                    throw new ServiceUnavailableException("User service is currently unavailable");
                    
                } catch (ServiceCommunicationException e) {
                    log.error("Service communication error: {}", e.getMessage());
                    throw new ServiceUnavailableException("Unable to communicate with user service");
                }
            });
            
            // Create order with validated user
            Order order = Order.builder()
                .userId(user.getId())
                .userEmail(user.getEmail())
                .items(request.getItems())
                .totalAmount(calculateTotal(request.getItems()))
                .status(OrderStatus.PENDING)
                .createdAt(LocalDateTime.now())
                .build();
                
            return orderRepository.save(order);
            
        } catch (Exception e) {
            log.error("Failed to create order for user {}", request.getUserId(), e);
            throw e;
        }
    }
    
    public Order updateOrderWithUserInfo(String orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        try {
            // Use circuit breaker with fallback
            UserDto user = circuitBreaker.executeSupplier(() -> 
                userServiceClient.getUser(order.getUserId())
            );
            
            // Update order with latest user information
            order.setUserEmail(user.getEmail());
            order.setUserName(user.getName());
            
            return orderRepository.save(order);
            
        } catch (Exception e) {
            log.warn("Failed to update user info for order {}, using cached data", orderId, e);
            // Return order with existing data - graceful degradation
            return order;
        }
    }
    
    private BigDecimal calculateTotal(List<OrderItem> items) {
        return items.stream()
            .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

6. Enhanced Global Exception Handler for Microservices

@ControllerAdvice
@Slf4j
public class MicroserviceExceptionHandler extends GlobalExceptionHandler {
    
    // Handle service communication exceptions
    @ExceptionHandler(ServiceCommunicationException.class)
    public ResponseEntity<ErrorResponse> handleServiceCommunication(ServiceCommunicationException e) {
        log.error("Service communication failed - Service: {}, Status: {}", 
            e.getServiceName(), e.getStatusCode(), e);
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.SERVICE_UNAVAILABLE.value())
            .error("Service Communication Error")
            .message("Unable to communicate with " + e.getServiceName())
            .path(getCurrentPath())
            .details(Map.of(
                "serviceName", e.getServiceName(),
                "statusCode", e.getStatusCode()
            ))
            .build();
            
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
    }
    
    // Handle remote service exceptions
    @ExceptionHandler(RemoteServiceException.class)
    public ResponseEntity<ErrorResponse> handleRemoteService(RemoteServiceException e) {
        log.error("Remote service error - Service: {}, Operation: {}", 
            e.getServiceName(), e.getOperation(), e);
        
        // Map remote error to appropriate HTTP status
        HttpStatus status = mapRemoteErrorStatus(e.getRemoteError());
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(status.value())
            .error("Remote Service Error")
            .message(e.getMessage())
            .path(getCurrentPath())
            .details(Map.of(
                "serviceName", e.getServiceName(),
                "operation", e.getOperation(),
                "remoteError", e.getRemoteError()
            ))
            .build();
            
        return ResponseEntity.status(status).body(error);
    }
    
    // Handle circuit breaker exceptions
    @ExceptionHandler({CallNotPermittedException.class, TimeoutException.class})
    public ResponseEntity<ErrorResponse> handleCircuitBreakerException(Exception e) {
        log.error("Circuit breaker exception", e);
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.SERVICE_UNAVAILABLE.value())
            .error("Service Unavailable")
            .message("Service is temporarily unavailable")
            .path(getCurrentPath())
            .build();
            
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
    }
    
    // Handle service unavailable
    @ExceptionHandler(ServiceUnavailableException.class)
    public ResponseEntity<ErrorResponse> handleServiceUnavailable(ServiceUnavailableException e) {
        log.error("Service unavailable", e);
        
        ErrorResponse error = ErrorResponse.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.SERVICE_UNAVAILABLE.value())
            .error("Service Unavailable")
            .message(e.getMessage())
            .path(getCurrentPath())
            .build();
            
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
    }
    
    private HttpStatus mapRemoteErrorStatus(ErrorResponse remoteError) {
        if (remoteError == null) {
            return HttpStatus.INTERNAL_SERVER_ERROR;
        }
        
        // Map remote status codes to appropriate HTTP status
        switch (remoteError.getStatus()) {
            case 400: return HttpStatus.BAD_REQUEST;
            case 404: return HttpStatus.NOT_FOUND;
            case 409: return HttpStatus.CONFLICT;
            case 422: return HttpStatus.UNPROCESSABLE_ENTITY;
            default: return HttpStatus.INTERNAL_SERVER_ERROR;
        }
    }
}

7. Circuit Breaker Configuration

@Configuration
public class CircuitBreakerConfig {
    
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> globalCustomConfiguration() {
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
            .timeLimiterConfig(TimeLimiterConfig.custom()
                .timeoutDuration(Duration.ofSeconds(4))
                .build())
            .circuitBreakerConfig(io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom()
                .slidingWindowSize(5)
                .permittedNumberOfCallsInHalfOpenState(2)
                .failureRateThreshold(50)
                .waitDurationInOpenState(Duration.ofMillis(30000))
                .build())
            .build());
    }
}

Best Practices for Exception Handling

1. Use Appropriate Exception Types

  • Use checked exceptions for recoverable conditions
  • Use unchecked exceptions for programming errors
  • Create meaningful custom exceptions

2. Provide Context in Exception Messages

// Bad
throw new IllegalArgumentException("Invalid input");
// Good
throw new IllegalArgumentException("User age must be between 0 and 150, but was: " + age);

3. Don't Ignore Exceptions

// Bad
try {
    riskyOperation();
} catch (Exception e) {
    // Silent failure
}
// Good
try {
    riskyOperation();
} catch (Exception e) {
    log.error("Failed to perform risky operation", e);
    // Handle appropriately
}

4. Use Specific Exception Types

// Bad
catch (Exception e) {
    // Too broad
}
// Good
catch (IOException e) {
    // Handle I/O issues
} catch (SQLException e) {
    // Handle database issues
}

5. Clean Up Resources

// Use try-with-resources when possible
try (Connection conn = getConnection()) {
    // Use connection
} catch (SQLException e) {
    // Handle exception
}
// Connection automatically closed

Advanced Exception Handling Patterns

1. Exception Translation Pattern

@Repository
public class UserRepositoryImpl implements UserRepository {
    
    public User findById(String id) throws UserNotFoundException {
        try {
            // Database operation
            return jdbcTemplate.queryForObject(sql, params, mapper);
        } catch (EmptyResultDataAccessException e) {
            // Translate Spring exception to domain exception
            throw new UserNotFoundException(id);
        } catch (DataAccessException e) {
            // Translate to more specific domain exception
            throw new UserDataException("Failed to access user data", e);
        }
    }
}

2. Retry Pattern with Exception Handling

@Service
public class ResilientService {
    
    @Retryable(value = {ServiceCommunicationException.class}, 
               maxAttempts = 3, 
               backoff = @Backoff(delay = 1000, multiplier = 2))
    public String callExternalService(String request) {
        try {
            return externalServiceClient.call(request);
        } catch (FeignException e) {
            log.warn("External service call failed, attempt will be retried", e);
            throw new ServiceCommunicationException("external-service", e.status(), e.getMessage());
        }
    }
    
    @Recover
    public String recoverFromServiceCall(ServiceCommunicationException e, String request) {
        log.error("All retry attempts failed for external service call", e);
        return "Default response due to service unavailability";
    }
}

3. Bulk Operation Exception Handling

@Service
public class BulkUserService {
    
    public BulkOperationResult<User> createUsers(List<CreateUserRequest> requests) {
        List<User> successfulUsers = new ArrayList<>();
        List<BulkOperationError> errors = new ArrayList<>();
        
        for (int i = 0; i < requests.size(); i++) {
            try {
                User user = userService.createUser(requests.get(i));
                successfulUsers.add(user);
            } catch (InvalidUserDataException e) {
                errors.add(new BulkOperationError(i, e.getField(), e.getMessage()));
            } catch (Exception e) {
                errors.add(new BulkOperationError(i, "general", "Unexpected error: " + e.getMessage()));
            }
        }
        
        return BulkOperationResult.<User>builder()
            .successful(successfulUsers)
            .errors(errors)
            .totalProcessed(requests.size())
            .successCount(successfulUsers.size())
            .errorCount(errors.size())
            .build();
    }
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BulkOperationResult<T> {
    private List<T> successful;
    private List<BulkOperationError> errors;
    private int totalProcessed;
    private int successCount;
    private int errorCount;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class BulkOperationError {
    private int index;
    private String field;
    private String message;
}

Monitoring and Logging

1. Structured Logging with Exception Context

@Service
@Slf4j
public class MonitoredService {
    
    public void processOrder(String orderId) {
        MDC.put("orderId", orderId);
        MDC.put("operation", "processOrder");
        
        try {
            log.info("Starting order processing");
            
            // Process order
            Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));
            
            processOrderInternal(order);
            
            log.info("Order processed successfully");
            
        } catch (OrderNotFoundException e) {
            log.warn("Order not found: {}", e.getMessage());
            throw e;
        } catch (PaymentException e) {
            log.error("Payment processing failed", e);
            throw e;
        } catch (Exception e) {
            log.error("Unexpected error during order processing", e);
            throw new OrderProcessingException("Failed to process order", e);
        } finally {
            MDC.clear();
        }
    }
}

2. Custom Exception Metrics

@Component
public class ExceptionMetrics {
    
    private final MeterRegistry meterRegistry;
    private final Counter exceptionCounter;
    
    public ExceptionMetrics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.exceptionCounter = Counter.builder("application.exceptions")
            .description("Count of application exceptions")
            .register(meterRegistry);
    }
    
    @EventListener
    public void handleException(ExceptionEvent event) {
        exceptionCounter.increment(
            Tags.of(
                "exception.class", event.getException().getClass().getSimpleName(),
                "service", event.getServiceName(),
                "operation", event.getOperation()
            )
        );
    }
}
// Custom exception event
@Data
@AllArgsConstructor
public class ExceptionEvent {
    private Exception exception;
    private String serviceName;
    private String operation;
    private LocalDateTime timestamp;
}

Testing Exception Handling

1. Unit Testing Exceptions

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    void shouldThrowUserNotFoundExceptionWhenUserDoesNotExist() {
        // Given
        String userId = "nonexistent";
        when(userRepository.findById(userId)).thenReturn(null);
        
        // When & Then
        UserNotFoundException exception = assertThrows(
            UserNotFoundException.class,
            () -> userService.findUserById(userId)
        );
        
        assertEquals("User not found with ID: " + userId, exception.getMessage());
        assertEquals(userId, exception.getUserId());
    }
    
    @Test
    void shouldThrowInvalidUserDataExceptionForInvalidEmail() {
        // Given
        CreateUserRequest request = new CreateUserRequest();
        request.setEmail("invalid-email");
        request.setAge(25);
        
        // When & Then
        InvalidUserDataException exception = assertThrows(
            InvalidUserDataException.class,
            () -> userService.createUser(request)
        );
        
        assertEquals("email", exception.getField());
        assertEquals("invalid-email", exception.getValue());
        assertThat(exception.getMessage()).contains("Invalid email format");
    }
    
    @Test
    void shouldHandleDatabaseExceptionGracefully() {
        // Given
        String userId = "user123";
        when(userRepository.findById(userId)).thenThrow(new DataAccessException("DB connection failed") {});
        
        // When & Then
        assertThrows(UserDataException.class, () -> userService.findUserById(userId));
    }
}

2. Integration Testing with TestContainers

@SpringBootTest
@Testcontainers
class UserControllerIntegrationTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Test
    void shouldReturn404WhenUserNotFound() {
        // When
        ResponseEntity<ErrorResponse> response = restTemplate.getForEntity(
            "/api/users/nonexistent", 
            ErrorResponse.class
        );
        
        // Then
        assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
        
        ErrorResponse error = response.getBody();
        assertNotNull(error);
        assertEquals(404, error.getStatus());
        assertEquals("User Not Found", error.getError());
        assertThat(error.getMessage()).contains("User not found with ID: nonexistent");
    }
    
    @Test
    void shouldReturn400ForInvalidUserData() {
        // Given
        CreateUserRequest request = new CreateUserRequest();
        request.setEmail("invalid");
        request.setAge(-5);
        
        // When
        ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
            "/api/users", 
            request, 
            ErrorResponse.class
        );
        
        // Then
        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
        
        ErrorResponse error = response.getBody();
        assertNotNull(error);
        assertEquals(400, error.getStatus());
        assertNotNull(error.getDetails());
    }
}

3. Testing Feign Client Exception Handling

@SpringBootTest
class UserServiceClientTest {
    
    @MockBean
    private UserServiceClient userServiceClient;
    
    @Autowired
    private OrderService orderService;
    
    @Test
    void shouldHandleUserServiceNotFound() {
        // Given
        String userId = "nonexistent";
        CreateOrderRequest request = new CreateOrderRequest();
        request.setUserId(userId);
        
        RemoteServiceException remoteException = new RemoteServiceException(
            "UserService", 
            "getUser",
            ErrorResponse.builder()
                .status(404)
                .error("User Not Found")
                .message("User not found with ID: " + userId)
                .build()
        );
        
        when(userServiceClient.getUser(userId)).thenThrow(remoteException);
        
        // When & Then
        UserNotFoundException exception = assertThrows(
            UserNotFoundException.class,
            () -> orderService.createOrder(request)
        );
        
        assertEquals(userId, exception.getUserId());
    }
    
    @Test
    void shouldHandleServiceCommunicationFailure() {
        // Given
        String userId = "user123";
        CreateOrderRequest request = new CreateOrderRequest();
        request.setUserId(userId);
        
        ServiceCommunicationException commException = new ServiceCommunicationException(
            "UserService", 
            503, 
            "Service temporarily unavailable"
        );
        
        when(userServiceClient.getUser(userId)).thenThrow(commException);
        
        // When & Then
        ServiceUnavailableException exception = assertThrows(
            ServiceUnavailableException.class,
            () -> orderService.createOrder(request)
        );
        
        assertThat(exception.getMessage()).contains("User service is currently unavailable");
    }
}

Configuration Examples

1. Application Properties for Exception Handling

# application.yml
spring:
  application:
    name: order-service
    
# Feign configuration
feign:
  client:
    config:
      default:
        connectTimeout: 5000
        readTimeout: 5000
        loggerLevel: basic
        errorDecoder: com.example.config.CustomFeignErrorDecoder
        retryer: com.example.config.CustomRetryer
        
# Circuit breaker configuration
resilience4j:
  circuitbreaker:
    configs:
      default:
        slidingWindowSize: 10
        permittedNumberOfCallsInHalfOpenState: 3
        failureRateThreshold: 50
        waitDurationInOpenState: 10000ms
        slowCallRateThreshold: 50
        slowCallDurationThreshold: 2000ms
    instances:
      user-service:
        baseConfig: default
        
  retry:
    configs:
      default:
        maxAttempts: 3
        waitDuration: 1000ms
        retryExceptions:
          - com.example.exception.ServiceCommunicationException
          - java.net.ConnectException
        ignoreExceptions:
          - com.example.exception.UserNotFoundException
          
# Logging configuration
logging:
  level:
    com.example: DEBUG
    feign: DEBUG
  pattern:
    console: "%d{HH:mm:ss.SSS} [%thread] %-5level [%X{correlationId}] %logger{36} - %msg%n"
    
# Monitoring
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  metrics:
    export:
      prometheus:
        enabled: true

Conclusion

Exception handling is a crucial aspect of building robust Java and Spring Boot applications, especially in microservice architectures. Key takeaways from this comprehensive guide:

Core Principles:

  1. Use appropriate exception types — Choose between checked and unchecked exceptions based on the use case
  2. Provide meaningful context — Include relevant information in exception messages and custom exception classes
  3. Handle exceptions at the right level — Use global exception handlers for cross-cutting concerns and specific handlers for business logic
  4. Design for resilience — Implement circuit breakers, retries, and graceful degradation for distributed systems

Microservice Considerations:

  • Service boundaries — Handle exceptions properly when crossing service boundaries with Feign clients
  • Error translation — Convert technical exceptions to meaningful business exceptions
  • Monitoring and observability — Implement proper logging, metrics, and tracing for distributed exception handling
  • Resilience patterns — Use circuit breakers, bulkheads, and timeouts to handle service failures gracefully

Testing Strategy:

  • Unit tests — Test exception scenarios thoroughly with proper assertions
  • Integration tests — Verify end-to-end exception handling behavior
  • Contract testing — Ensure service interfaces handle exceptions consistently

By following these patterns and practices, you can build applications that gracefully handle failures, provide meaningful error messages to users, and maintain system stability even when things go wrong. Remember that good exception handling is not just about catching errors — it's about creating a robust, maintainable, and user-friendly system that can recover from failures and provide clear feedback about what went wrong.

The key is to balance comprehensive error handling with code maintainability, ensuring that your exception handling strategy supports both developers debugging issues and users understanding what actions they need to take.