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