Error Handling Patterns

Robust Exception Management in Java

← Back to Index

Exception Hierarchy Strategy

Organize exceptions in a meaningful hierarchy that reflects your domain.

// Base exception for your application
public class ApplicationException extends RuntimeException {
    private final ErrorCode errorCode;

    public ApplicationException(ErrorCode code, String message) {
        super(message);
        this.errorCode = code;
    }

    public ApplicationException(ErrorCode code, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = code;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

// Domain-specific exceptions
public class EntityNotFoundException extends ApplicationException {
    public EntityNotFoundException(String entityType, Object id) {
        super(ErrorCode.NOT_FOUND,
              String.format("%s not found with id: %s", entityType, id));
    }
}

public class ValidationException extends ApplicationException {
    private final List<FieldError> fieldErrors;

    public ValidationException(List<FieldError> errors) {
        super(ErrorCode.VALIDATION_FAILED, "Validation failed");
        this.fieldErrors = errors;
    }
}

public class BusinessRuleException extends ApplicationException {
    public BusinessRuleException(String message) {
        super(ErrorCode.BUSINESS_RULE_VIOLATION, message);
    }
}

Error Codes

public enum ErrorCode {
    NOT_FOUND("E001", 404),
    VALIDATION_FAILED("E002", 400),
    BUSINESS_RULE_VIOLATION("E003", 422),
    UNAUTHORIZED("E004", 401),
    INTERNAL_ERROR("E999", 500);

    private final String code;
    private final int httpStatus;

    ErrorCode(String code, int httpStatus) {
        this.code = code;
        this.httpStatus = httpStatus;
    }
}

Try-with-Resources Pattern

// Always use try-with-resources for AutoCloseable resources
public List<String> readLines(Path path) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(path)) {
        return reader.lines().collect(Collectors.toList());
    }
    // Resource automatically closed, even on exception
}

// Multiple resources
public void copyData(Path source, Path target) throws IOException {
    try (
        InputStream in = Files.newInputStream(source);
        OutputStream out = Files.newOutputStream(target)
    ) {
        in.transferTo(out);
    }
}

// Custom AutoCloseable
public class DatabaseConnection implements AutoCloseable {
    private final Connection connection;

    @Override
    public void close() {
        try {
            connection.close();
        } catch (SQLException e) {
            // Log but don't rethrow in close()
            log.warn("Failed to close connection", e);
        }
    }
}

Fail-Fast Principle

// Validate early, fail fast
public class OrderService {

    public Order createOrder(OrderRequest request) {
        // Validate at the entry point
        validateRequest(request);
        validateBusinessRules(request);

        // If we get here, we know the request is valid
        return processOrder(request);
    }

    private void validateRequest(OrderRequest request) {
        Objects.requireNonNull(request, "Request cannot be null");
        Objects.requireNonNull(request.getCustomerId(), "Customer ID required");

        if (request.getItems().isEmpty()) {
            throw new ValidationException("Order must have at least one item");
        }
    }

    private void validateBusinessRules(OrderRequest request) {
        Customer customer = customerRepository.findById(request.getCustomerId())
            .orElseThrow(() -> new EntityNotFoundException("Customer", request.getCustomerId()));

        if (customer.hasOutstandingDebt()) {
            throw new BusinessRuleException("Cannot create order: customer has outstanding debt");
        }
    }
}

Optional for Nullable Returns

// BAD: Returns null
public User findByEmail(String email) {
    return userMap.get(email);  // Could be null!
}

// GOOD: Returns Optional
public Optional<User> findByEmail(String email) {
    return Optional.ofNullable(userMap.get(email));
}

// Using Optional effectively
public UserDto getUserProfile(String email) {
    return userRepository.findByEmail(email)
        .map(this::toDto)
        .orElseThrow(() -> new EntityNotFoundException("User", email));
}

// Optional with default value
public User getOrCreateGuest(String email) {
    return userRepository.findByEmail(email)
        .orElseGet(() -> createGuestUser(email));
}

// DON'T use Optional for:
// - Fields (use null instead)
// - Method parameters (overload or use @Nullable)
// - Collections (return empty collection)

