← Back to DevBytes

Error Handling Patterns in Java

Introduction to Error Handling in Java

Error handling in Java refers to the systematic approach of detecting, reporting, and recovering from unexpected conditions that occur during program execution. Java provides a robust exception handling mechanism built into the language itself, centered around the Throwable class hierarchy — which splits into checked exceptions (Exception), unchecked exceptions (RuntimeException), and Error for catastrophic failures. Error handling patterns go beyond basic try-catch blocks; they encompass architectural decisions about how errors propagate through layers, how they get logged, how they get reported to clients, and how the system recovers without losing data or entering inconsistent states.

Why Error Handling Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Without deliberate error handling patterns, applications fail silently, corrupt user data, leak sensitive information through stack traces, or crash unpredictably. Effective error handling provides:

In modern distributed systems with microservices, error handling becomes even more critical because failures cascade across service boundaries. A well-designed error handling strategy prevents a single downstream timeout from taking down an entire cluster.

Core Error Handling Patterns

1. The Classic Try-Catch-Finally Pattern

This is the fundamental building block. The try block contains the risky code, the catch block handles specific exceptions, and the finally block runs cleanup code regardless of success or failure.

public void processFile(String filePath) {
    FileInputStream fileStream = null;
    try {
        fileStream = new FileInputStream(filePath);
        // Process the file contents
        byte[] buffer = new byte[1024];
        int bytesRead;
        while ((bytesRead = fileStream.read(buffer)) != -1) {
            // Process buffer...
        }
        System.out.println("File processed successfully.");
    } catch (FileNotFoundException e) {
        System.err.println("File not found at path: " + filePath);
        // Log with a proper logging framework in production
        throw new ApplicationException("Could not locate the requested file", e);
    } catch (IOException e) {
        System.err.println("I/O error while reading file: " + e.getMessage());
        throw new ApplicationException("Failed to read file contents", e);
    } finally {
        if (fileStream != null) {
            try {
                fileStream.close();
            } catch (IOException e) {
                System.err.println("Failed to close file stream: " + e.getMessage());
            }
        }
    }
}

2. Try-with-Resources Pattern (Java 7+)

For resources that implement AutoCloseable, the try-with-resources statement eliminates the need for manual cleanup in finally. Resources are closed automatically in reverse order of creation, even when exceptions occur.

public List readLinesFromFile(String filePath) throws IOException {
    List lines = new ArrayList<>();
    
    try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
        String line;
        while ((line = reader.readLine()) != null) {
            lines.add(line);
        }
    } // reader.close() is called automatically
    // FileReader.close() is also called automatically via chained resources
    
    return lines;
}

// Multiple resources example
public void copyFile(String sourcePath, String destPath) throws IOException {
    try (FileInputStream input = new FileInputStream(sourcePath);
         FileOutputStream output = new FileOutputStream(destPath)) {
        
        byte[] buffer = new byte[8192];
        int bytesRead;
        while ((bytesRead = input.read(buffer)) != -1) {
            output.write(buffer, 0, bytesRead);
        }
    } // Both streams are closed, even if one close() throws an exception
}

If both the body and the close() method throw exceptions, the body exception is thrown, and the close exception is suppressed — accessible via getSuppressed(). This preserves the original error while still tracking cleanup failures.

try (DatabaseConnection conn = new DatabaseConnection()) {
    // This throws SQLException
    conn.execute("INSERT INTO users VALUES (1, 'Alice')");
} catch (SQLException e) {
    // The main exception is the INSERT failure
    System.err.println("Main exception: " + e.getMessage());
    for (Throwable suppressed : e.getSuppressed()) {
        System.err.println("Suppressed (close failure): " + suppressed.getMessage());
    }
}

3. Exception Wrapping / Chaining Pattern

Low-level exceptions should be caught, wrapped in a domain-appropriate exception, and re-thrown. This preserves the original cause while presenting a meaningful abstraction to higher layers. Always pass the original exception to the constructor to maintain the stack trace chain.

