Checked vs Unchecked: The debate that defines Java error handling
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
└── ...
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 Exceptions | Unchecked Exceptions |
|---|---|
Must be declared with throws | No declaration required |
| Compiler forces handling | Caller can ignore |
| External failures (IO, network, DB) | Programming errors (bugs) |
| Caller should be able to recover | Usually not recoverable |
Extends Exception | Extends RuntimeException |
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); }
Any class implementing AutoCloseable (streams, connections, readers, etc.)
should be used with try-with-resources. It handles closing correctly even when exceptions occur.
// 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
// 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
// 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) // ...
// 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)); }
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
new XxxException(msg, cause)| Checked Exception | External failure the caller can recover from (retry, use default, alert user) |
| Unchecked Exception | Programming error or unrecoverable state (null, invalid argument, impossible state) |
| Optional | Absence is normal and expected (lookup that may not find anything) |
| Result Type | Multiple possible failure modes with detailed error info |