⚠️ Java Exceptions: Complete Guide to Error Handling

🌟 Introduction to Java Exceptions

In the world of programming, things don't always go as planned. Files may be missing, network connections might drop, or users could enter invalid data. Java's exception system provides a structured way to identify, categorize, and respond to these unexpected situations.

Exceptions in Java are events that disrupt the normal flow of program execution. They represent problems that occur during a program's execution and provide a mechanism to:

  • Detect problems at runtime
  • Separate error-handling code from regular code
  • Create more robust applications
  • Provide meaningful information about errors

Think of exceptions as Java's way of saying, "Houston, we have a problem!" When something goes wrong, Java creates an exception object that contains information about what happened, where it happened, and potentially why it happened.

Before we dive into handling exceptions (which will be covered in the next chapter), it's essential to understand what exceptions are, how they're organized, and why they're a fundamental part of Java programming.

🧩 What Are Java Exceptions?

Definition and Purpose Exceptions in Java

An exception is an object that represents an abnormal condition that occurred during program execution. When an exceptional situation arises, Java creates an exception object.

Exceptions serve several important purposes:

  1. Separating Error Detection from Error Handling: The code that detects an error doesn't need to know how to handle it.

  2. Propagating Error Information: Exceptions carry information about what went wrong and where.

  3. Grouping and Differentiating Error Types: The exception hierarchy helps categorize different types of errors.

  4. Ensuring Resource Cleanup: The exception mechanism helps ensure resources are properly released even when errors occur.

The Exception Object

When an exception occurs, Java creates an exception object that contains:

  • The exception type (class name)
  • A message describing what went wrong
  • The stack trace (sequence of method calls that led to the exception)
  • Potentially, a reference to another exception that caused this one (the "cause")

Here's what an exception might look like when printed:

java.io.FileNotFoundException: config.txt (No such file or directory)
    at java.base/Java.io.FileInputStream.open0(Native Method)
    at java.base/Java.io.FileInputStream.open(FileInputStream.java:219)
    at java.base/Java.io.FileInputStream.<init>(FileInputStream.java:157)
    at ExampleProgram.readConfig(ExampleProgram.java:42)
    at ExampleProgram.main(ExampleProgram.java:12)

This output tells us:

  • The exception type: FileNotFoundException
  • The message: config.txt (No such file or directory)
  • The stack trace: showing where the exception occurred and the sequence of method calls

🌳 The Exception Hierarchy in Java

Java exceptions are organized in a class hierarchy, with all exceptions extending the Throwable class:

Throwable
├── Error (serious problems, not typically handled by applications)
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── ...
└── Exception (conditions that applications might want to handle)
    ├── IOException
    │   ├── FileNotFoundException
    │   └── ...
    ├── SQLException
    ├── RuntimeException (unchecked exceptions)
    │   ├── NullPointerException
    │   ├── ArrayIndexOutOfBoundsException
    │   ├── ArithmeticException
    │   └── ...
    └── ...

Checked vs. Unchecked Exceptions in Java

Java exceptions fall into two categories:

  1. Checked Exceptions:

    • Subclasses of Exception (excluding RuntimeException and its subclasses)
    • Represent conditions that a reasonable application might want to catch
    • Must be either caught or declared in the method signature
    • Examples: IOException, SQLException, ClassNotFoundException
  2. Unchecked Exceptions:

    • Subclasses of RuntimeException
    • Typically represent programming errors
    • Don't need to be explicitly caught or declared
    • Examples: NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException
  3. Errors:

    • Subclasses of Error
    • Represent serious problems that applications shouldn't try to handle
    • Examples: OutOfMemoryError, StackOverflowError

Key Differences Between Java Checked and Unchecked Exceptions

Aspect Checked Exceptions Unchecked Exceptions
Compile-time checking Yes No
Need to be declared or caught Yes No
Typical causes External factors (I/O, network, etc.) Programming errors
Recovery Often possible Often not possible
Examples IOException, SQLException NullPointerException, ArithmeticException

🔍 Common Java Exception Types

Let's explore some of the most common exception types you'll encounter in Java:

Unchecked Exceptions (Java RuntimeExceptions)

  1. NullPointerException

    • Occurs when you try to use a reference that points to null
    • One of the most common exceptions in Java
    String str = null;
    int length = str.length(); // Throws NullPointerException
  2. ArrayIndexOutOfBoundsException

    • Occurs when you try to access an array element with an invalid index
    int[] numbers = {1, 2, 3};
    int value = numbers[5]; // Throws ArrayIndexOutOfBoundsException
  3. ArithmeticException

    • Occurs when an arithmetic operation fails
    int result = 10 / 0; // Throws ArithmeticException: / by zero
  4. NumberFormatException

    • Occurs when you try to convert a string to a numeric type, but the string doesn't have the appropriate format
    int number = Integer.parseInt("abc"); // Throws NumberFormatException
  5. ClassCastException

    • Occurs when you try to cast an object to a subclass of which it is not an instance
    Object obj = "Hello";
    Integer num = (Integer) obj; // Throws ClassCastException
  6. IllegalArgumentException

    • Thrown when a method receives an argument that's inappropriate
    public void setAge(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
        this.age = age;
    }
  7. IllegalStateException

    • Thrown when a method is invoked at an inappropriate time or when an object is in an inappropriate state
    Iterator<String> iterator = list.iterator();
    iterator.remove(); // Throws IllegalStateException if next() hasn't been called

