7.4 Error Handling Patterns and Strategies

Effective error handling goes beyond catching exceptions. This section explores patterns and strategies for robust error management, recovery, and resilience.

Fail Fast Principle

Validate Early:

// Bad - error discovered late
public void processOrder(Order order) {
    // ... lots of processing ...
    if (order.items().isEmpty()) {  // Should check immediately!
        throw new IllegalArgumentException("Order has no items");
    }
}

// Good - fail fast
public void processOrder(Order order) {
    Objects.requireNonNull(order, "Order cannot be null");
    if (order.items().isEmpty()) {
        throw new IllegalArgumentException("Order must have items");
    }
    // ... continue processing ...
}

Constructor Validation:

public class User {
    private final String email;
    private final String name;
    private final int age;

    public User(String email, String name, int age) {
        // Validate all invariants immediately
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email: " + email);
        }
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name cannot be blank");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Invalid age: " + age);
        }

        this.email = email;
        this.name = name;
        this.age = age;
    }
}

Optional Instead of Null

Avoid Null Returns:

// Bad - returns null, requires null checks
public User findUser(String id) {
    return repository.get(id);  // might be null
}

// Caller must check
User user = findUser("123");
if (user != null) {
    process(user);
}

// Good - returns Optional
public Optional<User> findUser(String id) {
    return Optional.ofNullable(repository.get(id));
}

// Caller uses Optional API
findUser("123").ifPresent(this::process);

// Or with exception
User user = findUser("123")
    .orElseThrow(() -> new UserNotFoundException("123"));

Optional Pattern:

public class UserService {
    // Query methods return Optional
    public Optional<User> findById(String id) {
        return repository.findById(id);
    }

    public Optional<User> findByEmail(String email) {
        return repository.findByEmail(email);
    }

    // Command methods throw exceptions
    public User getById(String id) {
        return findById(id)
            .orElseThrow(() -> new EntityNotFoundException("User", id));
    }

    public User createUser(CreateUserRequest request) {
        // Validate
        if (findByEmail(request.email()).isPresent()) {
            throw new ValidationException("Email already exists");
        }

        // Create
        return repository.save(mapToUser(request));
    }
}

Result/Either Pattern

Result Type for Operations That May Fail:

public sealed interface Result<T> {
    record Success<T>(T value) implements Result<T> { }
    record Failure<T>(String error, Throwable cause) implements Result<T> { }

    default boolean isSuccess() {
        return this instanceof Success;
    }

    default boolean isFailure() {
        return this instanceof Failure;
    }

    default T getValue() {
        return switch (this) {
            case Success<T> s -> s.value();
            case Failure<T> f -> throw new IllegalStateException("No value present", f.cause());
        };
    }

    default T getValueOrElse(T defaultValue) {
        return switch (this) {
            case Success<T> s -> s.value();
            case Failure<T> f -> defaultValue;
        };
    }

    default <U> Result<U> map(Function<T, U> mapper) {
        return switch (this) {
            case Success<T> s -> new Success<>(mapper.apply(s.value()));
            case Failure<T> f -> new Failure<>(f.error(), f.cause());
        };
    }

    default <U> Result<U> flatMap(Function<T, Result<U>> mapper) {
        return switch (this) {
            case Success<T> s -> mapper.apply(s.value());
            case Failure<T> f -> new Failure<>(f.error(), f.cause());
        };
    }

    static <T> Result<T> success(T value) {
        return new Success<>(value);
    }

    static <T> Result<T> failure(String error) {
        return new Failure<>(error, null);
    }

    static <T> Result<T> failure(String error, Throwable cause) {
        return new Failure<>(error, cause);
    }
}

// Usage
public class ConfigService {
    public Result<Config> loadConfig(Path file) {
        try {
            String content = Files.readString(file);
            Config config = parseConfig(content);
            return Result.success(config);
        } catch (IOException e) {
            return Result.failure("Failed to read config file: " + file, e);
        } catch (ParseException e) {
            return Result.failure("Failed to parse config file: " + file, e);
        }
    }