// Domain-specific exception
public class OrderProcessingException extends Exception {
    private final String orderId;
    private final ErrorCode errorCode;
    
    public enum ErrorCode {
        PAYMENT_FAILED,
        INVENTORY_UNAVAILABLE,
        SHIPPING_CALCULATION_ERROR,
        UNKNOWN
    }
    
    public OrderProcessingException(String orderId, ErrorCode errorCode, 
                                     String message, Throwable cause) {
        super(message, cause);
        this.orderId = orderId;
        this.errorCode = errorCode;
    }
    
    public String getOrderId() { return orderId; }
    public ErrorCode getErrorCode() { return errorCode; }
}

// Usage in service layer
public class OrderService {
    private final PaymentGateway paymentGateway;
    
    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
    
    public void processOrder(Order order) throws OrderProcessingException {
        try {
            paymentGateway.charge(order.getCustomerId(), order.getTotal());
        } catch (PaymentTimeoutException e) {
            throw new OrderProcessingException(
                order.getId(),
                OrderProcessingException.ErrorCode.PAYMENT_FAILED,
                "Payment timed out for order " + order.getId(),
                e  // Preserve original cause
            );
        } catch (PaymentDeclinedException e) {
            throw new OrderProcessingException(
                order.getId(),
                OrderProcessingException.ErrorCode.PAYMENT_FAILED,
                "Payment declined: " + e.getDeclineReason(),
                e
            );
        }
    }
}

4. Custom Exception Hierarchy Pattern

Create a base exception for your module and subclass it for specific error categories. This allows callers to catch at varying levels of granularity. Combine with error codes for machine-readable error classification.

// Base exception for the entire module
public abstract class ServiceException extends RuntimeException {
    private final String serviceName;
    private final String errorCode;
    private final Map context;
    
    protected ServiceException(String serviceName, String errorCode, 
                                String message, Throwable cause,
                                Map context) {
        super(message, cause);
        this.serviceName = serviceName;
        this.errorCode = errorCode;
        this.context = context != null ? new HashMap<>(context) : new HashMap<>();
    }
    
    public String getServiceName() { return serviceName; }
    public String getErrorCode() { return errorCode; }
    public Map getContext() { return Collections.unmodifiableMap(context); }
}

// Specific exceptions
public class ResourceNotFoundException extends ServiceException {
    private final String resourceType;
    private final String resourceId;
    
    public ResourceNotFoundException(String serviceName, String resourceType, 
                                      String resourceId) {
        super(serviceName, "RESOURCE_NOT_FOUND",
              resourceType + " with id '" + resourceId + "' was not found",
              null,
              Map.of("resourceType", resourceType, "resourceId", resourceId));
        this.resourceType = resourceType;
        this.resourceId = resourceId;
    }
    
    public String getResourceType() { return resourceType; }
    public String getResourceId() { return resourceId; }
}

public class ValidationException extends ServiceException {
    private final List fieldErrors;
    
    public record FieldError(String field, String message, Object rejectedValue) {}
    
    public ValidationException(String serviceName, List fieldErrors) {
        super(serviceName, "VALIDATION_FAILED",
              "Validation failed with " + fieldErrors.size() + " error(s)",
              null,
              Map.of("fieldErrors", fieldErrors));
        this.fieldErrors = List.copyOf(fieldErrors);
    }
    
    public List getFieldErrors() { return fieldErrors; }
}

// Usage demonstrates catching at different levels
public void handleRequest(String resourceId) {
    try {
        lookupResource(resourceId);
    } catch (ResourceNotFoundException e) {
        // Handle specifically
        return ResponseEntity.status(404).body(Map.of(
            "error", e.getErrorCode(),
            "resourceType", e.getResourceType(),
            "resourceId", e.getResourceId()
        ));
    } catch (ServiceException e) {
        // Fallback for any other service error
        return ResponseEntity.status(500).body(Map.of(
            "error", e.getErrorCode(),
            "service", e.getServiceName()
        ));
    }
}

