7.1 Exception Fundamentals

Exceptions are Java's mechanism for handling abnormal conditions that disrupt normal program flow. Understanding the exception hierarchy, types, and when to use each is fundamental to writing robust Java applications.

The Exception Hierarchy

Throwable
├── Error (serious problems, typically not caught)
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── AssertionError
└── Exception (conditions that should be caught)
    ├── RuntimeException (unchecked exceptions)
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   ├── IllegalStateException
    │   ├── IndexOutOfBoundsException
    │   ├── ClassCastException
    │   └── UnsupportedOperationException
    └── Checked Exceptions (must be declared or caught)
        ├── IOException
        ├── SQLException
        ├── ParseException
        └── ClassNotFoundException

Checked vs Unchecked Exceptions

Checked Exceptions (extend Exception but not RuntimeException):

  • Must be declared in method signature or caught
  • Represent recoverable conditions
  • Force callers to handle the error
  • Use sparingly in modern Java
// Checked exception - must declare or catch
public String readFile(Path path) throws IOException {
    return Files.readString(path);
}

// Caller must handle it
public void processFile() {
    try {
        String content = readFile(Path.of("data.txt"));
        // process content
    } catch (IOException e) {
        // handle error
        System.err.println("Failed to read file: " + e.getMessage());
    }
}

Unchecked Exceptions (extend RuntimeException):

  • Not required to be declared or caught
  • Represent programming errors or unrecoverable conditions
  • Can occur anywhere in the code
  • Preferred for most application-level exceptions
// Unchecked exception - no declaration required
public User findUser(String id) {
    if (id == null) {
        throw new IllegalArgumentException("User ID cannot be null");
    }
    User user = repository.get(id);
    if (user == null) {
        throw new EntityNotFoundException("User not found: " + id);
    }
    return user;
}

// Caller can choose whether to handle
public void displayUser(String id) {
    User user = findUser(id); // may throw, but no try-catch required
    System.out.println(user.name());
}

When to Use Checked vs Unchecked

Use Checked Exceptions when:

  • The caller can reasonably recover from the error
  • The error is expected and part of normal operation
  • You're designing a library API and want to force handling
// Example: File parsing where caller decides how to handle
public class ConfigParser {
    public Config parse(Path file) throws ConfigParseException {
        // Checked because caller should decide: use defaults? retry? fail?
        try {
            return parseConfig(Files.readString(file));
        } catch (IOException e) {
            throw new ConfigParseException("Cannot read config file", e);
        }
    }
}

Use Unchecked Exceptions when:

  • The error is a programming bug (null pointer, illegal argument)
  • Recovery is unlikely or not meaningful
  • You want to avoid cluttering code with try-catch blocks
  • The error should propagate up the call stack
// Example: Validation errors
public class OrderService {
    public Order createOrder(OrderRequest request) {
        // Unchecked - these are programming errors
        if (request == null) {
            throw new IllegalArgumentException("Request cannot be null");
        }
        if (request.items().isEmpty()) {
            throw new IllegalStateException("Order must have items");
        }
        // ... create order
    }
}

Common Built-in Exceptions

IllegalArgumentException:

public void setAge(int age) {
    if (age < 0 || age > 150) {
        throw new IllegalArgumentException(
            "Age must be between 0 and 150, got: " + age
        );
    }
    this.age = age;
}

IllegalStateException:

public class Connection {
    private boolean connected = false;

    public void send(String data) {
        if (!connected) {
            throw new IllegalStateException(
                "Cannot send data: connection not established"
            );
        }
        // send data
    }
}

NullPointerException (with message, Java 14+):

public void process(String input) {
    Objects.requireNonNull(input, "Input cannot be null");
    // process input
}

// Or let Java generate helpful NPE messages automatically
String result = user.getAddress().getCity(); // NPE shows which was null

UnsupportedOperationException:

public class ImmutableList<T> extends AbstractList<T> {
    @Override
    public T set(int index, T element) {
        throw new UnsupportedOperationException(
            "Cannot modify immutable list"
        );
    }
}

IndexOutOfBoundsException:

public T get(int index) {
    if (index < 0 || index >= size) {
        throw new IndexOutOfBoundsException(
            "Index %d out of bounds for size %d".formatted(index, size)
        );
    }
    return elements[index];
}

Basic Exception Handling

Try-Catch:

public String loadContent(String url) {
    try {
        HttpResponse response = httpClient.get(url);
        return response.body();
    } catch (IOException e) {
        // Handle specific exception
        System.err.println("Network error: " + e.getMessage());
        return ""; // return default
    }
}

