Exception Handling in Java

Checked vs Unchecked: The debate that defines Java error handling

The Exception Hierarchy

Throwable
    ├── Error                    // JVM problems - don't catch these
    │     ├── OutOfMemoryError
    │     ├── StackOverflowError
    │     └── ...
    │
    └── Exception                // Application problems
          │
          ├── RuntimeException   // UNCHECKED - programming errors
          │     ├── NullPointerException
          │     ├── IllegalArgumentException
          │     ├── IndexOutOfBoundsException
          │     └── ...
          │
          └── [All others]       // CHECKED - recoverable errors
                ├── IOException
                ├── SQLException
                ├── ParseException
                └── ...

Checked vs Unchecked Exceptions

This is Java's most debated design decision. Understanding the philosophy is crucial for architects.

// CHECKED EXCEPTIONS: Must be declared or handled
// Philosophy: "Recoverable errors the caller should know about"

public String readFile(String path) throws IOException {
    // IOException is checked - declared in signature
    return Files.readString(Path.of(path));
}

// Caller MUST handle or propagate
public void process() {
    // Option 1: Handle it
    try {
        String content = readFile("data.txt");
    } catch (IOException e) {
        log.error("Failed to read file", e);
        // Recover, retry, or throw different exception
    }

    // Option 2: Propagate it
    // public void process() throws IOException { ... }
}

// Common checked exceptions:
// - IOException (file/network operations)
// - SQLException (database operations)
// - ParseException (parsing dates, etc.)
// - InterruptedException (threading)
// UNCHECKED EXCEPTIONS (RuntimeException): No declaration required
// Philosophy: "Programming errors that shouldn't happen"

public User getUser(String id) {
    if (id == null) {
        // IllegalArgumentException is unchecked
        throw new IllegalArgumentException("id cannot be null");
    }
    return repository.findById(id)
        .orElseThrow(() -> new UserNotFoundException(id));
}

// Caller doesn't HAVE to handle these
public void process() {
    User user = getUser("123");  // Compiles without try/catch
    // If it throws, it propagates automatically
}

// Common unchecked exceptions:
// - NullPointerException (null access)
// - IllegalArgumentException (bad input)
// - IllegalStateException (wrong state)
// - IndexOutOfBoundsException (array/list bounds)
// - UnsupportedOperationException (not implemented)
Checked ExceptionsUnchecked Exceptions
Must be declared with throwsNo declaration required
Compiler forces handlingCaller can ignore
External failures (IO, network, DB)Programming errors (bugs)
Caller should be able to recoverUsually not recoverable
Extends ExceptionExtends RuntimeException

Try-Catch-Finally

try:
    file = open("data.txt")
    content = file.read()
except FileNotFoundError as e:
    print(f"File not found: {e}")
except IOError as e:
    print(f"IO error: {e}")
except Exception as e:
    print(f"Unexpected: {e}")
else:
    print("Success!")  # Only if no exception
finally:
    file.close()  # Always runs

# Context manager (preferred)
with open("data.txt") as file:
    content = file.read()
# File automatically closed
// Basic try-catch-finally
FileReader reader = null;
try {
    reader = new FileReader("data.txt");
    // ... use reader
} catch (FileNotFoundException e) {
    log.error("File not found", e);
} catch (IOException e) {
    log.error("IO error", e);
} finally {
    // Always runs - cleanup
    if (reader != null) {
        try {
            reader.close();
        } catch (IOException e) {
            // Swallowed - ugly!
        }
    }
}

// TRY-WITH-RESOURCES (Java 7+) - much better!
try (FileReader reader = new FileReader("data.txt");
     BufferedReader buffered = new BufferedReader(reader)) {
    String line = buffered.readLine();
    // ... use resources
} catch (IOException e) {
    log.error("Error reading file", e);
}
// Resources automatically closed, even on exception!
// Any class implementing AutoCloseable works

// Multi-catch (Java 7+)
try {
    doSomething();
} catch (IOException | SQLException e) {
    // Handle both the same way
    log.error("External service failed", e);
}

Always Use Try-With-Resources

Any class implementing AutoCloseable (streams, connections, readers, etc.) should be used with try-with-resources. It handles closing correctly even when exceptions occur.

Creating Custom Exceptions

// Checked exception - caller must handle
// Use when caller can reasonably recover

public class InsufficientFundsException extends Exception {
    private final BigDecimal requested;
    private final BigDecimal available;

    public InsufficientFundsException(BigDecimal requested, BigDecimal available) {
        super(String.format(
            "Requested %s but only %s available",
            requested, available
        ));
        this.requested = requested;
        this.available = available;
    }

    public BigDecimal getRequested() { return requested; }
    public BigDecimal getAvailable() { return available; }
}

// Usage
public void withdraw(BigDecimal amount) throws InsufficientFundsException {
    if (amount.compareTo(balance) > 0) {
        throw new InsufficientFundsException(amount, balance);
    }
    balance = balance.subtract(amount);
}
// Unchecked exception - programming error or unrecoverable
// Use for invalid arguments, illegal states, etc.