Checked Exceptions in Java

  1. IOException

    • Base class for exceptions related to input and output operations
    FileReader file = new FileReader("nonexistent.txt"); // Throws FileNotFoundException
  2. SQLException

    • Thrown when there's a problem with database access
    Connection conn = DriverManager.getConnection("invalid_url"); // Throws SQLException
  3. ClassNotFoundException

    • Thrown when an application tries to load a class through its string name but the class cannot be found
    Class.forName("com.example.NonExistentClass"); // Throws ClassNotFoundException
  4. InterruptedException

    • Thrown when a thread is interrupted while it's waiting, sleeping, or otherwise occupied
    Thread.sleep(1000); // Throws InterruptedException if thread is interrupted

Java Errors

  1. OutOfMemoryError

    • Thrown when the JVM runs out of memory and cannot allocate more objects
    // Creating a very large array might cause:
    // java.lang.OutOfMemoryError: Java heap space
    byte[] hugeArray = new byte[Integer.MAX_VALUE];
  2. StackOverflowError

    • Thrown when a stack overflow occurs, typically due to infinite recursion
    // Infinite recursion
    public void infiniteRecursion() {
        infiniteRecursion(); // Eventually throws StackOverflowError
    }

📊 Understanding Java Exception Scenarios

Let's look at some common scenarios that cause exceptions and understand why they occur:

1. Null References in Java

public class NullReferenceExample {
    public static void main(String[] args) {
        // Scenario 1: Direct null reference
        String str = null;
        int length = str.length(); // NullPointerException
        
        // Scenario 2: Null in a chain of method calls
        Person person = getPerson(); // Might return null
        String city = person.getAddress().getCity(); // Potential NullPointerException
        
        // Scenario 3: Null in collections
        List<String> names = getNames(); // Might return null
        for (String name : names) { // NullPointerException if names is null
            System.out.println(name);
        }
    }
    
    private static Person getPerson() {
        // Might return null in some cases
        return null;
    }
    
    private static List<String> getNames() {
        // Might return null in some cases
        return null;
    }
}

class Person {
    private Address address;
    
    public Address getAddress() {
        return address;
    }
}

class Address {
    private String city;
    
    public String getCity() {
        return city;
    }
}

2. Java Array and Collection Access Exceptions

public class ArrayAccessExample {
    public static void main(String[] args) {
        // Scenario 1: Negative index
        int[] numbers = {1, 2, 3};
        int value = numbers[-1]; // ArrayIndexOutOfBoundsException
        
        // Scenario 2: Index too large
        value = numbers[3]; // ArrayIndexOutOfBoundsException
        
        // Scenario 3: List access
        List<String> names = new ArrayList<>();
        names.add("Alice");
        String name = names.get(1); // IndexOutOfBoundsException
        
        // Scenario 4: Map access
        Map<String, Integer> ages = new HashMap<>();
        int age = ages.get("Bob"); // NullPointerException when unboxing null to int
    }
}

3. Type Conversion Issues in Java

public class TypeConversionExample {
    public static void main(String[] args) {
        // Scenario 1: String to number conversion
        String notANumber = "abc";
        int number = Integer.parseInt(notANumber); // NumberFormatException
        
        // Scenario 2: Incorrect casting
        Object obj = "Hello";
        Integer num = (Integer) obj; // ClassCastException
        
        // Scenario 3: Lossy conversion
        long bigNumber = Long.MAX_VALUE;
        int smallerNumber = (int) bigNumber; // No exception, but data loss
    }
}

4. Java Resource Access Exceptions

public class ResourceAccessExample {
    public static void main(String[] args) throws IOException {
        // Scenario 1: File not found
        FileReader reader = new FileReader("nonexistent.txt"); // FileNotFoundException
        
        // Scenario 2: Network connection issues
        URL url = new URL("http://nonexistent.example.com");
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.connect(); // IOException
        
        // Scenario 3: Database connection issues
        Connection dbConnection = DriverManager.getConnection("jdbc:mysql://localhost/nonexistent");
        // SQLException
    }
}

🔬 Anatomy of an Exception

To better understand exceptions, let's examine their structure and the information they provide:

Java Exception Components

  1. Type: The class of the exception (e.g., NullPointerException, IOException)
  2. Message: A human-readable description of what went wrong
  3. Stack Trace: The sequence of method calls that led to the exception
  4. Cause: Another exception that caused this one (for chained exceptions)

Examining an Exception