    public Result<Config> loadWithDefaults(Path primaryFile, Path fallbackFile) {
        return loadConfig(primaryFile)
            .map(this::applyEnvironmentOverrides)
            .flatMap(config -> {
                if (config.isValid()) {
                    return Result.success(config);
                }
                // Try fallback
                return loadConfig(fallbackFile);
            });
    }
}

// Pattern matching usage
Result<Config> result = configService.loadConfig(configPath);
switch (result) {
    case Result.Success<Config> s -> {
        initialize(s.value());
    }
    case Result.Failure<Config> f -> {
        logger.error("Config load failed: {}", f.error(), f.cause());
        useDefaultConfig();
    }
}

Try-Catch-Finally vs Try-With-Resources

Choose Appropriate Pattern:

// Use try-with-resources for AutoCloseable
public String readFile(Path file) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(file)) {
        return reader.lines().collect(Collectors.joining("\n"));
    }
}

// Use try-catch-finally for non-AutoCloseable cleanup
public void processWithLock() {
    Lock lock = new ReentrantLock();
    lock.lock();
    try {
        // critical section
        modifySharedState();
    } finally {
        lock.unlock();
    }
}

// Combine both when needed
public void processFileWithLock(Path file) throws IOException {
    Lock lock = new ReentrantLock();
    lock.lock();
    try (BufferedReader reader = Files.newBufferedReader(file)) {
        String content = reader.lines().collect(Collectors.joining("\n"));
        processWithLock(content);
    } finally {
        lock.unlock();
    }
}

Error Recovery Strategies

Retry Pattern:

public class RetryHandler {
    private final int maxRetries;
    private final Duration initialDelay;
    private final double backoffMultiplier;

    public RetryHandler(int maxRetries, Duration initialDelay, double backoffMultiplier) {
        this.maxRetries = maxRetries;
        this.initialDelay = initialDelay;
        this.backoffMultiplier = backoffMultiplier;
    }

    public <T> T execute(Supplier<T> operation) {
        return execute(operation, e -> true); // Retry all exceptions
    }

    public <T> T execute(Supplier<T> operation, Predicate<Exception> shouldRetry) {
        int attempts = 0;
        Duration delay = initialDelay;
        Exception lastException = null;

        while (attempts < maxRetries) {
            try {
                return operation.get();
            } catch (Exception e) {
                lastException = e;
                attempts++;

                if (attempts >= maxRetries || !shouldRetry.test(e)) {
                    break;
                }

                System.out.printf("Attempt %d failed, retrying after %dms: %s%n",
                    attempts, delay.toMillis(), e.getMessage());

                try {
                    Thread.sleep(delay.toMillis());
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException("Retry interrupted", ie);
                }

                delay = Duration.ofMillis((long) (delay.toMillis() * backoffMultiplier));
            }
        }

        throw new RuntimeException(
            "Operation failed after " + attempts + " attempts",
            lastException
        );
    }
}

// Usage
RetryHandler retryHandler = new RetryHandler(
    3,                          // max retries
    Duration.ofSeconds(1),      // initial delay
    2.0                         // backoff multiplier
);

// Retry only on transient failures
User user = retryHandler.execute(
    () -> httpClient.getUser(userId),
    e -> e instanceof IOException || e instanceof TimeoutException
);

Circuit Breaker Pattern:

public class CircuitBreaker {
    private enum State { CLOSED, OPEN, HALF_OPEN }

    private State state = State.CLOSED;
    private int failureCount = 0;
    private int successCount = 0;
    private Instant lastFailureTime;

    private final int failureThreshold;
    private final int successThreshold;
    private final Duration timeout;

    public CircuitBreaker(int failureThreshold, int successThreshold, Duration timeout) {
        this.failureThreshold = failureThreshold;
        this.successThreshold = successThreshold;
        this.timeout = timeout;
    }