public class UserNotFoundException extends RuntimeException {
    private final String userId;

    public UserNotFoundException(String userId) {
        super("User not found: " + userId);
        this.userId = userId;
    }

    public UserNotFoundException(String userId, Throwable cause) {
        super("User not found: " + userId, cause);
        this.userId = userId;
    }

    public String getUserId() { return userId; }
}

// Usage - no throws declaration needed
public User getUser(String id) {
    return repository.findById(id)
        .orElseThrow(() -> new UserNotFoundException(id));
}

// Caller can catch if they want, but don't have to
User user = getUser("123");  // May throw, compiles fine

Exception Handling Patterns

Pattern 1: Exception Translation

// Don't leak implementation details through exceptions
// Translate low-level exceptions to domain exceptions

public class UserRepository {
    public User findById(String id) {
        try {
            return jdbc.queryForObject(
                "SELECT * FROM users WHERE id = ?",
                userRowMapper,
                id
            );
        } catch (EmptyResultDataAccessException e) {
            // Translate to domain exception
            throw new UserNotFoundException(id);
        } catch (DataAccessException e) {
            // Wrap with cause preserved
            throw new RepositoryException("Failed to find user", e);
        }
    }
}

// Caller doesn't need to know about JDBC, SQL, or data access details
// They deal with UserNotFoundException and RepositoryException

Pattern 2: Exception Chaining

// Always preserve the root cause!

public Config loadConfig() {
    try {
        String json = Files.readString(Path.of("config.json"));
        return objectMapper.readValue(json, Config.class);
    } catch (IOException e) {
        // BAD: loses original exception
        // throw new ConfigException("Failed to load config");

        // GOOD: preserves cause chain
        throw new ConfigException("Failed to load config", e);
    } catch (JsonProcessingException e) {
        throw new ConfigException("Invalid config format", e);
    }
}

// When debugging, you see the full stack trace:
// ConfigException: Failed to load config
//   at ConfigLoader.loadConfig(ConfigLoader.java:25)
// Caused by: java.io.FileNotFoundException: config.json
//   at java.io.FileInputStream.open0(Native Method)
//   ...

Pattern 3: Fail-Fast Validation

// Validate inputs immediately - don't wait until deep in the call stack

public class UserService {

    public User createUser(String name, String email) {
        // Fail fast with clear messages
        Objects.requireNonNull(name, "name cannot be null");
        Objects.requireNonNull(email, "email cannot be null");

        if (name.isBlank()) {
            throw new IllegalArgumentException("name cannot be blank");
        }
        if (!EMAIL_PATTERN.matcher(email).matches()) {
            throw new IllegalArgumentException("invalid email format");
        }

        // Now we KNOW inputs are valid - no need to check again
        return repository.save(new User(name, email));
    }
}

// Using Guava Preconditions (cleaner)
import static com.google.common.base.Preconditions.*;

public User createUser(String name, String email) {
    checkNotNull(name, "name cannot be null");
    checkArgument(!name.isBlank(), "name cannot be blank");
    checkArgument(isValidEmail(email), "invalid email: %s", email);

    return repository.save(new User(name, email));
}

Modern Alternative: Result Types

Many modern Java codebases avoid exceptions for expected error cases:

// Use Optional when absence is expected, not exceptional

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

// Caller handles presence/absence explicitly
User user = findByEmail("alice@example.com")
    .orElseThrow(() -> new UserNotFoundException(email));

// Or with default
User user = findByEmail(email)
    .orElse(createGuestUser());

// Or with lazy default
User user = findByEmail(email)
    .orElseGet(() -> createGuestUser());

// Chaining
String city = findByEmail(email)
    .map(User::getAddress)
    .map(Address::getCity)
    .orElse("Unknown");
// Result type: Either success or failure with details
// (Using sealed types from Java 17)

public sealed interface Result<T>
    permits Success, Failure {
}

public record Success<T>(T value) implements Result<T> {}

public record Failure<T>(String error, String code) implements Result<T> {}

// Usage
public Result<User> createUser(String name, String email) {
    if (name.isBlank()) {
        return new Failure<>("Name is required", "INVALID_NAME");
    }
    if (emailExists(email)) {
        return new Failure<>("Email already registered", "DUPLICATE_EMAIL");
    }
    return new Success<>(repository.save(new User(name, email)));
}

// Caller handles result
Result<User> result = createUser(name, email);
switch (result) {
    case Success<User> s -> log.info("Created user: " + s.value().getId());
    case Failure<User> f -> log.warn("Failed: " + f.error());
}

// Libraries like Vavr provide rich Result/Either types

Best Practices Summary

Exception Anti-Patterns

When to Use Which

Checked ExceptionExternal failure the caller can recover from (retry, use default, alert user)
Unchecked ExceptionProgramming error or unrecoverable state (null, invalid argument, impossible state)
OptionalAbsence is normal and expected (lookup that may not find anything)
Result TypeMultiple possible failure modes with detailed error info