public class ExceptionAnatomy {
    public static void main(String[] args) {
        try {
            // Cause an exception
            String str = null;
            str.length();
        } catch (NullPointerException e) {
            // 1. Get the exception type
            String exceptionType = e.getClass().getName();
            System.out.println("Exception Type: " + exceptionType);
            
            // 2. Get the message
            String message = e.getMessage();
            System.out.println("Message: " + message);
            
            // 3. Get the stack trace as a string
            StringWriter sw = new StringWriter();
            PrintWriter pw = new PrintWriter(sw);
            e.printStackTrace(pw);
            String stackTrace = sw.toString();
            System.out.println("Stack Trace: \n" + stackTrace);
            
            // 4. Get the cause (null in this case)
            Throwable cause = e.getCause();
            System.out.println("Cause: " + cause);
            
            // 5. Get individual stack trace elements
            StackTraceElement[] elements = e.getStackTrace();
            System.out.println("\nStack Trace Elements:");
            for (StackTraceElement element : elements) {
                System.out.println("  File: " + element.getFileName());
                System.out.println("  Class: " + element.getClassName());
                System.out.println("  Method: " + element.getMethodName());
                System.out.println("  Line: " + element.getLineNumber());
                System.out.println();
            }
        }
    }
}

Output might look like:

Exception Type: java.lang.NullPointerException
Message: Cannot invoke "String.length()" because "str" is null
Stack Trace: 
java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null
    at ExceptionAnatomy.main(ExceptionAnatomy.java:7)
Cause: null

Stack Trace Elements:
  File: ExceptionAnatomy.java
  Class: ExceptionAnatomy
  Method: main
  Line: 7

Chained Exceptions in Java

Exceptions can be chained to indicate that one exception caused another:

public class ChainedExceptionExample {
    public static void main(String[] args) {
        try {
            processFile("config.txt");
        } catch (Exception e) {
            System.out.println("Exception Type: " + e.getClass().getName());
            System.out.println("Message: " + e.getMessage());
            System.out.println("Cause Type: " + e.getCause().getClass().getName());
            System.out.println("Cause Message: " + e.getCause().getMessage());
        }
    }
    
    public static void processFile(String filename) throws Exception {
        try {
            readFile(filename);
        } catch (IOException e) {
            // Wrap the IOException in a more general exception
            throw new Exception("Could not process file: " + filename, e);
        }
    }
    
    public static void readFile(String filename) throws IOException {
        throw new IOException("File not found: " + filename);
    }
}

Output:

Exception Type: java.lang.Exception
Message: Could not process file: config.txt
Cause Type: java.io.IOException
Cause Message: File not found: config.txt

🛠️ Creating Custom Exceptions in Java

While Java provides many built-in exceptions, sometimes you need to create your own to represent application-specific error conditions.

Why Create Custom Exceptions?

  1. Domain-Specific Error Information: Include fields relevant to your application
  2. Meaningful Exception Hierarchy: Group related exceptions
  3. Clear Error Communication: Make error messages more understandable
  4. Consistent Error Handling: Standardize how errors are represented

Java Custom Exception Examples

Basic Custom Exception

// Custom checked exception
public class InsufficientFundsException extends Exception {
    public InsufficientFundsException(String message) {
        super(message);
    }
}

// Usage
public class BankAccount {
    private double balance;
    
    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException(
                "Cannot withdraw $" + amount + ". Current balance: $" + balance);
        }
        balance -= amount;
    }
}

Custom Exception with Additional Information

public class InsufficientFundsException extends Exception {
    private final String accountId;
    private final double requestedAmount;
    private final double availableBalance;
    
    public InsufficientFundsException(String accountId, double requestedAmount, double availableBalance) {
        super(String.format("Insufficient funds in account %s: requested $%.2f but available balance is $%.2f", 
                           accountId, requestedAmount, availableBalance));
        this.accountId = accountId;
        this.requestedAmount = requestedAmount;
        this.availableBalance = availableBalance;
    }
    
    public String getAccountId() {
        return accountId;
    }
    
    public double getRequestedAmount() {
        return requestedAmount;
    }
    
    public double getAvailableBalance() {
        return availableBalance;
    }
}

Custom Exception Hierarchy

// Base exception for all banking operations
public class BankingException extends Exception {
    public BankingException(String message) {
        super(message);
    }
    
    public BankingException(String message, Throwable cause) {
        super(message, cause);
    }
}

// Account-related exceptions
public class AccountException extends BankingException {
    private final String accountId;
    
    public AccountException(String message, String accountId) {
        super(message);
        this.accountId = accountId;
    }
    
    public String getAccountId() {
        return accountId;
    }
}

// Specific account exceptions
public class AccountNotFoundException extends AccountException {
    public AccountNotFoundException(String accountId) {
        super("Account not found: " + accountId, accountId);
    }
}

public class InsufficientFundsException extends AccountException {
    private final double requestedAmount;
    private final double availableBalance;
    
    public InsufficientFundsException(String accountId, double requestedAmount, double availableBalance) {
        super(String.format("Insufficient funds in account %s: requested $%.2f but available balance is $%.2f", 
                           accountId, requestedAmount, availableBalance), accountId);
        this.requestedAmount = requestedAmount;
        this.availableBalance = availableBalance;
    }
    
    public double getRequestedAmount() {
        return requestedAmount;
    }
    
    public double getAvailableBalance() {
        return availableBalance;
    }
}

// Transaction-related exceptions
public class TransactionException extends BankingException {
    private final String transactionId;
    
    public TransactionException(String message, String transactionId) {
        super(message);
        this.transactionId = transactionId;
    }
    
    public String getTransactionId() {
        return transactionId;
    }
}

Best Practices for Custom Exceptions

  1. Naming Convention: End the class name with "Exception"
  2. Choose the Right Superclass: Extend Exception for checked exceptions or RuntimeException for unchecked exceptions
  3. Include Constructors: At minimum, include constructors that accept a message and a cause
  4. Add Domain-Specific Information: Include fields and methods that provide additional context
  5. Make Exceptions Serializable: They are by default if they extend Exception or RuntimeException
  6. Document Exceptions: Use Javadoc to document when and why exceptions are thrown

💥 Common Java Exception Pitfalls

Even before we discuss how to handle exceptions (which will be covered in the next chapter), it's important to understand common pitfalls related to exceptions:

1. Overusing Exceptions

Exceptions should represent exceptional conditions, not normal program flow:

// BAD: Using exceptions for normal flow control
public boolean containsKey(String key) {
    try {
        getValue(key);
        return true;
    } catch (KeyNotFoundException e) {
        return false;
    }
}

// GOOD: Using conditional logic for normal flow control
public boolean containsKey(String key) {
    return map.containsKey(key);
}

2. Creating Too Many Custom Exceptions

Don't create a new exception class for every possible error:

// BAD: Too granular
public class UsernameTooShortException extends Exception { /*...*/ }
public class UsernameTooLongException extends Exception { /*...*/ }
public class UsernameContainsInvalidCharsException extends Exception { /*...*/ }

// GOOD: More general with specific information
public class InvalidUsernameException extends Exception {
    private final String reason;
    
    public InvalidUsernameException(String username, String reason) {
        super("Invalid username: " + username + " - " + reason);
        this.reason = reason;
    }
    
    public String getReason() {
        return reason;
    }
}

3. Using Exceptions for Control Flow

Exceptions are for exceptional conditions, not normal program flow:

// BAD: Using exceptions for control flow
public int findIndex(String[] array, String target) {
    try {
        for (int i = 0; i < array.length; i++) {
            if (array[i].equals(target)) {
                return i;
            }
        }
        throw new NotFoundException();
    } catch (NotFoundException e) {
        return -1;
    }
}

// GOOD: Using normal control flow
public int findIndex(String[] array, String target) {
    for (int i = 0; i < array.length; i++) {
        if (array[i].equals(target)) {
            return i;
        }
    }
    return -1;
}

4. Ignoring Exception Information

Exceptions provide valuable information that should be used:

// BAD: Ignoring exception details
try {
    // Code that might throw different exceptions
} catch (Exception e) {
    System.out.println("An error occurred");
}

// GOOD: Using exception information
try {
    // Code that might throw different exceptions
} catch (FileNotFoundException e) {
    System.out.println("Could not find file: " + e.getMessage());
} catch (IOException e) {
    System.out.println("I/O error: " + e.getMessage());
} catch (Exception e) {
    System.out.println("Unexpected error: " + e.getMessage());
    e.printStackTrace();
}

5. Throwing Generic Exceptions

Throw specific exceptions that provide meaningful information:

// BAD: Throwing generic exceptions
if (username == null) {
    throw new Exception("Invalid username");
}

// GOOD: Throwing specific exceptions
if (username == null) {
    throw new IllegalArgumentException("Username cannot be null");
}

🏆 Java Exception Best Practices and Rules

Even before we discuss exception handling mechanisms, there are several best practices to follow regarding exceptions:

1. Use Exceptions for Exceptional Conditions

Exceptions should represent exceptional conditions, not normal program flow:

// GOOD: Using exceptions for exceptional conditions
public void withdraw(double amount) throws InsufficientFundsException {
    if (amount > balance) {
        throw new InsufficientFundsException(accountId, amount, balance);
    }
    balance -= amount;
}

2. Choose the Right Exception Type

Use the most specific exception type that accurately represents the error:

// BAD: Using a generic exception
if (amount < 0) {
    throw new Exception("Amount cannot be negative");
}

// GOOD: Using a specific exception
if (amount < 0) {
    throw new IllegalArgumentException("Amount cannot be negative: " + amount);
}

3. Include Meaningful Information

Provide detailed information in exception messages:

// BAD: Vague message
throw new FileNotFoundException("File error");

// GOOD: Detailed message
throw new FileNotFoundException("Could not find configuration file: " + configPath);

4. Create Custom Exceptions When Appropriate

Create custom exceptions for domain-specific error conditions:

// GOOD: Custom exception for domain-specific error
public class InsufficientFundsException extends Exception {
    // With additional context information
}

5. Document Exceptions

Use Javadoc to document when and why exceptions are thrown:

/**
 * Withdraws the specified amount from this account.
 *
 * @param amount the amount to withdraw
 * @throws InsufficientFundsException if the account has insufficient funds
 * @throws IllegalArgumentException if the amount is negative
 */
public void withdraw(double amount) throws InsufficientFundsException {
    // Implementation
}

6. Follow the Exception Hierarchy

  • Extend Exception for checked exceptions
  • Extend RuntimeException for unchecked exceptions
  • Don't extend Error or Throwable directly
// GOOD: Proper hierarchy
public class DatabaseException extends Exception { /*...*/ }
public class InvalidInputException extends RuntimeException { /*...*/ }

7. Keep Exceptions Focused

Each exception should represent a specific type of error:

// BAD: Too broad
public class DatabaseException extends Exception { /*...*/ }

// GOOD: More focused
public class DatabaseConnectionException extends Exception { /*...*/ }
public class DatabaseQueryException extends Exception { /*...*/ }

🌐 Why Exceptions Matter

Understanding exceptions is crucial for several reasons:

1. Robustness

Exceptions help create robust applications that can detect and respond to errors:

  • Prevent application crashes
  • Provide graceful degradation
  • Enable recovery strategies

2. Separation of Concerns

Exceptions separate error detection from error handling:

  • The code that detects an error doesn't need to know how to handle it
  • Error handling code can be centralized
  • Business logic remains cleaner

3. Debugging and Troubleshooting

Exceptions provide valuable information for debugging:

  • Stack traces show where the error occurred
  • Exception messages describe what went wrong
  • Exception types categorize different kinds of errors

4. API Design

Exceptions are a key part of API design:

  • They define the contract between components
  • They communicate what can go wrong
  • They provide a standard way to report errors

5. Resource Management

Exceptions help ensure resources are properly managed:

  • They signal when resource acquisition fails
  • They enable cleanup code to run even when errors occur

📊 Real-World Example: Banking System

Let's examine a more complex real-world example of exceptions in a banking application:

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;

// Custom exceptions
class InsufficientFundsException extends Exception {
    private final String accountId;
    private final BigDecimal requestedAmount;
    private final BigDecimal availableBalance;
    
    public InsufficientFundsException(String accountId, BigDecimal requestedAmount, BigDecimal availableBalance) {
        super(String.format("Insufficient funds in account %s: requested %s but available balance is %s", 
                           accountId, requestedAmount, availableBalance));
        this.accountId = accountId;
        this.requestedAmount = requestedAmount;
        this.availableBalance = availableBalance;
    }
    
    public String getAccountId() {
        return accountId;
    }
    
    public BigDecimal getRequestedAmount() {
        return requestedAmount;
    }
    
    public BigDecimal getAvailableBalance() {
        return availableBalance;
    }
}

class AccountNotFoundException extends Exception {
    private final String accountId;
    
    public AccountNotFoundException(String accountId) {
        super("Account not found: " + accountId);
        this.accountId = accountId;
    }
    
    public String getAccountId() {
        return accountId;
    }
}

class NegativeAmountException extends IllegalArgumentException {
    private final BigDecimal amount;
    
    public NegativeAmountException(BigDecimal amount) {
        super("Amount cannot be negative: " + amount);
        this.amount = amount;
    }
    
    public BigDecimal getAmount() {
        return amount;
    }
}

// Account class
class BankAccount {
    private final String accountId;
    private final String ownerName;
    private BigDecimal balance;
    private boolean active;
    
    public BankAccount(String ownerName, BigDecimal initialBalance) {
        this.accountId = UUID.randomUUID().toString();
        this.ownerName = ownerName;
        
        // Validate initial balance
        if (initialBalance.compareTo(BigDecimal.ZERO) < 0) {
            throw new NegativeAmountException(initialBalance);
        }
        
        this.balance = initialBalance;
        this.active = true;
    }
    
    public String getAccountId() {
        return accountId;
    }
    
    public String getOwnerName() {
        return ownerName;
    }
    
    public BigDecimal getBalance() {
        return balance;
    }
    
    public boolean isActive() {
        return active;
    }
    
    public void deposit(BigDecimal amount) throws NegativeAmountException {
        // Validate amount
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new NegativeAmountException(amount);
        }
        
        // Check if account is active
        if (!active) {
            throw new IllegalStateException("Cannot deposit to inactive account: " + accountId);
        }
        
        balance = balance.add(amount);
    }
    
    public void withdraw(BigDecimal amount) 
            throws NegativeAmountException, InsufficientFundsException {
        // Validate amount
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new NegativeAmountException(amount);
        }
        
        // Check if account is active
        if (!active) {
            throw new IllegalStateException("Cannot withdraw from inactive account: " + accountId);
        }
        
        // Check if sufficient funds
        if (balance.compareTo(amount) < 0) {
            throw new InsufficientFundsException(accountId, amount, balance);
        }
        
        balance = balance.subtract(amount);
    }
    
    public void deactivate() {
        active = false;
    }
    
    public void activate() {
        active = true;
    }
    
    @Override
    public String toString() {
        return String.format("Account[id=%s, owner=%s, balance=%s, active=%s]", 
                            accountId, ownerName, balance, active);
    }
}

// Bank service
class BankService {
    private static final Logger logger = Logger.getLogger(BankService.class.getName());
    private final Map<String, BankAccount> accounts = new HashMap<>();
    
    public String createAccount(String ownerName, BigDecimal initialBalance) {
        try {
            BankAccount account = new BankAccount(ownerName, initialBalance);
            accounts.put(account.getAccountId(), account);
            logger.info("Created account: " + account);
            return account.getAccountId();
        } catch (NegativeAmountException e) {
            logger.log(Level.WARNING, "Failed to create account", e);
            throw e;
        }
    }
    
    public void deposit(String accountId, BigDecimal amount) 
            throws AccountNotFoundException, NegativeAmountException {
        BankAccount account = findAccount(accountId);
        
        try {
            account.deposit(amount);
            logger.info(String.format("Deposited %s to account %s, new balance: %s", 
                                     amount, accountId, account.getBalance()));
        } catch (IllegalStateException e) {
            logger.warning(String.format("Cannot deposit to inactive account %s", accountId));
            throw e;
        }
    }
    
    public void withdraw(String accountId, BigDecimal amount) 
            throws AccountNotFoundException, NegativeAmountException, InsufficientFundsException {
        BankAccount account = findAccount(accountId);
        
        try {
            account.withdraw(amount);
            logger.info(String.format("Withdrew %s from account %s, new balance: %s", 
                                     amount, accountId, account.getBalance()));
        } catch (IllegalStateException e) {
            logger.warning(String.format("Cannot withdraw from inactive account %s", accountId));
            throw e;
        }
    }
    
    public BigDecimal getBalance(String accountId) throws AccountNotFoundException {
        BankAccount account = findAccount(accountId);
        return account.getBalance();
    }
    
    public void transferFunds(String fromAccountId, String toAccountId, BigDecimal amount) 
            throws AccountNotFoundException, NegativeAmountException, InsufficientFundsException {
        // Find both accounts first to avoid partial operations
        BankAccount fromAccount = findAccount(fromAccountId);
        BankAccount toAccount = findAccount(toAccountId);
        
        // Withdraw from source account
        fromAccount.withdraw(amount);
        
        try {
            // Deposit to destination account
            toAccount.deposit(amount);
        } catch (Exception e) {
            // If deposit fails, refund the withdrawal
            try {
                fromAccount.deposit(amount);
                logger.warning("Transfer failed, funds returned to source account");
            } catch (Exception refundException) {
                // Critical situation: withdrawal succeeded but deposit and refund failed
                logger.severe("CRITICAL: Transfer failed and refund failed. Account " + 
                             fromAccountId + " was debited but " + toAccountId + 
                             " was not credited. Manual intervention required.");
            }
            
            // Re-throw the original exception
            if (e instanceof NegativeAmountException) {
                throw (NegativeAmountException) e;
            } else if (e instanceof IllegalStateException) {
                throw e;
            } else {
                throw new RuntimeException("Unexpected error during transfer", e);
            }
        }
        
        logger.info(String.format("Transferred %s from account %s to account %s", 
                                 amount, fromAccountId, toAccountId));
    }
    
    public void closeAccount(String accountId) throws AccountNotFoundException {
        BankAccount account = findAccount(accountId);
        
        if (account.getBalance().compareTo(BigDecimal.ZERO) > 0) {
            throw new IllegalStateException(
                "Cannot close account with positive balance: " + account.getBalance());
        }
        
        account.deactivate();
        logger.info("Closed account: " + accountId);
    }
    
    private BankAccount findAccount(String accountId) throws AccountNotFoundException {
        BankAccount account = accounts.get(accountId);
        
        if (account == null) {
            logger.warning("Account not found: " + accountId);
            throw new AccountNotFoundException(accountId);
        }
        
        return account;
    }
}