5. The Either / Result Pattern

Instead of throwing exceptions for expected failure paths, return a result wrapper that explicitly represents either success or failure. This forces callers to handle both cases and makes error paths visible in the method signature. While Java lacks built-in Either, you can implement it or use Optional for simple cases.

// Simple Result type for expected failures
public class Result {
    private final T value;
    private final E error;
    private final boolean success;
    
    private Result(T value, E error, boolean success) {
        this.value = value;
        this.error = error;
        this.success = success;
    }
    
    public static  Result success(T value) {
        return new Result<>(value, null, true);
    }
    
    public static  Result failure(E error) {
        return new Result<>(null, error, false);
    }
    
    public boolean isSuccess() { return success; }
    public boolean isFailure() { return !success; }
    
    public T getValue() {
        if (!success) throw new IllegalStateException("Result is a failure: " + error.getMessage());
        return value;
    }
    
    public E getError() {
        if (success) throw new IllegalStateException("Result is a success");
        return error;
    }
    
    // Map over success value
    public  Result map(Function mapper) {
        if (success) {
            return Result.success(mapper.apply(value));
        }
        return Result.failure(error);
    }
    
    // FlatMap / bind for chaining
    public  Result flatMap(Function> mapper) {
        if (success) {
            return mapper.apply(value);
        }
        return Result.failure(error);
    }
    
    // Recover from failure
    public T orElse(T defaultValue) {
        return success ? value : defaultValue;
    }
    
    // Pattern match style handling
    public  R fold(Function failureHandler, Function successHandler) {
        return success ? successHandler.apply(value) : failureHandler.apply(error);
    }
}

// Usage example
public class UserRepository {
    
    public Result findById(String userId) {
        User user = databaseLookup(userId);
        if (user == null) {
            return Result.failure(
                new NotFoundException("User not found: " + userId)
            );
        }
        return Result.success(user);
    }
    
    public Result getProfile(User user) {
        UserProfile profile = profileLookup(user.getId());
        if (profile == null) {
            return Result.failure(
                new NotFoundException("Profile not found for user: " + user.getId())
            );
        }
        return Result.success(profile);
    }
    
    // Chaining operations without exception handling noise
    public Result getUserProfile(String userId) {
        return findById(userId)
            .flatMap(user -> getProfile(user));  // Only executed if findById succeeds
    }
    
    // Using fold for clean handling
    public String renderUserProfile(String userId) {
        return getUserProfile(userId).fold(
            error -> "Error: " + error.getMessage(),    // Failure path
            profile -> "Profile: " + profile.toString()  // Success path
        );
    }
}

6. Global Exception Handler Pattern (REST APIs)

In web applications, centralize exception-to-response mapping in a single @ControllerAdvice or @ExceptionHandler class. This prevents exception handling logic from scattering across controllers and ensures consistent error response formats.

// Standardized error response DTO
public class ApiErrorResponse {
    private final String errorCode;
    private final String message;
    private final String details;
    private final String timestamp;
    private final String path;
    private final Map additionalProperties;
    
    public ApiErrorResponse(String errorCode, String message, String details,
                             String timestamp, String path, 
                             Map additionalProperties) {
        this.errorCode = errorCode;
        this.message = message;
        this.details = details;
        this.timestamp = timestamp;
        this.path = path;
        this.additionalProperties = additionalProperties != null 
            ? new HashMap<>(additionalProperties) : new HashMap<>();
    }
    
    // Getters...
    public String getErrorCode() { return errorCode; }
    public String getMessage() { return message; }
    public String getDetails() { return details; }
    public String getTimestamp() { return timestamp; }
    public String getPath() { return path; }
    public Map getAdditionalProperties() { 
        return Collections.unmodifiableMap(additionalProperties); 
    }
}