    public <T> T execute(Supplier<T> operation) {
        if (state == State.OPEN) {
            if (Instant.now().isAfter(lastFailureTime.plus(timeout))) {
                state = State.HALF_OPEN;
                successCount = 0;
            } else {
                throw new CircuitBreakerOpenException(
                    "Circuit breaker is OPEN. Will retry after " + 
                    Duration.between(Instant.now(), lastFailureTime.plus(timeout))
                );
            }
        }

        try {
            T result = operation.get();
            onSuccess();
            return result;
        } catch (Exception e) {
            onFailure();
            throw e;
        }
    }

    private synchronized void onSuccess() {
        if (state == State.HALF_OPEN) {
            successCount++;
            if (successCount >= successThreshold) {
                state = State.CLOSED;
                failureCount = 0;
                System.out.println("Circuit breaker transitioned to CLOSED");
            }
        } else {
            failureCount = 0;
        }
    }

    private synchronized void onFailure() {
        failureCount++;
        lastFailureTime = Instant.now();

        if (state == State.HALF_OPEN) {
            state = State.OPEN;
            System.out.println("Circuit breaker transitioned to OPEN (from HALF_OPEN)");
        } else if (failureCount >= failureThreshold) {
            state = State.OPEN;
            System.out.println("Circuit breaker transitioned to OPEN");
        }
    }

    public State getState() {
        return state;
    }
}

// Usage
CircuitBreaker breaker = new CircuitBreaker(
    5,                          // failure threshold
    2,                          // success threshold
    Duration.ofMinutes(1)       // timeout
);

public String callExternalService() {
    try {
        return breaker.execute(() -> externalApi.getData());
    } catch (CircuitBreakerOpenException e) {
        // Circuit is open, use fallback
        return getCachedData();
    }
}

Fallback Pattern:

public class FallbackHandler {
    public <T> T executeWithFallback(Supplier<T> primary, Supplier<T> fallback) {
        try {
            return primary.get();
        } catch (Exception e) {
            System.err.println("Primary operation failed, using fallback: " + e.getMessage());
            return fallback.get();
        }
    }

    public <T> T executeWithMultipleFallbacks(List<Supplier<T>> operations) {
        Exception lastException = null;

        for (int i = 0; i < operations.size(); i++) {
            try {
                return operations.get(i).get();
            } catch (Exception e) {
                lastException = e;
                if (i < operations.size() - 1) {
                    System.err.printf("Operation %d failed, trying next: %s%n", 
                        i + 1, e.getMessage());
                }
            }
        }

        throw new RuntimeException("All operations failed", lastException);
    }
}

// Usage
FallbackHandler fallbackHandler = new FallbackHandler();

// Simple fallback
Config config = fallbackHandler.executeWithFallback(
    () -> loadConfigFromFile("config.json"),
    () -> getDefaultConfig()
);

// Multiple fallbacks
User user = fallbackHandler.executeWithMultipleFallbacks(List.of(
    () -> primaryDatabase.getUser(id),
    () -> secondaryDatabase.getUser(id),
    () -> cacheService.getUser(id),
    () -> getGuestUser()
));

Bulkhead Pattern

Isolate Failures:

public class BulkheadExecutor {
    private final Map<String, ExecutorService> executors = new ConcurrentHashMap<>();

    public BulkheadExecutor(Map<String, Integer> poolSizes) {
        poolSizes.forEach((name, size) -> 
            executors.put(name, Executors.newFixedThreadPool(size))
        );
    }

    public <T> CompletableFuture<T> execute(String bulkhead, Supplier<T> operation) {
        ExecutorService executor = executors.get(bulkhead);
        if (executor == null) {
            throw new IllegalArgumentException("Unknown bulkhead: " + bulkhead);
        }

        return CompletableFuture.supplyAsync(operation, executor);
    }