// Example usage
public class BankingExample {
    public static void main(String[] args) {
        BankService bank = new BankService();
        
        try {
            // Create accounts
            String aliceAccountId = bank.createAccount("Alice", new BigDecimal("1000.00"));
            String bobAccountId = bank.createAccount("Bob", new BigDecimal("500.00"));
            
            // Perform operations
            bank.deposit(aliceAccountId, new BigDecimal("250.00"));
            bank.withdraw(bobAccountId, new BigDecimal("100.00"));
            bank.transferFunds(aliceAccountId, bobAccountId, new BigDecimal("300.00"));
            
            // Check balances
            System.out.println("Alice's balance: $" + bank.getBalance(aliceAccountId));
            System.out.println("Bob's balance: $" + bank.getBalance(bobAccountId));
            
            // Try some operations that will cause exceptions
            try {
                bank.withdraw(aliceAccountId, new BigDecimal("2000.00"));
            } catch (InsufficientFundsException e) {
                System.out.println("Expected error: " + e.getMessage());
                System.out.println("Requested: $" + e.getRequestedAmount());
                System.out.println("Available: $" + e.getAvailableBalance());
            }
            
            try {
                bank.deposit(aliceAccountId, new BigDecimal("-50.00"));
            } catch (NegativeAmountException e) {
                System.out.println("Expected error: " + e.getMessage());
            }
            
            try {
                bank.getBalance("nonexistent-account");
            } catch (AccountNotFoundException e) {
                System.out.println("Expected error: " + e.getMessage());
            }
            
        } catch (Exception e) {
            System.out.println("Unexpected error: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

This banking example demonstrates:

  1. Custom Exception Hierarchy: Different types of exceptions for different error conditions
  2. Domain-Specific Information: Exceptions include relevant context (account IDs, amounts, etc.)
  3. Proper Exception Handling: Each method declares the exceptions it might throw
  4. Logging: Exceptions are logged for troubleshooting
  5. Recovery Strategies: The transfer method attempts to recover from errors
  6. Validation: Input validation to prevent invalid states

📝 Why Use Exceptions in Real Applications?

Let's explore some concrete benefits of using exceptions in real-world applications:

1. User Experience

Good exception handling translates technical errors into user-friendly messages:

try {
    // Database operation
} catch (SQLException e) {
    logger.error("Database error", e);
    showUserMessage("We're having trouble accessing your data. Please try again later.");
}

2. Debugging and Troubleshooting

Well-structured exception handling provides valuable information for debugging:

  • Stack traces show where the error occurred
  • Exception messages describe what went wrong
  • Custom exceptions add domain context
  • Logging captures the error state

3. Security

Exception handling helps prevent information leakage:

try {
    // Authentication logic
} catch (AuthenticationException e) {
    logger.error("Authentication failed for user " + username, e);
    // Don't tell the user exactly what went wrong
    return "Invalid username or password";
}

4. Resource Management

Exception handling ensures resources are properly released, even when errors occur:

try (Connection conn = dataSource.getConnection()) {
    // Database operations
} catch (SQLException e) {
    // Handle exception
}
// Connection is automatically closed

🧪 Exception Handling in Testing

Exception handling is also important in testing. JUnit provides several ways to test exceptions:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class BankAccountTest {
    
    @Test
    public void testWithdrawInsufficientFunds() {
        BankAccount account = new BankAccount("Test", new BigDecimal("100.00"));
        
        // Using assertThrows
        InsufficientFundsException exception = assertThrows(
            InsufficientFundsException.class,
            () -> account.withdraw(new BigDecimal("200.00"))
        );
        
        // Verify exception properties
        assertEquals("100.00", exception.getAvailableBalance().toString());
        assertEquals("200.00", exception.getRequestedAmount().toString());
    }
    
    @Test
    public void testDepositNegativeAmount() {
        BankAccount account = new BankAccount("Test", new BigDecimal("100.00"));
        
        Exception exception = assertThrows(
            NegativeAmountException.class,
            () -> account.deposit(new BigDecimal("-50.00"))
        );
        
        assertTrue(exception.getMessage().contains("-50.00"));
    }
}

📝 Exercises and Practice

Let's put your exception knowledge to work with some exercises:

Exercise 1: File Reader with Exception Handling

Task: Create a program that reads a configuration file and handles potential exceptions.

Requirements:

  • Read a file named "config.properties"
  • Handle file not found scenario
  • Handle permission issues
  • Parse integer values with proper exception handling

Solution:

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Properties;

public class ConfigReader {
    public static void main(String[] args) {
        Properties config = new Properties();
        File configFile = new File("config.properties");
        
        try {
            // Check if file exists
            if (!configFile.exists()) {
                throw new IOException("Configuration file not found");
            }
            
            // Check if file is readable
            if (!configFile.canRead()) {
                throw new IOException("Cannot read configuration file (permission denied)");
            }
            
            // Load properties
            try (FileInputStream fis = new FileInputStream(configFile)) {
                config.load(fis);
                System.out.println("Configuration loaded successfully");
                
                // Parse integer property with exception handling
                try {
                    String portStr = config.getProperty("server.port");
                    if (portStr == null) {
                        System.out.println("Warning: server.port not defined, using default 8080");
                    } else {
                        int port = Integer.parseInt(portStr);
                        System.out.println("Server will run on port: " + port);
                    }
                } catch (NumberFormatException e) {
                    System.out.println("Error: server.port is not a valid number");
                    System.out.println("Using default port 8080");
                }
            }
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
            System.out.println("Creating default configuration...");
            createDefaultConfig();
        } catch (Exception e) {
            System.out.println("Unexpected error: " + e.getMessage());
            e.printStackTrace();
        }
    }
    
    private static void createDefaultConfig() {
        // Default configuration logic
        System.out.println("Default configuration created");
    }
}

Exercise 2: Custom Exception Hierarchy

Task: Create a custom exception hierarchy for a library management system.

Your turn: Try implementing these custom exceptions:

  • BookNotFoundException (checked)
  • BookAlreadyBorrowedException (checked)
  • InvalidMembershipException (unchecked)

Include appropriate constructors and additional fields that provide context.

Exercise 3: Exception Handling in a Web Application

Task: Implement exception handling for a REST API endpoint.

Your turn: Create a method that processes user registration with proper exception handling:

  • Handle validation errors
  • Handle duplicate username errors
  • Log exceptions appropriately
  • Return user-friendly error messages

Mini-Project: Calculator with Exception Handling

Let's create a simple calculator application that demonstrates robust exception handling:

import java.util.Scanner;

// Custom exceptions
class DivideByZeroException extends ArithmeticException {
    public DivideByZeroException() {
        super("Cannot divide by zero");
    }
}

class InvalidOperatorException extends IllegalArgumentException {
    private final String operator;
    
    public InvalidOperatorException(String operator) {
        super("Invalid operator: " + operator);
        this.operator = operator;
    }
    
    public String getOperator() {
        return operator;
    }
}

public class Calculator {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        boolean running = true;
        
        System.out.println("Simple Calculator");
        System.out.println("Enter 'exit' to quit");
        
        while (running) {
            try {
                // Get first number
                System.out.print("Enter first number: ");
                String input = scanner.nextLine();
                
                if (input.equalsIgnoreCase("exit")) {
                    running = false;
                    continue;
                }
                
                double num1 = parseNumber(input);
                
                // Get operator
                System.out.print("Enter operator (+, -, *, /): ");
                String operator = scanner.nextLine();
                
                if (operator.equalsIgnoreCase("exit")) {
                    running = false;
                    continue;
                }
                
                // Get second number
                System.out.print("Enter second number: ");
                input = scanner.nextLine();
                
                if (input.equalsIgnoreCase("exit")) {
                    running = false;
                    continue;
                }
                
                double num2 = parseNumber(input);
                
                // Calculate and display result
                double result = calculate(num1, num2, operator);
                System.out.println("Result: " + result);
                System.out.println();
                
            } catch (NumberFormatException e) {
                System.out.println("Error: Please enter valid numbers");
                System.out.println();
            } catch (DivideByZeroException e) {
                System.out.println("Error: " + e.getMessage());
                System.out.println();
            } catch (InvalidOperatorException e) {
                System.out.println("Error: " + e.getMessage());
                System.out.println("Valid operators are: +, -, *, /");
                System.out.println();
            } catch (Exception e) {
                System.out.println("An unexpected error occurred: " + e.getMessage());
                e.printStackTrace();
                System.out.println();
            }
        }
        
        System.out.println("Calculator closed. Goodbye!");
        scanner.close();
    }
    
    private static double parseNumber(String input) throws NumberFormatException {
        try {
            return Double.parseDouble(input);
        } catch (NumberFormatException e) {
            throw new NumberFormatException("'" + input + "' is not a valid number");
        }
    }
    
    private static double calculate(double num1, double num2, String operator) 
            throws InvalidOperatorException, DivideByZeroException {
        switch (operator) {
            case "+":
                return num1 + num2;
            case "-":
                return num1 - num2;
            case "*":
                return num1 * num2;
            case "/":
                if (num2 == 0) {
                    throw new DivideByZeroException();
                }
                return num1 / num2;
            default:
                throw new InvalidOperatorException(operator);
        }
    }
}

This calculator demonstrates:

  • Custom exceptions for specific error conditions
  • Input validation with appropriate exception handling
  • User-friendly error messages
  • Graceful error recovery (continues operation after errors)

🎯 Java Exceptions: Key Takeaways

  1. Exception Hierarchy: Understand the difference between checked and unchecked exceptions.

  2. Exception Types: Familiarize yourself with common exception types and when they occur.

  3. Custom Exceptions: Create domain-specific exceptions to provide context and improve error handling.

  4. Exception Information: Use the information provided by exceptions for debugging and logging.

  5. Best Practices:

    • Use exceptions for exceptional conditions, not normal flow control
    • Choose the right exception type
    • Include meaningful information in exception messages
    • Document exceptions properly
  6. Benefits:

    • Improved robustness
    • Better separation of concerns
    • Enhanced debugging and troubleshooting
    • Cleaner API design
    • Proper resource management
  7. Testing: Write tests that verify your exception behavior works correctly.