Multiple Catch Blocks:

public User parseUser(String json) {
    try {
        return objectMapper.readValue(json, User.class);
    } catch (JsonParseException e) {
        // Handle parsing errors
        throw new InvalidDataException("Invalid JSON syntax", e);
    } catch (JsonMappingException e) {
        // Handle mapping errors
        throw new InvalidDataException("Cannot map to User", e);
    } catch (IOException e) {
        // Handle general I/O errors
        throw new RuntimeException("Failed to parse user", e);
    }
}

Multi-Catch (Java 7+):

public void processFile(Path file) {
    try {
        String content = Files.readString(file);
        processContent(content);
    } catch (IOException | ParseException e) {
        // Handle multiple exception types the same way
        throw new ProcessingException("Failed to process file", e);
    }
}

Finally Block:

Connection conn = null;
try {
    conn = openConnection();
    conn.send(data);
} catch (IOException e) {
    System.err.println("Send failed: " + e.getMessage());
} finally {
    // Always executes, even if exception thrown
    if (conn != null) {
        conn.close();
    }
}

Exception Propagation

Let Exceptions Propagate:

// Low-level method throws
public String readFile(Path path) throws IOException {
    return Files.readString(path);
}

// Mid-level method propagates
public Config loadConfig(Path path) throws IOException {
    String content = readFile(path); // propagates IOException
    return parseConfig(content);
}

// High-level method handles
public void startup() {
    try {
        Config config = loadConfig(Path.of("config.json"));
        initialize(config);
    } catch (IOException e) {
        System.err.println("Startup failed: " + e.getMessage());
        System.exit(1);
    }
}

Wrapping Exceptions:

// Wrap low-level exception in domain exception
public class UserRepository {
    public User findById(String id) {
        try {
            ResultSet rs = executeQuery("SELECT * FROM users WHERE id = ?", id);
            return mapToUser(rs);
        } catch (SQLException e) {
            // Wrap SQL exception in domain exception
            throw new RepositoryException(
                "Failed to find user: " + id,
                e  // preserve original cause
            );
        }
    }
}

Rethrowing Exceptions

Rethrow after Logging:

public void processOrder(Order order) {
    try {
        validate(order);
        save(order);
        sendConfirmation(order);
    } catch (ValidationException e) {
        // Log and rethrow
        logger.error("Order validation failed: {}", order.id(), e);
        throw e;
    } catch (Exception e) {
        // Log unexpected errors
        logger.error("Unexpected error processing order: {}", order.id(), e);
        throw new OrderProcessingException("Failed to process order", e);
    }
}

Try-Catch in Streams:

// Wrap checked exceptions in unchecked for use in streams
public List<String> loadFiles(List<Path> paths) {
    return paths.stream()
        .map(path -> {
            try {
                return Files.readString(path);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        })
        .toList();
}

// Or use helper method
public List<String> loadFilesHelper(List<Path> paths) {
    return paths.stream()
        .map(this::readFileUnchecked)
        .toList();
}

private String readFileUnchecked(Path path) {
    try {
        return Files.readString(path);
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    }
}

Real-World Example: Service Layer

import java.util.*;

// Custom exception hierarchy
class ServiceException extends RuntimeException {
    public ServiceException(String message) {
        super(message);
    }

    public ServiceException(String message, Throwable cause) {
        super(message, cause);
    }
}

class ValidationException extends ServiceException {
    private final Map<String, String> fieldErrors;

    public ValidationException(String message, Map<String, String> fieldErrors) {
        super(message);
        this.fieldErrors = fieldErrors;
    }

    public Map<String, String> getFieldErrors() {
        return fieldErrors;
    }
}

class EntityNotFoundException extends ServiceException {
    private final String entityType;
    private final String entityId;

    public EntityNotFoundException(String entityType, String entityId) {
        super(String.format("%s not found: %s", entityType, entityId));
        this.entityType = entityType;
        this.entityId = entityId;
    }

    public String getEntityType() { return entityType; }
    public String getEntityId() { return entityId; }
}

// Service implementation
class UserService {
    private final UserRepository repository;
    private final EmailService emailService;

    public UserService(UserRepository repository, EmailService emailService) {
        this.repository = repository;
        this.emailService = emailService;
    }

    public User createUser(CreateUserRequest request) {
        // Validation
        validateRequest(request);

        // Check for duplicates
        if (repository.existsByEmail(request.email())) {
            throw new ValidationException(
                "Email already exists",
                Map.of("email", "Email is already registered")
            );
        }

        try {
            // Create user
            User user = new User(
                UUID.randomUUID().toString(),
                request.email(),
                request.name()
            );

            User saved = repository.save(user);

            // Send welcome email (don't fail if this fails)
            try {
                emailService.sendWelcome(saved.email());
            } catch (Exception e) {
                // Log but don't propagate
                System.err.println("Failed to send welcome email: " + e.getMessage());
            }

            return saved;

        } catch (Exception e) {
            // Wrap unexpected exceptions
            throw new ServiceException("Failed to create user", e);
        }
    }

    public User getUser(String userId) {
        return repository.findById(userId)
            .orElseThrow(() -> new EntityNotFoundException("User", userId));
    }

    public void updateUserEmail(String userId, String newEmail) {
        // Validate
        if (newEmail == null || newEmail.isBlank()) {
            throw new IllegalArgumentException("Email cannot be blank");
        }

        // Find user
        User user = getUser(userId);

        // Check duplicate
        if (repository.existsByEmail(newEmail)) {
            throw new ValidationException(
                "Email already exists",
                Map.of("email", "Email is already registered")
            );
        }

        // Update
        User updated = user.withEmail(newEmail);
        repository.save(updated);
    }

    private void validateRequest(CreateUserRequest request) {
        Map<String, String> errors = new HashMap<>();

        if (request.email() == null || request.email().isBlank()) {
            errors.put("email", "Email is required");
        } else if (!request.email().contains("@")) {
            errors.put("email", "Invalid email format");
        }

        if (request.name() == null || request.name().isBlank()) {
            errors.put("name", "Name is required");
        }

        if (!errors.isEmpty()) {
            throw new ValidationException("Validation failed", errors);
        }
    }
}

// Usage in controller/handler
class UserController {
    private final UserService userService;

    public Response createUser(CreateUserRequest request) {
        try {
            User user = userService.createUser(request);
            return Response.ok(user);
        } catch (ValidationException e) {
            return Response.badRequest(e.getMessage(), e.getFieldErrors());
        } catch (ServiceException e) {
            return Response.serverError(e.getMessage());
        }
    }
}

Best Practices

  1. Be Specific with Exception Types

    // Bad - too generic
    throw new Exception("Something went wrong");
    
    // Good - specific
    throw new InvalidEmailException("Invalid email format: " + email);
    
  2. Include Context in Messages

    // Bad - not helpful
    throw new IllegalArgumentException("Invalid input");
    
    // Good - provides context
    throw new IllegalArgumentException(
        "Age must be between 0 and 150, got: " + age
    );
    
  3. Preserve the Original Cause

    // Bad - loses original cause
    catch (IOException e) {
        throw new ServiceException("Failed to read file");
    }
    
    // Good - preserves cause
    catch (IOException e) {
        throw new ServiceException("Failed to read file", e);
    }
    
  4. Don't Swallow Exceptions

    // Bad - exception is lost
    try {
        doSomething();
    } catch (Exception e) {
        // empty catch - very bad!
    }
    
    // Good - at minimum, log it
    try {
        doSomething();
    } catch (Exception e) {
        logger.error("Failed to do something", e);
        throw e; // or handle appropriately
    }
    
  5. Don't Use Exceptions for Control Flow

    // Bad - using exceptions for control flow
    try {
        String value = map.get(key);
        if (value == null) throw new KeyNotFoundException();
        return value;
    } catch (KeyNotFoundException e) {
        return defaultValue;
    }
    
    // Good - use normal conditional logic
    return map.getOrDefault(key, defaultValue);
    

Common Anti-Patterns

Anti-Pattern 1: Catching Throwable/Error

// Don't do this - catches errors that shouldn't be caught
try {
    doSomething();
} catch (Throwable t) {
    // Catches OutOfMemoryError, StackOverflowError, etc.
}

Anti-Pattern 2: Multi-Level Catch

// Avoid catching exception hierarchy levels separately
try {
    doSomething();
} catch (RuntimeException e) {
    handle(e);
} catch (Exception e) {
    // RuntimeException is subclass of Exception
    // This block is redundant
    handle(e);
}

Anti-Pattern 3: Returning Null on Error

// Bad - hides errors
public User findUser(String id) {
    try {
        return database.query(id);
    } catch (Exception e) {
        return null; // Caller doesn't know if user doesn't exist or error occurred
    }
}

// Good - let exception propagate or return Optional
public Optional<User> findUser(String id) {
    return database.findById(id);
}