Global Exception Handling (Spring)

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
        log.debug("Entity not found: {}", ex.getMessage());
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse(ex.getErrorCode(), ex.getMessage()));
    }

    @ExceptionHandler(ValidationException.class)
    public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
        log.debug("Validation failed: {}", ex.getFieldErrors());
        ErrorResponse response = new ErrorResponse(
            ex.getErrorCode(),
            ex.getMessage(),
            ex.getFieldErrors()
        );
        return ResponseEntity.badRequest().body(response);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
        // Log full stack trace for unexpected errors
        log.error("Unexpected error", ex);

        // Don't expose internal details to client
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse(
                ErrorCode.INTERNAL_ERROR,
                "An unexpected error occurred"
            ));
    }
}

// Standardized error response
public record ErrorResponse(
    String code,
    String message,
    Instant timestamp,
    List<FieldError> fieldErrors
) {
    public ErrorResponse(ErrorCode code, String message) {
        this(code.getCode(), message, Instant.now(), List.of());
    }
}

Logging Best Practices

public class PaymentService {
    private static final Logger log = LoggerFactory.getLogger(PaymentService.class);

    public void processPayment(Payment payment) {
        log.info("Processing payment: id={}, amount={}",
                 payment.getId(), payment.getAmount());

        try {
            gateway.process(payment);
            log.info("Payment processed successfully: id={}", payment.getId());

        } catch (GatewayTimeoutException e) {
            // Recoverable - log as warning
            log.warn("Payment gateway timeout, will retry: id={}", payment.getId());
            throw new RetryableException(e);

        } catch (PaymentDeclinedException e) {
            // Expected business case - log as info or debug
            log.info("Payment declined: id={}, reason={}",
                     payment.getId(), e.getReason());
            throw e;

        } catch (Exception e) {
            // Unexpected - log as error with stack trace
            log.error("Unexpected error processing payment: id={}",
                      payment.getId(), e);
            throw new PaymentException("Failed to process payment", e);
        }
    }
}
Logging Levels
  • ERROR - Application failure, needs attention
  • WARN - Potential problem, recoverable
  • INFO - Important business events
  • DEBUG - Detailed diagnostic info
  • TRACE - Very detailed, typically for debugging

Exception Translation

// Translate low-level exceptions to domain exceptions
public class UserRepository {

    public User save(User user) {
        try {
            return jdbcTemplate.update(sql, user.getName(), user.getEmail());
        } catch (DuplicateKeyException e) {
            // Translate to domain exception
            throw new UserAlreadyExistsException(user.getEmail(), e);
        } catch (DataAccessException e) {
            // Wrap infrastructure exception
            throw new RepositoryException("Failed to save user", e);
        }
    }
}

// Preserve the cause chain
public class UserAlreadyExistsException extends ApplicationException {
    public UserAlreadyExistsException(String email, Throwable cause) {
        super(ErrorCode.DUPLICATE_ENTITY,
              "User already exists with email: " + email,
              cause);  // Important: preserve the cause!
    }
}

Anti-Patterns to Avoid

// BAD: Empty catch block
try {
    riskyOperation();
} catch (Exception e) {
    // Silently swallowed!
}

// BAD: Catching Throwable or Error
try {
    operation();
} catch (Throwable t) {
    // Catches OutOfMemoryError, StackOverflowError, etc.
}

// BAD: Using exceptions for flow control
try {
    int value = Integer.parseInt(input);
} catch (NumberFormatException e) {
    value = 0;  // Use validation instead!
}

// GOOD: Validate first
if (input.matches("\\d+")) {
    value = Integer.parseInt(input);
} else {
    value = 0;
}

// BAD: Log and rethrow
try {
    operation();
} catch (Exception e) {
    log.error("Error", e);
    throw e;  // Logs twice if caller also logs!
}

// BAD: throw new Exception()
throw new Exception("Something went wrong");  // Too generic!

// GOOD: Use specific exceptions
throw new InvalidOrderStateException(order.getId(), order.getStatus());