// Global exception handler
@ControllerAdvice
public class GlobalExceptionHandler {
    
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity handleResourceNotFound(
            ResourceNotFoundException ex, WebRequest request) {
        
        logger.warn("Resource not found: {}", ex.getMessage());
        
        ApiErrorResponse response = new ApiErrorResponse(
            "RESOURCE_NOT_FOUND",
            ex.getMessage(),
            "The requested resource could not be located",
            Instant.now().toString(),
            extractPath(request),
            Map.of(
                "resourceType", ex.getResourceType(),
                "resourceId", ex.getResourceId()
            )
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }
    
    @ExceptionHandler(ValidationException.class)
    public ResponseEntity handleValidation(
            ValidationException ex, WebRequest request) {
        
        logger.warn("Validation failed: {} error(s)", ex.getFieldErrors().size());
        
        ApiErrorResponse response = new ApiErrorResponse(
            "VALIDATION_FAILED",
            "Request validation failed",
            ex.getFieldErrors().stream()
                .map(e -> e.field() + ": " + e.message())
                .collect(Collectors.joining("; ")),
            Instant.now().toString(),
            extractPath(request),
            Map.of("fieldErrors", ex.getFieldErrors())
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity handleGeneral(
            Exception ex, WebRequest request) {
        
        // Log full stack trace for unexpected errors
        logger.error("Unexpected error occurred", ex);
        
        // Do NOT expose internal details to the client
        ApiErrorResponse response = new ApiErrorResponse(
            "INTERNAL_SERVER_ERROR",
            "An unexpected error occurred",
            "Please contact support with reference ID: " + UUID.randomUUID(),
            Instant.now().toString(),
            extractPath(request),
            Map.of()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
    
    private String extractPath(WebRequest request) {
        if (request instanceof ServletWebRequest) {
            return ((ServletWebRequest) request).getRequest().getRequestURI();
        }
        return "unknown";
    }
}

7. Circuit Breaker Pattern

When calling external services, repeated failures should trigger a circuit breaker that temporarily stops all calls, allowing the downstream service to recover. After a cooldown period, the circuit moves to half-open state to test if the service has recovered.

public class CircuitBreaker {
    private final int failureThreshold;
    private final long cooldownMillis;
    private final long timeoutMillis;
    
    private enum State { CLOSED, OPEN, HALF_OPEN }
    
    private State state = State.CLOSED;
    private int failureCount = 0;
    private long lastFailureTimestamp = 0;
    private final AtomicInteger consecutiveSuccesses = new AtomicInteger(0);
    private final int requiredHalfOpenSuccesses = 3;
    
    public CircuitBreaker(int failureThreshold, long cooldownMillis, long timeoutMillis) {
        this.failureThreshold = failureThreshold;
        this.cooldownMillis = cooldownMillis;
        this.timeoutMillis = timeoutMillis;
    }
    
    public  T execute(Supplier action, Supplier fallback) {
        switch (state) {
            case OPEN:
                if (System.currentTimeMillis() - lastFailureTimestamp >= cooldownMillis) {
                    // Move to half-open to test the waters
                    synchronized (this) {
                        if (state == State.OPEN) {
                            state = State.HALF_OPEN;
                            consecutiveSuccesses.set(0);
                        }
                    }
                } else {
                    // Circuit is open, use fallback immediately
                    return fallback.get();
                }
                // Fall through to attempt execution in half-open state
            
            case HALF_OPEN:
            case CLOSED:
                try {
                    T result = action.get();
                    recordSuccess();
                    return result;
                } catch (Exception e) {
                    recordFailure();
                    return fallback.get();
                }
            default:
                return fallback.get();
        }
    }
    
    private synchronized void recordSuccess() {
        if (state == State.HALF_OPEN) {
            int successes = consecutiveSuccesses.incrementAndGet();
            if (successes >= requiredHalfOpenSuccesses) {
                state = State.CLOSED;
                failureCount = 0;
                consecutiveSuccesses.set(0);
                System.out.println("Circuit breaker reset to CLOSED after successful half-open test");
            }
        } else if (state == State.CLOSED) {
            failureCount = 0;  // Reset on any success when closed
        }
    }
    
    private synchronized void recordFailure() {
        failureCount++;
        lastFailureTimestamp = System.currentTimeMillis();
        
        if (state == State.HALF_OPEN) {
            // A single failure in half-open re-opens the circuit
            state = State.OPEN;
            System.out.println("Circuit breaker moved to OPEN from HALF_OPEN due to failure");
        } else if (state == State.CLOSED && failureCount >= failureThreshold) {
            state = State.OPEN;
            System.out.println("Circuit breaker moved to OPEN after " + failureCount + " failures");
        }
    }
    
    public State getState() { return state; }
    public int getFailureCount() { return failureCount; }
}

// Usage example with an external payment service
public class ResilientPaymentService {
    private final PaymentGateway paymentGateway;
    private final CircuitBreaker circuitBreaker;
    
    public ResilientPaymentService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
        this.circuitBreaker = new CircuitBreaker(
            5,            // Open circuit after 5 failures
            30_000,       // 30 second cooldown
            5_000         // 5 second timeout per call
        );
    }
    
    public PaymentResult processPayment(PaymentRequest request) {
        return circuitBreaker.execute(
            // Primary action
            () -> {
                // Use a timeout for the actual call
                CompletableFuture future = CompletableFuture.supplyAsync(
                    () -> paymentGateway.charge(request)
                );
                try {
                    return future.get(5_000, TimeUnit.MILLISECONDS);
                } catch (TimeoutException e) {
                    throw new RuntimeException("Payment gateway timeout", e);
                }
            },
            // Fallback action
            () -> {
                // Queue the payment for later retry, or return a pending status
                paymentGateway.queueForLaterProcessing(request);
                return PaymentResult.pending("Payment queued due to service unavailability");
            }
        );
    }
}

8. Retry Pattern with Exponential Backoff

For transient failures like network timeouts or temporary database contention, retry the operation with increasing delays between attempts. This avoids overwhelming a recovering service while still eventually succeeding.

public class RetryHandler {
    private final int maxRetries;
    private final long initialBackoffMillis;
    private final double backoffMultiplier;
    private final long maxBackoffMillis;
    private final Set> retryableExceptions;
    
    private RetryHandler(Builder builder) {
        this.maxRetries = builder.maxRetries;
        this.initialBackoffMillis = builder.initialBackoffMillis;
        this.backoffMultiplier = builder.backoffMultiplier;
        this.maxBackoffMillis = builder.maxBackoffMillis;
        this.retryableExceptions = builder.retryableExceptions;
    }
    
    public static class Builder {
        private int maxRetries = 3;
        private long initialBackoffMillis = 500;
        private double backoffMultiplier = 2.0;
        private long maxBackoffMillis = 10_000;
        private Set> retryableExceptions = new HashSet<>();
        
        public Builder maxRetries(int maxRetries) { 
            this.maxRetries = maxRetries; 
            return this; 
        }
        public Builder initialBackoff(long millis) { 
            this.initialBackoffMillis = millis; 
            return this; 
        }
        public Builder backoffMultiplier(double multiplier) { 
            this.backoffMultiplier = multiplier; 
            return this; 
        }
        public Builder maxBackoff(long millis) { 
            this.maxBackoffMillis = millis; 
            return this; 
        }
        public Builder retryOn(Class exceptionClass) {
            this.retryableExceptions.add(exceptionClass);
            return this;
        }
        public RetryHandler build() { return new RetryHandler(this); }
    }
    
    public  T executeWithRetry(Supplier operation) throws Exception {
        Exception lastException = null;
        long backoff = initialBackoffMillis;
        
        for (int attempt = 0; attempt <= maxRetries; attempt++) {
            try {
                if (attempt > 0) {
                    System.out.println("Retry attempt " + attempt + " of " + maxRetries 
                        + " after " + backoff + "ms backoff");
                }
                return operation.get();
            } catch (Exception e) {
                lastException = e;
                
                // Check if this exception is retryable
                boolean isRetryable = retryableExceptions.stream()
                    .anyMatch(cls -> cls.isAssignableFrom(e.getClass()));
                
                if (!isRetryable || attempt == maxRetries) {
                    throw e;  // Non-retryable or exhausted retries
                }
                
                // Calculate backoff with jitter
                long jitter = (long) (Math.random() * backoff * 0.1);  // 10% jitter
                long sleepTime = Math.min(backoff + jitter, maxBackoffMillis);
                
                try {
                    Thread.sleep(sleepTime);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException("Retry interrupted", ie);
                }
                
                backoff = Math.min(
                    (long) (backoff * backoffMultiplier),
                    maxBackoffMillis
                );
            }
        }
        
        throw new RuntimeException("Unexpected: retry loop exhausted", lastException);
    }
}

// Usage example
public class ResilientDatabaseOperation {
    private final DatabaseConnectionPool pool;
    private final RetryHandler retryHandler;
    
    public ResilientDatabaseOperation(DatabaseConnectionPool pool) {
        this.pool = pool;
        this.retryHandler = new RetryHandler.Builder()
            .maxRetries(5)
            .initialBackoff(100)   // Start with 100ms
            .backoffMultiplier(2.0) // 100, 200, 400, 800, 1600ms
            .maxBackoff(5000)
            .retryOn(java.sql.SQLTransientException.class)
            .retryOn(java.sql.SQLTimeoutException.class)
            .retryOn(RetryableException.class)
            .build();
    }
    
    public List queryWithRetry(String sql) throws Exception {
        return retryHandler.executeWithRetry(() -> {
            try (Connection conn = pool.getConnection();
                 PreparedStatement stmt = conn.prepareStatement(sql);
                 ResultSet rs = stmt.executeQuery()) {
                
                List results = new ArrayList<>();
                while (rs.next()) {
                    results.add(new Record(rs));
                }
                return results;
            } catch (SQLTransientException e) {
                // Will be caught by retry handler and retried
                throw e;
            } catch (SQLException e) {
                // Non-retryable SQL errors
                throw new RuntimeException("Permanent database error", e);
            }
        });
    }
}

9. Fallback / Graceful Degradation Pattern

When a primary operation fails, fall back to a simpler, less optimal but still acceptable alternative. This maintains partial functionality rather than complete failure. Common in microservices where cached or default data can substitute for live data.

public class ProductRecommendationService {
    private final RecommendationEngine primaryEngine;
    private final SimpleRecommendationEngine fallbackEngine;
    private final RedisCache recommendationCache;
    private final Logger logger = LoggerFactory.getLogger(ProductRecommendationService.class);
    
    public ProductRecommendationService(
            RecommendationEngine primaryEngine,
            SimpleRecommendationEngine fallbackEngine,
            RedisCache recommendationCache) {
        this.primaryEngine = primaryEngine;
        this.fallbackEngine = fallbackEngine;
        this.recommendationCache = recommendationCache;
    }
    
    public List getRecommendations(String userId, String category) {
        // Try primary ML-based engine
        try {
            List recommendations = primaryEngine.getRecommendations(
                userId, category, 10
            );
            // Cache successful results for fallback later
            recommendationCache.set("rec:" + userId + ":" + category, recommendations, 300);
            return recommendations;
        } catch (TimeoutException | ConnectException e) {
            logger.warn("Primary recommendation engine unavailable: {}", e.getMessage());
            // Fall through to fallback
        } catch (Exception e) {
            logger.error("Unexpected error in primary engine", e);
            // Fall through to fallback
        }
        
        // Try cache as first fallback
        Optional> cached = recommendationCache.get("rec:" + userId + ":" + category);
        if (cached.isPresent()) {
            logger.info("Serving recommendations from cache for user {}", userId);
            return cached.get();
        }
        
        // Try simple fallback engine
        try {
            logger.info("Using simple fallback recommendation engine");
            List fallbackRecs = fallbackEngine.getRecommendations(category, 10);
            return fallbackRecs;
        } catch (Exception e) {
            logger.error("Fallback engine also failed", e);
        }
        
        // Ultimate fallback: static defaults
        logger.warn("All recommendation sources failed, using static defaults");
        return getStaticDefaultRecommendations(category);
    }
    
    private List getStaticDefaultRecommendations(String category) {
        // Return a curated list of popular products in this category
        return List.of(
            new Product("default-1", "Popular Item A", category),
            new Product("default-2", "Popular Item B", category),
            new Product("default-3", "Popular Item C", category)
        );
    }
}

10. Logging and Context Propagation Pattern

Errors must carry enough context for debugging. Use structured logging and thread-local context (like MDC in SLF4J) to propagate correlation IDs, user IDs, and transaction IDs across layers without modifying every method signature.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

public class RequestContext {
    private static final String CORRELATION_ID_KEY = "correlationId";
    private static final String USER_ID_KEY = "userId";
    private static final String SESSION_ID_KEY = "sessionId";
    
    public static void setCorrelationId(String correlationId) {
        MDC.put(CORRELATION_ID_KEY, correlationId);
    }
    
    public static void setUserId(String userId) {
        MDC.put(USER_ID_KEY, userId);
    }
    
    public static void setSessionId(String sessionId) {
        MDC.put(SESSION_ID_KEY, sessionId);
    }
    
    public static String getCorrelationId() {
        return MDC.get(CORRELATION_ID_KEY);
    }
    
    public static void clear() {
        MDC.clear();
    }
    
    // Wrap an operation with context
    public static  T withContext(String correlationId, String userId, 
                                     Supplier operation) {
        Map previousContext = MDC.getCopyOfContextMap();
        try {
            MDC.put(CORRELATION_ID_KEY, correlationId);
            MDC.put(USER_ID_KEY, userId);
            return operation.get();
        } finally {
            if (previousContext != null) {
                MDC.setContextMap(previousContext);
            } else {
                MDC.clear();
            }
        }
    }
}

// Usage in service layer
public class OrderProcessingService {
    private static final Logger logger = LoggerFactory.getLogger(OrderProcessingService.class);
    
    public void processOrder(Order order) {
        // MDC context is already set by a filter/interceptor at request entry
        
        logger.info("Starting order processing for order {}", order.getId());
        
        try {
            validateOrder(order);
            calculatePricing(order);
            reserveInventory(order);
            processPayment(order);
            confirmOrder(order);
            
            logger.info("Order {} processed successfully", order.getId());
        } catch (ValidationException e) {
            // MDC context automatically included in log output
            logger.error("Order validation failed for order {}: {}", 
                order.getId(), e.getFieldErrors(), e);
            throw e;
        } catch (InsufficientInventoryException e) {
            logger.warn("Insufficient inventory for order {}: sku={}, requested={}, available={}",
                order.getId(), e.getSku(), e.getRequested(), e.getAvailable());
            throw new OrderProcessingException(
                order.getId(), ErrorCode.INVENTORY_UNAVAILABLE,
                "Not enough inventory for " + e.getSku(), e
            );
        } catch (Exception e) {
            // Log with full context and stack trace
            logger.error("Unexpected error processing order {}", order.getId(), e);
            // Optionally capture a snapshot of the order state
            logger.error("Order state at failure: {}", order.toDetailedString());
            throw new OrderProcessingException(
                order.getId(), ErrorCode.UNKNOWN,
                "Unexpected error during processing", e
            );
        }
    }
    
    private void validateOrder(Order order) throws ValidationException {
        // Implementation...
    }
    private void calculatePricing(Order order) { /* ... */ }
    private void reserveInventory(Order order) throws InsufficientInventoryException { /* ... */ }
    private void processPayment(Order order) { /* ... */ }
    private void confirmOrder(Order order) { /* ... */ }
}

Best Practices Summary

Conclusion

Error handling in Java is far more than sprinkling try-catch blocks throughout your codebase. It is an architectural concern that

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles