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:
- Resilience — the system continues operating even when parts fail
- Observability — errors are logged with enough context to debug quickly
- User trust — failures are communicated clearly rather than exposing cryptic internals
- Data integrity — transactions roll back rather than leaving half-written state
- Security — error responses do not leak stack traces or internal paths to attackers
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 extends Exception> 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
- Never swallow exceptions silently. An empty catch block is one of the most dangerous patterns in Java. At minimum, log the exception with context. If you genuinely expect and can handle a condition, document why the catch is empty.
- Catch specific exceptions, not
ExceptionorThrowable. CatchingThrowablecan maskErrortypes likeOutOfMemoryErrorthat should propagate. Reserve broad catches for top-level exception handlers that log and wrap. - Preserve the original exception chain. Always pass the caught exception to the constructor of your wrapper exception. This preserves the full stack trace and root cause for debugging.
- Use checked exceptions for recoverable conditions, unchecked for programming errors. Checked exceptions force callers to handle expected failure modes. Unchecked exceptions (extending
RuntimeException) indicate bugs that callers cannot reasonably recover from. - Never use exceptions for control flow. Exceptions are expensive — they capture stack traces and are slow. Use the Result pattern or
Optionalfor expected, non-exceptional failure paths. - Design error response boundaries. In layered architectures, each layer should catch exceptions from the layer below and translate them into its own abstraction. A
SQLExceptionshould never propagate to a REST controller. - Include correlation IDs in all error logs. In distributed systems, a single user request may touch dozens of services. A correlation ID propagated across all calls allows you to trace the entire journey of a failed request.
- Test failure paths. Write unit tests that inject exceptions into your dependencies and verify that your error handling produces the expected outcome — correct logging, proper rollback, appropriate fallback, and clean error responses.
- Consider idempotency in retry logic. When retrying operations, ensure the operation is idempotent or use idempotency keys so that duplicate executions don't create duplicate orders, charges, or records.
- Monitor error rates. Wire your error handling into metrics systems. Track error counts by type, circuit breaker state changes, retry rates, and fallback activations. These metrics often provide the earliest warning of systemic problems.
Conclusion
Error handling in Java is far more than sprinkling try-catch blocks throughout your codebase. It is an architectural concern that