    public void shutdown() {
        executors.values().forEach(ExecutorService::shutdown);
    }
}

// Usage
BulkheadExecutor bulkhead = new BulkheadExecutor(Map.of(
    "database", 10,
    "externalApi", 5,
    "fileSystem", 3
));

// Separate thread pools prevent one slow operation from blocking others
CompletableFuture<List<User>> users = bulkhead.execute(
    "database",
    () -> userRepository.findAll()
);

CompletableFuture<String> apiData = bulkhead.execute(
    "externalApi",
    () -> externalService.getData()
);

CompletableFuture<String> fileData = bulkhead.execute(
    "fileSystem",
    () -> readFile(Path.of("data.txt"))
);

Validation Patterns

Defensive Programming:

public class OrderService {
    public Order createOrder(CreateOrderRequest request) {
        // Validate all inputs
        requireNonNull(request, "Request cannot be null");
        requireNonNull(request.userId(), "User ID cannot be null");
        requireNonNull(request.items(), "Items cannot be null");
        require(!request.items().isEmpty(), "Order must have at least one item");

        // Validate each item
        for (OrderItem item : request.items()) {
            requireNonNull(item.productId(), "Product ID cannot be null");
            require(item.quantity() > 0, "Quantity must be positive");
            require(item.price().compareTo(BigDecimal.ZERO) > 0, 
                   "Price must be positive");
        }

        // Process order
        return processOrder(request);
    }

    private void require(boolean condition, String message) {
        if (!condition) {
            throw new IllegalArgumentException(message);
        }
    }

    private <T> T requireNonNull(T obj, String message) {
        if (obj == null) {
            throw new IllegalArgumentException(message);
        }
        return obj;
    }
}

Bean Validation (Jakarta/javax.validation):

import jakarta.validation.*;
import jakarta.validation.constraints.*;

public class CreateUserRequest {
    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    private String name;

    @Min(value = 18, message = "Age must be at least 18")
    @Max(value = 120, message = "Age must be at most 120")
    private int age;

    // Getters and setters
}

public class UserService {
    private final Validator validator;

    public UserService() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        this.validator = factory.getValidator();
    }

    public User createUser(CreateUserRequest request) {
        // Validate
        Set<ConstraintViolation<CreateUserRequest>> violations = 
            validator.validate(request);

        if (!violations.isEmpty()) {
            Map<String, String> errors = violations.stream()
                .collect(Collectors.toMap(
                    v -> v.getPropertyPath().toString(),
                    ConstraintViolation::getMessage
                ));
            throw new ValidationException("Validation failed", errors);
        }

        // Create user
        return repository.save(mapToUser(request));
    }
}

Error Aggregation

Collect All Errors:

public class ValidationResult {
    private final List<String> errors = new ArrayList<>();

    public ValidationResult require(boolean condition, String error) {
        if (!condition) {
            errors.add(error);
        }
        return this;
    }

    public ValidationResult requireNonNull(Object obj, String fieldName) {
        if (obj == null) {
            errors.add(fieldName + " is required");
        }
        return this;
    }

    public boolean isValid() {
        return errors.isEmpty();
    }

    public List<String> getErrors() {
        return errors;
    }

    public void throwIfInvalid() {
        if (!isValid()) {
            throw new ValidationException(
                "Validation failed with " + errors.size() + " errors",
                errors
            );
        }
    }
}

// Usage
public Order createOrder(CreateOrderRequest request) {
    ValidationResult validation = new ValidationResult()
        .requireNonNull(request.userId(), "userId")
        .requireNonNull(request.items(), "items")
        .require(request.items().size() > 0, "Order must have at least one item");

    // Validate each item
    for (int i = 0; i < request.items().size(); i++) {
        OrderItem item = request.items().get(i);
        validation
            .requireNonNull(item.productId(), "items[" + i + "].productId")
            .require(item.quantity() > 0, "items[" + i + "].quantity must be positive");
    }

    // Throw with all errors at once
    validation.throwIfInvalid();

    return processOrder(request);
}

Real-World Example: Resilient HTTP Client

import java.net.http.*;
import java.time.Duration;
import java.util.concurrent.*;

public class ResilientHttpClient {
    private final HttpClient client;
    private final RetryHandler retryHandler;
    private final CircuitBreaker circuitBreaker;
    private final Duration timeout;

    public ResilientHttpClient(Duration timeout, int maxRetries, int circuitThreshold) {
        this.client = HttpClient.newBuilder()
            .connectTimeout(timeout)
            .build();

        this.retryHandler = new RetryHandler(
            maxRetries,
            Duration.ofSeconds(1),
            2.0
        );

        this.circuitBreaker = new CircuitBreaker(
            circuitThreshold,
            2,
            Duration.ofMinutes(1)
        );

        this.timeout = timeout;
    }

    public Result<String> get(String url) {
        try {
            String response = circuitBreaker.execute(() ->
                retryHandler.execute(() -> {
                    HttpRequest request = HttpRequest.newBuilder()
                        .uri(URI.create(url))
                        .timeout(timeout)
                        .GET()
                        .build();

                    HttpResponse<String> response = client.send(
                        request,
                        HttpResponse.BodyHandlers.ofString()
                    );

                    if (response.statusCode() >= 400) {
                        throw new HttpException(
                            "HTTP " + response.statusCode(),
                            response.statusCode()
                        );
                    }

                    return response.body();
                },
                // Retry only on transient errors
                e -> e instanceof IOException || 
                     e instanceof TimeoutException ||
                     (e instanceof HttpException he && he.getStatusCode() >= 500)
                )
            );

            return Result.success(response);

        } catch (CircuitBreakerOpenException e) {
            return Result.failure("Circuit breaker is open", e);
        } catch (Exception e) {
            return Result.failure("Request failed: " + url, e);
        }
    }

    public Result<String> getWithFallback(String primaryUrl, String fallbackUrl) {
        Result<String> result = get(primaryUrl);

        if (result.isSuccess()) {
            return result;
        }

        System.err.println("Primary request failed, trying fallback");
        return get(fallbackUrl);
    }

    public Result<String> getWithCache(String url, Cache<String, String> cache) {
        // Try cache first
        String cached = cache.get(url);
        if (cached != null) {
            return Result.success(cached);
        }

        // Make request
        Result<String> result = get(url);

        // Cache successful responses
        if (result.isSuccess()) {
            cache.put(url, result.getValue());
        }

        return result;
    }
}

// Usage
ResilientHttpClient client = new ResilientHttpClient(
    Duration.ofSeconds(5),  // timeout
    3,                      // max retries
    5                       // circuit breaker threshold
);

// Simple request
Result<String> result = client.get("https://api.example.com/data");
switch (result) {
    case Result.Success<String> s -> processData(s.value());
    case Result.Failure<String> f -> handleError(f.error(), f.cause());
}

// With fallback
Result<String> data = client.getWithFallback(
    "https://api.example.com/data",
    "https://backup-api.example.com/data"
);

// With cache
Cache<String, String> cache = new ConcurrentHashMap<>();
Result<String> cached = client.getWithCache(
    "https://api.example.com/static-data",
    cache
);

Best Practices Summary

  1. Fail Fast - Validate inputs immediately
  2. Use Optional - Avoid null returns for query operations
  3. Be Specific - Throw specific exception types with context
  4. Retry Transient Failures - Use exponential backoff
  5. Use Circuit Breakers - Prevent cascading failures
  6. Provide Fallbacks - Have alternative strategies
  7. Aggregate Errors - Return all validation errors at once
  8. Log Appropriately - Log errors with context before throwing
  9. Don't Swallow Exceptions - Always handle or propagate
  10. Clean Up Resources - Use try-with-resources