Exception Handling




1. What is Exception Handling?

An exception is an unexpected event that occurs during the execution of a program and disrupts the normal flow of instructions. Exception handling is the mechanism Java provides to detect these events and respond to them gracefully, so your program does not crash.

Think of it like a safety net under a tightrope walker. The walker (your program) is crossing a bridge (executing code). If the bridge breaks (an error occurs), instead of falling to the ground (crashing), the walker lands safely in the net (the exception handler) and can decide what to do next – try again, take a different path, or alert someone for help.

Without exception handling, any unexpected situation – a missing file, a network timeout, dividing by zero, accessing a null reference – causes your program to terminate immediately with an ugly stack trace. In a production application serving thousands of users, that is unacceptable.

Here is what happens when you do not handle an exception:

public class NoHandling {
    public static void main(String[] args) {
        int numerator = 10;
        int denominator = 0;

        // This line throws ArithmeticException -- program crashes here
        int result = numerator / denominator;

        // This line NEVER executes
        System.out.println("Result: " + result);
        System.out.println("Program finished.");
    }
}

// Output:
// Exception in thread "main" java.lang.ArithmeticException: / by zero
//     at NoHandling.main(NoHandling.java:7)

Notice two important things: the program crashed immediately at line 7, and the remaining lines never executed. Now compare that with proper exception handling:

public class WithHandling {
    public static void main(String[] args) {
        int numerator = 10;
        int denominator = 0;

        try {
            int result = numerator / denominator;
            System.out.println("Result: " + result);
        } catch (ArithmeticException e) {
            System.out.println("Error: Cannot divide by zero.");
        }

        // This line DOES execute because the exception was handled
        System.out.println("Program finished.");
    }
}

// Output:
// Error: Cannot divide by zero.
// Program finished.

With exception handling, the program catches the error, handles it gracefully, and continues running. This is the fundamental purpose of exception handling: keeping your program alive and responsive, even when things go wrong.

Common situations that cause exceptions:

  • Trying to read a file that does not exist (FileNotFoundException)
  • Accessing an array element beyond its bounds (ArrayIndexOutOfBoundsException)
  • Calling a method on a null reference (NullPointerException)
  • Connecting to a database that is down (SQLException)
  • Parsing a non-numeric string as a number (NumberFormatException)
  • Running out of memory (OutOfMemoryError)



2. Exception Hierarchy

Java organizes all exceptions and errors in a class hierarchy rooted at the Throwable class. Understanding this hierarchy is essential because it determines which exceptions you can catch, which you must declare, and which you should never try to handle.

                        java.lang.Object
                             |
                      java.lang.Throwable
                       /              \
               java.lang.Error    java.lang.Exception
                    |                  /            \
               (Do NOT catch)   Checked         RuntimeException
                    |           Exceptions        (Unchecked)
                    |               |                  |
            OutOfMemoryError   IOException      NullPointerException
            StackOverflowError SQLException     IllegalArgumentException
            VirtualMachineError FileNotFound    ArrayIndexOutOfBounds
                               ClassNotFound    ArithmeticException
                                                NumberFormatException
                                                ClassCastException

Throwable

Throwable is the root class of Java’s exception hierarchy. Only objects that are instances of Throwable (or its subclasses) can be thrown with the throw keyword or caught with a catch block. It has two direct subclasses: Error and Exception.

Error (Do Not Catch)

Error represents serious problems that a reasonable application should not try to catch or recover from. These are typically conditions where the JVM itself is in trouble:

  • OutOfMemoryError – The JVM ran out of heap memory. You cannot meaningfully recover from this.
  • StackOverflowError – A method called itself recursively too many times, exhausting the call stack.
  • VirtualMachineError – The JVM is broken or has run out of resources to operate.

If you see an Error, the correct response is usually to fix the root cause in your code (e.g., fix the infinite recursion) or increase JVM resources (e.g., increase heap size with -Xmx), not to catch it at runtime.

Exception (The Ones You Handle)

Exception represents conditions that a program might reasonably want to catch and recover from. This class branches into two categories based on whether the compiler forces you to handle them:

  1. Checked Exceptions – Direct subclasses of Exception (but not RuntimeException). The compiler requires you to handle or declare them.
  2. Unchecked Exceptions – Subclasses of RuntimeException. The compiler does not force you to handle them.



3. Checked vs Unchecked Exceptions

This is one of the most important distinctions in Java and a common interview question. The difference comes down to one question: does the compiler force you to handle it?

Checked Exceptions

Checked exceptions are exceptions that the compiler checks at compile time. If a method can throw a checked exception, you must either catch it with a try-catch block or declare it in the method signature with the throws keyword. If you do neither, your code will not compile.

Checked exceptions represent recoverable conditions that are outside your program’s control – things like a file not existing, a network connection failing, or a database being unavailable. The compiler forces you to think about these scenarios.

Common checked exceptions:

  • IOException – Input/output operation failed
  • FileNotFoundException – File does not exist at the specified path
  • SQLException – Database access error
  • ClassNotFoundException – A class could not be found at runtime
  • InterruptedException – A thread was interrupted while waiting
  • ParseException – Error while parsing a date or other formatted text
import java.io.FileReader;
import java.io.IOException;

public class CheckedExample {
    public static void main(String[] args) {
        // This will NOT compile without handling IOException
        // FileReader constructor throws FileNotFoundException (a checked exception)

        // Option 1: Handle with try-catch
        try {
            FileReader reader = new FileReader("data.txt");
            int character = reader.read(); // read() throws IOException
            System.out.println((char) character);
            reader.close();
        } catch (IOException e) {
            System.out.println("Could not read file: " + e.getMessage());
        }
    }

    // Option 2: Declare with throws
    public static void readFile() throws IOException {
        FileReader reader = new FileReader("data.txt");
        int character = reader.read();
        System.out.println((char) character);
        reader.close();
    }
}

Unchecked Exceptions (Runtime Exceptions)

Unchecked exceptions are subclasses of RuntimeException. The compiler does not require you to handle or declare them. They typically represent programming bugs – mistakes in your logic that should be fixed in the code itself, not caught at runtime.

Common unchecked exceptions:

  • NullPointerException – Called a method on a null reference
  • ArrayIndexOutOfBoundsException – Accessed an invalid array index
  • IllegalArgumentException – Passed an invalid argument to a method
  • ArithmeticException – Illegal arithmetic operation (e.g., divide by zero)
  • NumberFormatException – Tried to parse a non-numeric string
  • ClassCastException – Invalid type cast
  • IllegalStateException – Method called at an inappropriate time
  • UnsupportedOperationException – Requested operation is not supported
public class UncheckedExample {
    public static void main(String[] args) {
        // NullPointerException -- calling method on null
        String name = null;
        // name.length(); // Throws NullPointerException

        // ArrayIndexOutOfBoundsException -- invalid index
        int[] numbers = {1, 2, 3};
        // int value = numbers[5]; // Throws ArrayIndexOutOfBoundsException

        // NumberFormatException -- invalid number format
        // int num = Integer.parseInt("abc"); // Throws NumberFormatException

        // ArithmeticException -- divide by zero
        // int result = 10 / 0; // Throws ArithmeticException

        // IllegalArgumentException -- invalid argument
        // Thread.sleep(-1); // Throws IllegalArgumentException

        // You CAN catch unchecked exceptions, but often it is better
        // to prevent them with proper validation
        String input = "abc";
        try {
            int parsed = Integer.parseInt(input);
            System.out.println("Parsed: " + parsed);
        } catch (NumberFormatException e) {
            System.out.println("'" + input + "' is not a valid number");
        }
    }
}

// Output:
// 'abc' is not a valid number

Comparison Table

Feature Checked Exception Unchecked Exception
Superclass Exception (excluding RuntimeException) RuntimeException
Compile-time check Yes – must handle or declare No – compiler does not enforce
Typical cause External conditions (file, network, database) Programming bugs (null, bad index, bad cast)
Recovery Often recoverable (retry, fallback, user prompt) Usually indicates a bug to fix in code
Examples IOException, SQLException, FileNotFoundException NullPointerException, IllegalArgumentException
Best approach Catch and handle, or propagate with throws Prevent with validation and null checks



4. The try-catch Block

The try-catch block is the fundamental mechanism for handling exceptions in Java. You place risky code inside the try block, and the code that handles the error goes in the catch block.

Basic Syntax

try {
    // Code that might throw an exception
    // If an exception occurs, execution jumps to the matching catch block
} catch (ExceptionType variableName) {
    // Code to handle the exception
    // Only runs if the exception type matches
}

Here is how it works step by step:

  1. Java executes the code inside the try block line by line.
  2. If no exception occurs, the entire try block runs, the catch block is skipped, and execution continues after the catch.
  3. If an exception occurs on any line, execution immediately jumps to the first matching catch block. The remaining lines in the try block are skipped.
  4. After the catch block finishes, execution continues normally after the entire try-catch structure.
public class TryCatchFlow {
    public static void main(String[] args) {
        System.out.println("1. Before try block");

        try {
            System.out.println("2. Inside try -- before risky code");
            int result = 10 / 0; // ArithmeticException thrown here
            System.out.println("3. Inside try -- after risky code"); // SKIPPED
        } catch (ArithmeticException e) {
            System.out.println("4. Inside catch -- handling error");
        }

        System.out.println("5. After try-catch -- program continues");
    }
}

// Output:
// 1. Before try block
// 2. Inside try -- before risky code
// 4. Inside catch -- handling error
// 5. After try-catch -- program continues

Multiple catch Blocks

A single try block can have multiple catch blocks to handle different types of exceptions differently. Java checks them from top to bottom and executes the first matching block. Because of this, you must order catch blocks from most specific to most general. If you put a parent exception class before a child, the child catch will be unreachable, and the compiler will report an error.

public class MultipleCatch {
    public static void main(String[] args) {
        String[] data = {"10", "abc", null};

        for (String item : data) {
            try {
                // Could throw NullPointerException if item is null
                int length = item.length();

                // Could throw NumberFormatException if item is not numeric
                int number = Integer.parseInt(item);

                // Could throw ArithmeticException
                int result = 100 / number;

                System.out.println("100 / " + number + " = " + result);

            } catch (NullPointerException e) {
                System.out.println("Error: input is null");
            } catch (NumberFormatException e) {
                System.out.println("Error: '" + item + "' is not a number");
            } catch (ArithmeticException e) {
                System.out.println("Error: division by zero");
            }
        }
    }
}

// Output:
// 100 / 10 = 10
// Error: 'abc' is not a number
// Error: input is null

Exception Object Methods

When you catch an exception, you receive an exception object that contains useful information for debugging. Here are the most commonly used methods:

Method Returns Purpose
getMessage() String A human-readable description of the error
printStackTrace() void Prints the full stack trace to standard error (useful for debugging)
getCause() Throwable Returns the underlying exception that caused this one (may be null)
getClass().getName() String Returns the fully qualified class name of the exception
getStackTrace() StackTraceElement[] Returns the stack trace as an array (for programmatic access)
public class ExceptionMethods {
    public static void main(String[] args) {
        try {
            int[] arr = new int[3];
            arr[10] = 42; // Invalid index
        } catch (ArrayIndexOutOfBoundsException e) {
            // getMessage() -- short description
            System.out.println("Message: " + e.getMessage());

            // getClass().getName() -- exception type
            System.out.println("Type: " + e.getClass().getName());

            // getCause() -- underlying cause (null if none)
            System.out.println("Cause: " + e.getCause());

            // printStackTrace() -- full stack trace (printed to System.err)
            System.out.println("\nFull stack trace:");
            e.printStackTrace();
        }
    }
}

// Output:
// Message: Index 10 out of bounds for length 3
// Type: java.lang.ArrayIndexOutOfBoundsException
// Cause: null
//
// Full stack trace:
// java.lang.ArrayIndexOutOfBoundsException: Index 10 out of bounds for length 3
//     at ExceptionMethods.main(ExceptionMethods.java:5)



5. The finally Block

The finally block is an optional block that comes after try and catch. Its purpose is to execute cleanup code that should always run, regardless of whether an exception occurred or not. This makes it perfect for releasing resources like file handles, database connections, or network sockets.

The finally block runs in all of these scenarios:

  • The try block completes normally (no exception)
  • An exception is thrown and caught by a catch block
  • An exception is thrown but not caught (finally still runs before the exception propagates)
  • The try or catch block contains a return statement (finally runs before the return)

The only situation where finally does not run is if the JVM itself shuts down (via System.exit()) or the JVM crashes.

public class FinallyDemo {
    public static void main(String[] args) {
        // Scenario 1: No exception
        System.out.println("--- Scenario 1: No exception ---");
        try {
            System.out.println("Try: doing work...");
        } catch (Exception e) {
            System.out.println("Catch: handling error");
        } finally {
            System.out.println("Finally: cleanup runs");
        }

        // Scenario 2: Exception caught
        System.out.println("\n--- Scenario 2: Exception caught ---");
        try {
            int result = 10 / 0;
        } catch (ArithmeticException e) {
            System.out.println("Catch: " + e.getMessage());
        } finally {
            System.out.println("Finally: cleanup runs");
        }

        // Scenario 3: finally runs even with return
        System.out.println("\n--- Scenario 3: Return in try ---");
        System.out.println("getValue() returned: " + getValue());
    }

    public static String getValue() {
        try {
            System.out.println("Try: about to return");
            return "from try";
        } finally {
            System.out.println("Finally: runs BEFORE the return");
        }
    }
}

// Output:
// --- Scenario 1: No exception ---
// Try: doing work...
// Finally: cleanup runs
//
// --- Scenario 2: Exception caught ---
// Catch: / by zero
// Finally: cleanup runs
//
// --- Scenario 3: Return in try ---
// Try: about to return
// Finally: runs BEFORE the return
// getValue() returned: from try

Classic Pattern: Resource Cleanup with finally

Before Java 7, the standard pattern for working with resources was to open them in the try block and close them in finally. This ensured resources were always released, even if an exception occurred:

import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;

public class FinallyResourceCleanup {
    public static void main(String[] args) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader("data.txt"));
            String line = reader.readLine();
            System.out.println("First line: " + line);
        } catch (IOException e) {
            System.out.println("Error reading file: " + e.getMessage());
        } finally {
            // Always close the resource, even if an exception occurred
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    System.out.println("Error closing reader: " + e.getMessage());
                }
            }
        }
    }
}

Notice how ugly this code is. The finally block needs its own try-catch because close() can also throw an IOException. You also need a null check because the reader might not have been created if the constructor threw an exception. This boilerplate is exactly why Java 7 introduced try-with-resources.



6. try-with-resources (Java 7+)

The try-with-resources statement, introduced in Java 7, automatically closes resources when the try block finishes. A resource is any object that implements the AutoCloseable interface (which has a single close() method). This eliminates the need for verbose finally blocks when working with I/O, database connections, or any closeable resource.

Syntax

// Resource is declared inside parentheses after 'try'
// It is automatically closed when the try block ends
try (ResourceType resource = new ResourceType()) {
    // Use the resource
    // resource.close() is called automatically when this block ends
} catch (ExceptionType e) {
    // Handle exceptions
}
// No finally block needed for closing!

Compare the same file-reading code from the previous section, now using try-with-resources:

import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;

public class TryWithResourcesDemo {
    public static void main(String[] args) {
        // BufferedReader is AutoCloseable -- it will be closed automatically
        try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
        // reader.close() is called automatically -- no finally needed!
    }
}

Multiple Resources

You can declare multiple resources separated by semicolons. They are closed in reverse order of declaration (last opened, first closed):

import java.io.*;

public class MultipleResources {
    public static void main(String[] args) {
        // Both reader and writer are auto-closed when the block ends
        // writer is closed first, then reader (reverse order)
        try (
            BufferedReader reader = new BufferedReader(new FileReader("input.txt"));
            BufferedWriter writer = new BufferedWriter(new FileWriter("output.txt"))
        ) {
            String line;
            while ((line = reader.readLine()) != null) {
                writer.write(line.toUpperCase());
                writer.newLine();
            }
            System.out.println("File copied and converted to uppercase.");
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Creating Your Own AutoCloseable Resource

Any class can be used with try-with-resources by implementing the AutoCloseable interface:

public class DatabaseConnection implements AutoCloseable {
    private String connectionId;

    public DatabaseConnection(String url) {
        this.connectionId = "conn-" + System.currentTimeMillis();
        System.out.println("Opening connection " + connectionId + " to " + url);
    }

    public void executeQuery(String sql) {
        System.out.println("[" + connectionId + "] Executing: " + sql);
    }

    @Override
    public void close() {
        System.out.println("Closing connection " + connectionId);
    }

    public static void main(String[] args) {
        try (DatabaseConnection db = new DatabaseConnection("jdbc:mysql://localhost/mydb")) {
            db.executeQuery("SELECT * FROM users");
            db.executeQuery("UPDATE users SET active = true WHERE id = 1");
        }
        // close() is called automatically
    }
}

// Output:
// Opening connection conn-1709123456789 to jdbc:mysql://localhost/mydb
// [conn-1709123456789] Executing: SELECT * FROM users
// [conn-1709123456789] Executing: UPDATE users SET active = true WHERE id = 1
// Closing connection conn-1709123456789



7. Throwing Exceptions

So far we have been catching exceptions that Java throws for us. But you can also throw exceptions yourself using the throw keyword. This is how you signal that something has gone wrong in your own code – for example, when a method receives an invalid argument or a business rule is violated.

The throw Keyword

The throw keyword is followed by an exception object (created with new). When Java encounters a throw statement, it immediately stops executing the current method and looks for a matching catch block in the call stack.

public class ThrowExample {
    public static void setAge(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative: " + age);
        }
        if (age > 150) {
            throw new IllegalArgumentException("Age is unrealistic: " + age);
        }
        System.out.println("Age set to: " + age);
    }

    public static void main(String[] args) {
        setAge(25);   // Works fine

        try {
            setAge(-5); // Throws IllegalArgumentException
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }

        try {
            setAge(200); // Throws IllegalArgumentException
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

// Output:
// Age set to: 25
// Error: Age cannot be negative: -5
// Error: Age is unrealistic: 200

Re-throwing Exceptions

Sometimes you want to catch an exception, do something with it (like logging), and then re-throw it so the caller can handle it too. You can re-throw the same exception or wrap it in a different one:

import java.io.IOException;

public class RethrowExample {

    public static void processFile(String path) throws IOException {
        try {
            // Simulate file processing
            if (path == null) {
                throw new IOException("File path is null");
            }
            System.out.println("Processing: " + path);
        } catch (IOException e) {
            System.out.println("LOG: Error processing file - " + e.getMessage());
            throw e; // Re-throw the same exception to the caller
        }
    }

    public static void main(String[] args) {
        try {
            processFile(null);
        } catch (IOException e) {
            System.out.println("MAIN: Caught re-thrown exception - " + e.getMessage());
        }
    }
}

// Output:
// LOG: Error processing file - File path is null
// MAIN: Caught re-thrown exception - File path is null

throw vs throws

These two keywords are related but serve different purposes. Do not confuse them:

Feature throw throws
Purpose Actually throws an exception object Declares that a method might throw an exception
Used with An exception instance (throw new IOException()) Exception class names in method signature
Location Inside a method body In the method declaration (after parameter list)
Count Throws one exception at a time Can declare multiple exceptions
Example throw new IOException("fail"); void read() throws IOException, SQLException



8. The throws Clause

When a method can throw a checked exception but does not want to handle it internally, it must declare the exception using the throws clause in its method signature. This tells the caller: “I might throw this exception, so you need to deal with it.”

This mechanism allows exceptions to propagate up the call stack until some method catches them. If no method catches the exception, the JVM terminates the program and prints the stack trace.

import java.io.*;

public class ThrowsDemo {

    // Level 3: This method throws IOException -- does not handle it
    public static String readFirstLine(String path) throws IOException {
        BufferedReader reader = new BufferedReader(new FileReader(path));
        String line = reader.readLine();
        reader.close();
        return line;
    }

    // Level 2: This method also throws IOException -- passes it up
    public static void processConfig(String path) throws IOException {
        String firstLine = readFirstLine(path);
        System.out.println("Config: " + firstLine);
    }

    // Level 1: This method catches the exception -- the buck stops here
    public static void main(String[] args) {
        try {
            processConfig("config.properties");
        } catch (IOException e) {
            System.out.println("Could not load config: " + e.getMessage());
            System.out.println("Using default settings instead.");
        }
    }
}

// If config.properties does not exist:
// Could not load config: config.properties (No such file or directory)
// Using default settings instead.

Declaring Multiple Exceptions

A method can declare multiple checked exceptions, separated by commas:

import java.io.IOException;
import java.sql.SQLException;
import java.text.ParseException;

public class MultipleThrows {

    // Declares three different checked exceptions
    public static void importData(String filePath, String dbUrl)
            throws IOException, SQLException, ParseException {

        // Could throw IOException
        // readFile(filePath);

        // Could throw ParseException
        // parseData(rawData);

        // Could throw SQLException
        // saveToDatabase(dbUrl, parsedData);

        System.out.println("Data imported successfully.");
    }

    public static void main(String[] args) {
        try {
            importData("data.csv", "jdbc:mysql://localhost/mydb");
        } catch (IOException e) {
            System.out.println("File error: " + e.getMessage());
        } catch (SQLException e) {
            System.out.println("Database error: " + e.getMessage());
        } catch (ParseException e) {
            System.out.println("Parse error at position: " + e.getErrorOffset());
        }
    }
}

When to Handle vs When to Declare

A common question is: “Should I catch the exception here, or declare it with throws and let the caller handle it?” Here is a practical guide:

Handle it here (catch) when… Propagate it (throws) when…
You can meaningfully recover (retry, use default, prompt user) You do not know how the caller wants to handle it
You are at the top-level entry point (main, controller, servlet) You are writing a library or utility method
You want to translate it to a different exception type The caller has more context to make a recovery decision
Logging and continuing is the right behavior The exception represents a condition the caller should know about

Rule of thumb: Handle the exception at the level that has enough context to decide what to do about it. Utility methods usually propagate; application-level code usually catches.



9. Creating Custom Exceptions

Java provides many built-in exception classes, but real-world applications often need their own custom exceptions to represent domain-specific error conditions. For example, a banking application might need InsufficientFundsException, and a user management system might need UserNotFoundException.

Why Create Custom Exceptions?

  • ClarityInsufficientFundsException is far more meaningful than a generic Exception("Not enough money")
  • Catch specificity – Callers can catch your specific exception type separately from other exceptions
  • Additional data – Custom exceptions can carry domain-specific fields (account number, requested amount, available balance)
  • Separation of concerns – Business logic errors are distinct from I/O errors, parsing errors, etc.

How to Create a Custom Exception

  1. Extend Exception for a checked custom exception (caller must handle)
  2. Extend RuntimeException for an unchecked custom exception (caller does not have to handle)
  3. Provide standard constructors: no-arg, message, message + cause, cause
// Checked custom exception -- caller MUST handle or declare
public class InsufficientFundsException extends Exception {
    private double currentBalance;
    private double withdrawAmount;

    // Constructor with message only
    public InsufficientFundsException(String message) {
        super(message);
    }

    // Constructor with all details
    public InsufficientFundsException(String message, double currentBalance, double withdrawAmount) {
        super(message);
        this.currentBalance = currentBalance;
        this.withdrawAmount = withdrawAmount;
    }

    // Constructor with message and cause (for exception chaining)
    public InsufficientFundsException(String message, Throwable cause) {
        super(message, cause);
    }

    public double getCurrentBalance() {
        return currentBalance;
    }

    public double getWithdrawAmount() {
        return withdrawAmount;
    }

    public double getDeficit() {
        return withdrawAmount - currentBalance;
    }
}

Now let us use this custom exception in a practical banking example:

public class BankAccount {
    private String accountNumber;
    private double balance;

    public BankAccount(String accountNumber, double initialBalance) {
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
    }

    // This method throws our custom checked exception
    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal amount must be positive: " + amount);
        }
        if (amount > balance) {
            throw new InsufficientFundsException(
                "Account " + accountNumber + ": Cannot withdraw $" + amount
                    + " (balance: $" + balance + ")",
                balance,
                amount
            );
        }
        balance -= amount;
        System.out.println("Withdrew $" + amount + ". New balance: $" + balance);
    }

    public static void main(String[] args) {
        BankAccount account = new BankAccount("ACC-1001", 500.00);

        try {
            account.withdraw(200.00); // Success
            account.withdraw(400.00); // Fails -- only $300 left
        } catch (InsufficientFundsException e) {
            System.out.println("Transaction failed: " + e.getMessage());
            System.out.println("You are short by: $" + e.getDeficit());
        }
    }
}

// Output:
// Withdrew $200.0. New balance: $300.0
// Transaction failed: Account ACC-1001: Cannot withdraw $400.0 (balance: $300.0)
// You are short by: $100.0

Unchecked Custom Exception

If your exception represents a programming error (misuse of an API) rather than a recoverable condition, extend RuntimeException:

// Unchecked custom exception -- caller does NOT have to handle
public class UserNotFoundException extends RuntimeException {
    private String userId;

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

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

    public String getUserId() {
        return userId;
    }
}

// Usage
public class UserService {
    public User findById(String userId) {
        User user = database.lookup(userId); // returns null if not found
        if (user == null) {
            throw new UserNotFoundException(userId);
        }
        return user;
    }
}



10. Multi-catch (Java 7+)

Before Java 7, if you wanted to handle multiple exception types the same way, you had to write separate catch blocks with duplicated code. Java 7 introduced multi-catch, which lets you catch multiple exception types in a single catch block using the pipe (|) operator.

import java.io.IOException;
import java.sql.SQLException;
import java.text.ParseException;

public class MultiCatchDemo {

    // Before Java 7 -- duplicated catch blocks
    public static void beforeJava7(String input) {
        try {
            processInput(input);
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
            logError(e);
        } catch (SQLException e) {
            System.out.println("Error: " + e.getMessage()); // Same code!
            logError(e);
        } catch (ParseException e) {
            System.out.println("Error: " + e.getMessage()); // Same code!
            logError(e);
        }
    }

    // Java 7+ -- single multi-catch block
    public static void withMultiCatch(String input) {
        try {
            processInput(input);
        } catch (IOException | SQLException | ParseException e) {
            // One block handles all three exception types
            System.out.println("Error: " + e.getMessage());
            logError(e);
        }
    }

    private static void processInput(String input)
            throws IOException, SQLException, ParseException {
        // Processing logic that could throw any of these
    }

    private static void logError(Exception e) {
        // Log the error
    }
}

Multi-catch Rules and Restrictions

  • No parent-child relationships – You cannot put both a parent and child exception in the same multi-catch. For example, IOException | FileNotFoundException is a compile error because FileNotFoundException extends IOException. The parent already covers the child.
  • The exception variable is effectively final – Inside a multi-catch block, you cannot reassign the exception variable (e = new IOException() will not compile). This is because the type is a union type.
  • You can combine multi-catch with separate catches – Handle some exceptions the same way and others differently:
public class MultiCatchCombined {
    public static void process(String data) {
        try {
            int number = Integer.parseInt(data);
            int[] arr = new int[number];
            arr[number] = 42; // Out of bounds if number > 0

        } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
            // Handle these two the same way
            System.out.println("Input error: " + e.getMessage());

        } catch (NegativeArraySizeException e) {
            // Handle this one differently
            System.out.println("Cannot create array with negative size");
        }
    }

    public static void main(String[] args) {
        process("abc");  // NumberFormatException
        process("5");    // ArrayIndexOutOfBoundsException
        process("-3");   // NegativeArraySizeException
    }
}

// Output:
// Input error: For input string: "abc"
// Input error: Index 5 out of bounds for length 5
// Cannot create array with negative size



11. Exception Chaining

Exception chaining (also called wrapping or nesting) is the practice of catching one exception and throwing a new, higher-level exception while preserving the original exception as the cause. This is critical in layered applications where low-level details (like SQL errors) should be translated into meaningful business-level exceptions without losing debugging information.

For example, a UserService catches a SQLException from the database layer but throws a UserNotFoundException to the controller layer. The original SQLException is preserved as the cause so developers can still see the root problem in logs.

import java.sql.SQLException;

public class ExceptionChaining {

    // Low-level method -- throws SQLException
    public static String queryDatabase(String userId) throws SQLException {
        // Simulating a database error
        throw new SQLException("Connection refused: database server is down");
    }

    // Mid-level method -- translates to a business exception
    public static String findUserName(String userId) {
        try {
            return queryDatabase(userId);
        } catch (SQLException e) {
            // Wrap the low-level exception in a business-level exception
            // The original SQLException is preserved as the cause
            throw new RuntimeException("Failed to find user: " + userId, e);
        }
    }

    public static void main(String[] args) {
        try {
            String name = findUserName("user-42");
        } catch (RuntimeException e) {
            System.out.println("Error: " + e.getMessage());
            System.out.println("Root cause: " + e.getCause().getMessage());
            System.out.println("Root cause type: " + e.getCause().getClass().getName());
        }
    }
}

// Output:
// Error: Failed to find user: user-42
// Root cause: Connection refused: database server is down
// Root cause type: java.sql.SQLException

Two Ways to Set the Cause

You can attach a cause exception in two ways:

public class ChainingMethods {
    public static void main(String[] args) {
        // Method 1: Via constructor (preferred -- cleaner and more common)
        try {
            throw new Exception("original problem");
        } catch (Exception original) {
            RuntimeException wrapper = new RuntimeException("Higher-level error", original);
            // wrapper.getCause() returns the original exception
            System.out.println("Wrapper message: " + wrapper.getMessage());
            System.out.println("Cause message: " + wrapper.getCause().getMessage());
        }

        // Method 2: Via initCause() (useful when constructor does not accept cause)
        try {
            throw new Exception("original problem");
        } catch (Exception original) {
            RuntimeException wrapper = new RuntimeException("Higher-level error");
            wrapper.initCause(original); // Set cause after construction
            System.out.println("Wrapper message: " + wrapper.getMessage());
            System.out.println("Cause message: " + wrapper.getCause().getMessage());
        }
    }
}

// Output:
// Wrapper message: Higher-level error
// Cause message: original problem
// Wrapper message: Higher-level error
// Cause message: original problem

Why Exception Chaining Matters

Without exception chaining, you lose the original error information:

// BAD: Original exception is lost -- debugging becomes very difficult
try {
    riskyOperation();
} catch (SQLException e) {
    throw new RuntimeException("Operation failed"); // WHERE did it fail? WHY?
}

// GOOD: Original exception is preserved -- full debugging context available
try {
    riskyOperation();
} catch (SQLException e) {
    throw new RuntimeException("Operation failed", e); // Full cause chain preserved
}

// When you print the stack trace of the GOOD version, you see:
// RuntimeException: Operation failed
//     at MyClass.myMethod(MyClass.java:15)
// Caused by: SQLException: Connection refused
//     at Database.query(Database.java:42)
//     ... 3 more



12. Best Practices

Proper exception handling separates professional, production-quality code from code that breaks in unexpected ways. Here are the practices that experienced Java developers follow:

1. Catch Specific Exceptions

Never catch Exception or Throwable unless you have a very good reason (like a top-level error handler in a web framework). Catching too broadly hides bugs and makes debugging nearly impossible.

// BAD: Catches everything, including bugs you should fix
try {
    processOrder(order);
} catch (Exception e) {
    System.out.println("Something went wrong"); // What went wrong? No idea.
}

// GOOD: Catch only what you expect and can handle
try {
    processOrder(order);
} catch (InsufficientFundsException e) {
    notifyUser("Not enough funds. Balance: $" + e.getCurrentBalance());
} catch (ProductOutOfStockException e) {
    notifyUser("Sorry, " + e.getProductName() + " is out of stock.");
}

2. Never Use Empty Catch Blocks

An empty catch block silently swallows the exception. The error still happened, but now you have no idea it did. At minimum, log the exception.

// TERRIBLE: Exception is completely swallowed -- silent failure
try {
    saveToDatabase(data);
} catch (SQLException e) {
    // Nothing here -- the data was NOT saved, but nobody knows!
}

// ACCEPTABLE: At minimum, log it
try {
    saveToDatabase(data);
} catch (SQLException e) {
    logger.error("Failed to save data: {}", e.getMessage(), e);
}

// BEST: Log and take appropriate action
try {
    saveToDatabase(data);
} catch (SQLException e) {
    logger.error("Failed to save to primary DB, trying backup", e);
    saveToBackupDatabase(data); // Fallback strategy
}

3. Do Not Use Exceptions for Flow Control

Exceptions should represent exceptional conditions, not normal program logic. They are significantly slower than regular control flow because the JVM has to build a stack trace object.

// BAD: Using exception as a loop terminator
try {
    int i = 0;
    while (true) {
        System.out.println(array[i++]); // Eventually throws ArrayIndexOutOfBoundsException
    }
} catch (ArrayIndexOutOfBoundsException e) {
    // Loop "finished"
}

// GOOD: Use normal loop bounds
for (int i = 0; i < array.length; i++) {
    System.out.println(array[i]);
}

// BAD: Using exception to check if a string is a number
public static boolean isNumber(String s) {
    try {
        Integer.parseInt(s);
        return true;
    } catch (NumberFormatException e) {
        return false;
    }
}

// GOOD: Use validation instead (for performance-critical code)
public static boolean isNumber(String s) {
    if (s == null || s.isEmpty()) return false;
    for (char c : s.toCharArray()) {
        if (!Character.isDigit(c)) return false;
    }
    return true;
}

4. Fail Fast

Validate inputs at the beginning of a method and throw immediately if something is wrong. This prevents the method from doing partial work that is hard to undo.

// GOOD: Validate early, fail fast
public void transferMoney(BankAccount from, BankAccount to, double amount) {
    // Validate all inputs FIRST, before doing any work
    if (from == null) throw new IllegalArgumentException("Source account cannot be null");
    if (to == null) throw new IllegalArgumentException("Target account cannot be null");
    if (amount <= 0) throw new IllegalArgumentException("Amount must be positive: " + amount);
    if (from.equals(to)) throw new IllegalArgumentException("Cannot transfer to the same account");

    // Now proceed with the actual transfer
    from.withdraw(amount);
    to.deposit(amount);
}

5. Use Custom Exceptions for Business Logic

Do not use generic exception types like Exception or RuntimeException for your business rules. Create specific exception classes that make your code self-documenting.

6. Always Clean Up Resources

Use try-with-resources for anything that implements AutoCloseable. This applies to file handles, database connections, network sockets, and streams.

7. Write Meaningful Error Messages

Exception messages should include the context needed for debugging: what was expected, what actually happened, and any relevant variable values.

// BAD: Vague messages that do not help debugging
throw new IllegalArgumentException("Invalid input");
throw new RuntimeException("Error occurred");
throw new IOException("Failed");

// GOOD: Specific messages with context
throw new IllegalArgumentException("Age must be between 0 and 150, but was: " + age);
throw new RuntimeException("Failed to send email to " + recipient + " via SMTP server " + host);
throw new IOException("Cannot read config file: " + path + " (file does not exist)");

8. Prefer Unchecked Exceptions for Programming Errors

Use checked exceptions for recoverable conditions that the caller must handle (file not found, network timeout). Use unchecked exceptions for programming bugs that should be fixed in the code (null arguments, invalid state).

Summary of Best Practices

Practice Do Do Not
Catch specificity Catch the most specific exception type Catch Exception or Throwable
Empty catch Log the error or take action Leave catch block empty
Flow control Use normal control flow (if/else, loops) Use try-catch as if/else replacement
Validation Fail fast with descriptive messages Let errors propagate silently
Resources Use try-with-resources Rely on manual close in finally
Messages Include context (values, identifiers) Write vague messages ("Error occurred")
Chaining Preserve the original cause Throw new exception without cause



13. Common Mistakes

Even experienced developers make exception handling mistakes. Here are the most common anti-patterns and how to avoid them.

Mistake 1: Swallowing Exceptions

This is the single most dangerous mistake. Catching an exception and doing nothing with it means errors happen silently, and you will spend hours debugging something that the exception message would have told you in two seconds.

// MISTAKE: Silent failure -- data is NOT saved, but the program acts like it was
public void saveUser(User user) {
    try {
        database.insert(user);
    } catch (SQLException e) {
        // Silently swallowed -- the user thinks their data was saved
    }
}

// FIX: Always handle the error meaningfully
public void saveUser(User user) {
    try {
        database.insert(user);
    } catch (SQLException e) {
        logger.error("Failed to save user {}: {}", user.getId(), e.getMessage(), e);
        throw new DataAccessException("Could not save user " + user.getId(), e);
    }
}

Mistake 2: Catching Exception Too Broadly

Catching Exception catches everything, including NullPointerException, ClassCastException, and other bugs you should find and fix, not catch and ignore.

// MISTAKE: Catches bugs along with expected exceptions
try {
    String name = null;
    processUser(name.toUpperCase()); // NullPointerException -- this is a bug!
} catch (Exception e) {
    System.out.println("User processing failed"); // Hides the NPE bug
}

// FIX: Catch only specific expected exceptions
try {
    processUser(name);
} catch (UserNotFoundException e) {
    System.out.println("User not found: " + e.getMessage());
}
// Let NullPointerException propagate -- it is a bug that needs fixing!

Mistake 3: Losing the Original Stack Trace

When you catch one exception and throw another, always pass the original as the cause. Without it, you lose the information about where the problem actually started.

// MISTAKE: Original exception is lost
try {
    readConfigFile();
} catch (IOException e) {
    throw new RuntimeException("Config loading failed");
    // The IOException and its stack trace are gone forever
}

// FIX: Preserve the original exception as the cause
try {
    readConfigFile();
} catch (IOException e) {
    throw new RuntimeException("Config loading failed", e);
    // Now the full chain is available: RuntimeException -> IOException
}

Mistake 4: Not Closing Resources

Forgetting to close resources leads to resource leaks – file handles, database connections, or network sockets that stay open and are never released, eventually causing the application to run out of resources.

// MISTAKE: Resource leak -- if readLine() throws, the reader is never closed
public String readFile(String path) throws IOException {
    BufferedReader reader = new BufferedReader(new FileReader(path));
    String content = reader.readLine();
    reader.close(); // Never reached if readLine() throws
    return content;
}

// FIX: Use try-with-resources -- close is guaranteed
public String readFile(String path) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
        return reader.readLine(); // reader is closed automatically
    }
}

Mistake 5: Throwing Generic Exception Types

Declaring throws Exception on a method forces every caller to catch Exception, which defeats the purpose of Java's exception type system.

// MISTAKE: Forces callers to catch the vague "Exception" type
public void doWork() throws Exception {
    // ...
}

// FIX: Declare specific exceptions
public void doWork() throws IOException, SQLException {
    // Now callers know exactly what can go wrong
}

Mistake 6: Using return in finally

Returning from a finally block silently swallows any exception thrown in the try or catch block. The exception is discarded, and the return value from finally is used instead. This is extremely confusing and should be avoided.

// MISTAKE: return in finally swallows the exception
public static int badMethod() {
    try {
        throw new RuntimeException("Something went wrong!");
    } finally {
        return 42; // The RuntimeException is silently discarded!
    }
}

// The caller gets 42 and has no idea an exception occurred
System.out.println(badMethod()); // Prints: 42 (no exception!)

// FIX: Never return from a finally block
public static int goodMethod() {
    try {
        // do work
        return 42;
    } finally {
        // cleanup only -- no return statement here
        System.out.println("Cleanup completed");
    }
}



14. Complete Practical Example: Banking Transaction System

Let us build a complete banking transaction system that demonstrates everything we have learned. This example includes custom exceptions, exception chaining, try-with-resources, proper error handling, and best practices. Study this code carefully – it shows how exception handling works in a real application.

Step 1: Custom Exception Classes

First, we define three custom exceptions for our banking domain. Each carries relevant context for debugging and user-facing messages.

// Checked exception -- caller must handle
public class InsufficientFundsException extends Exception {
    private final String accountId;
    private final double requestedAmount;
    private final double availableBalance;

    public InsufficientFundsException(String accountId, double requested, double available) {
        super(String.format("Account %s: requested $%.2f but only $%.2f available",
              accountId, requested, available));
        this.accountId = accountId;
        this.requestedAmount = requested;
        this.availableBalance = available;
    }

    public String getAccountId() { return accountId; }
    public double getRequestedAmount() { return requestedAmount; }
    public double getAvailableBalance() { return availableBalance; }
    public double getDeficit() { return requestedAmount - availableBalance; }
}
// Unchecked exception -- programming error (account does not exist)
public class AccountNotFoundException extends RuntimeException {
    private final String accountId;

    public AccountNotFoundException(String accountId) {
        super("Account not found: " + accountId);
        this.accountId = accountId;
    }

    public AccountNotFoundException(String accountId, Throwable cause) {
        super("Account not found: " + accountId, cause);
        this.accountId = accountId;
    }

    public String getAccountId() { return accountId; }
}
// Checked exception -- represents a transaction failure
public class TransactionException extends Exception {
    private final String transactionId;

    public TransactionException(String transactionId, String message) {
        super(message);
        this.transactionId = transactionId;
    }

    public TransactionException(String transactionId, String message, Throwable cause) {
        super(message, cause);
        this.transactionId = transactionId;
    }

    public String getTransactionId() { return transactionId; }
}

Step 2: BankAccount Class

The account class uses fail-fast validation and throws meaningful exceptions:

public class BankAccount {
    private final String accountId;
    private final String ownerName;
    private double balance;

    public BankAccount(String accountId, String ownerName, double initialBalance) {
        // Fail fast -- validate all inputs immediately
        if (accountId == null || accountId.isBlank()) {
            throw new IllegalArgumentException("Account ID cannot be null or blank");
        }
        if (ownerName == null || ownerName.isBlank()) {
            throw new IllegalArgumentException("Owner name cannot be null or blank");
        }
        if (initialBalance < 0) {
            throw new IllegalArgumentException(
                "Initial balance cannot be negative: " + initialBalance);
        }

        this.accountId = accountId;
        this.ownerName = ownerName;
        this.balance = initialBalance;
    }

    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit amount must be positive: " + amount);
        }
        balance += amount;
    }

    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal amount must be positive: " + amount);
        }
        if (amount > balance) {
            throw new InsufficientFundsException(accountId, amount, balance);
        }
        balance -= amount;
    }

    public String getAccountId() { return accountId; }
    public String getOwnerName() { return ownerName; }
    public double getBalance() { return balance; }

    @Override
    public String toString() {
        return String.format("Account[%s, %s, $%.2f]", accountId, ownerName, balance);
    }
}

Step 3: TransactionService with Exception Chaining

The service layer coordinates transfers and demonstrates exception chaining – it catches low-level exceptions and wraps them in business-level TransactionException:

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

public class TransactionService {
    private final Map accounts = new HashMap<>();

    public void addAccount(BankAccount account) {
        accounts.put(account.getAccountId(), account);
    }

    public BankAccount getAccount(String accountId) {
        BankAccount account = accounts.get(accountId);
        if (account == null) {
            throw new AccountNotFoundException(accountId);
        }
        return account;
    }

    public String transfer(String fromId, String toId, double amount)
            throws TransactionException {

        String txId = "TX-" + UUID.randomUUID().toString().substring(0, 8);

        // Fail fast -- validate inputs before doing any work
        if (fromId == null || toId == null) {
            throw new TransactionException(txId, "Account IDs cannot be null");
        }
        if (fromId.equals(toId)) {
            throw new TransactionException(txId, "Cannot transfer to the same account");
        }
        if (amount <= 0) {
            throw new TransactionException(txId,
                "Transfer amount must be positive: " + amount);
        }

        try {
            // These throw AccountNotFoundException (unchecked) if not found
            BankAccount fromAccount = getAccount(fromId);
            BankAccount toAccount = getAccount(toId);

            System.out.println("[" + txId + "] Transferring $" + amount
                + " from " + fromId + " to " + toId);

            // withdraw() throws InsufficientFundsException (checked)
            fromAccount.withdraw(amount);
            toAccount.deposit(amount);

            System.out.println("[" + txId + "] Transfer successful");
            System.out.println("  " + fromAccount);
            System.out.println("  " + toAccount);
            return txId;

        } catch (InsufficientFundsException e) {
            // Exception chaining: wrap business exception in transaction exception
            throw new TransactionException(txId,
                "Transfer failed: insufficient funds", e);

        } catch (AccountNotFoundException e) {
            // Exception chaining: wrap runtime exception in transaction exception
            throw new TransactionException(txId,
                "Transfer failed: " + e.getMessage(), e);
        }
    }
}

Step 4: Transaction Logger with try-with-resources

The logger demonstrates try-with-resources with a custom AutoCloseable:

import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class TransactionLogger implements AutoCloseable {
    private final PrintWriter writer;
    private final DateTimeFormatter formatter =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public TransactionLogger(String logFile) throws IOException {
        // true = append mode
        this.writer = new PrintWriter(new FileWriter(logFile, true));
        log("INFO", "Transaction logger started");
    }

    public void log(String level, String message) {
        String timestamp = LocalDateTime.now().format(formatter);
        String entry = String.format("[%s] [%s] %s", timestamp, level, message);
        writer.println(entry);
        writer.flush();
        System.out.println(entry); // Also print to console
    }

    public void logSuccess(String txId, String fromId, String toId, double amount) {
        log("SUCCESS", String.format("%s: $%.2f transferred from %s to %s",
            txId, amount, fromId, toId));
    }

    public void logFailure(String txId, String reason, Throwable cause) {
        log("FAILURE", txId + ": " + reason);
        if (cause != null) {
            log("DETAIL", "Cause: " + cause.getClass().getSimpleName()
                + " - " + cause.getMessage());
        }
    }

    @Override
    public void close() {
        log("INFO", "Transaction logger shutting down");
        writer.close(); // Release the file handle
    }
}

Step 5: Main Application Bringing It All Together

The main class ties everything together and demonstrates proper exception handling at every level:

import java.io.IOException;

public class BankingApp {
    public static void main(String[] args) {
        // Set up accounts
        TransactionService service = new TransactionService();
        service.addAccount(new BankAccount("ACC-001", "Alice Johnson", 1000.00));
        service.addAccount(new BankAccount("ACC-002", "Bob Smith", 500.00));
        service.addAccount(new BankAccount("ACC-003", "Charlie Brown", 250.00));

        // try-with-resources: logger is automatically closed when block ends
        try (TransactionLogger logger = new TransactionLogger("transactions.log")) {

            // Transaction 1: Successful transfer
            try {
                String txId = service.transfer("ACC-001", "ACC-002", 200.00);
                logger.logSuccess(txId, "ACC-001", "ACC-002", 200.00);
            } catch (TransactionException e) {
                logger.logFailure("N/A", e.getMessage(), e.getCause());
            }

            // Transaction 2: Insufficient funds
            try {
                String txId = service.transfer("ACC-003", "ACC-001", 999.99);
                logger.logSuccess(txId, "ACC-003", "ACC-001", 999.99);
            } catch (TransactionException e) {
                logger.logFailure(e.getTransactionId(), e.getMessage(), e.getCause());
            }

            // Transaction 3: Account not found
            try {
                String txId = service.transfer("ACC-001", "ACC-999", 50.00);
                logger.logSuccess(txId, "ACC-001", "ACC-999", 50.00);
            } catch (TransactionException e) {
                logger.logFailure(e.getTransactionId(), e.getMessage(), e.getCause());
            }

            // Transaction 4: Invalid amount
            try {
                String txId = service.transfer("ACC-001", "ACC-002", -100.00);
                logger.logSuccess(txId, "ACC-001", "ACC-002", -100.00);
            } catch (TransactionException e) {
                logger.logFailure(e.getTransactionId(), e.getMessage(), e.getCause());
            }

            // Transaction 5: Same account transfer
            try {
                String txId = service.transfer("ACC-001", "ACC-001", 50.00);
                logger.logSuccess(txId, "ACC-001", "ACC-001", 50.00);
            } catch (TransactionException e) {
                logger.logFailure(e.getTransactionId(), e.getMessage(), e.getCause());
            }

            // Print final balances
            logger.log("INFO", "=== Final Balances ===");
            for (String id : new String[]{"ACC-001", "ACC-002", "ACC-003"}) {
                BankAccount acc = service.getAccount(id);
                logger.log("INFO", acc.toString());
            }

        } catch (IOException e) {
            // This only catches the logger's IOException
            System.err.println("FATAL: Could not initialize transaction logger: "
                + e.getMessage());
        }
        // TransactionLogger.close() is called automatically here
    }
}

// Output:
// [2026-02-28 10:30:00] [INFO] Transaction logger started
// [TX-a1b2c3d4] Transferring $200.0 from ACC-001 to ACC-002
// [TX-a1b2c3d4] Transfer successful
//   Account[ACC-001, Alice Johnson, $800.00]
//   Account[ACC-002, Bob Smith, $700.00]
// [2026-02-28 10:30:00] [SUCCESS] TX-a1b2c3d4: $200.00 transferred from ACC-001 to ACC-002
// [2026-02-28 10:30:00] [FAILURE] TX-e5f6g7h8: Transfer failed: insufficient funds
// [2026-02-28 10:30:00] [DETAIL] Cause: InsufficientFundsException - Account ACC-003: requested $999.99 but only $250.00 available
// [2026-02-28 10:30:00] [FAILURE] TX-i9j0k1l2: Transfer failed: Account not found: ACC-999
// [2026-02-28 10:30:00] [DETAIL] Cause: AccountNotFoundException - Account not found: ACC-999
// [2026-02-28 10:30:00] [FAILURE] TX-m3n4o5p6: Transfer amount must be positive: -100.0
// [2026-02-28 10:30:00] [DETAIL] Cause: null
// [2026-02-28 10:30:00] [FAILURE] TX-q7r8s9t0: Cannot transfer to the same account
// [2026-02-28 10:30:00] [DETAIL] Cause: null
// [2026-02-28 10:30:00] [INFO] === Final Balances ===
// [2026-02-28 10:30:00] [INFO] Account[ACC-001, Alice Johnson, $800.00]
// [2026-02-28 10:30:00] [INFO] Account[ACC-002, Bob Smith, $700.00]
// [2026-02-28 10:30:00] [INFO] Account[ACC-003, Charlie Brown, $250.00]
// [2026-02-28 10:30:00] [INFO] Transaction logger shutting down

What This Example Demonstrates

Concept Where It Appears
Custom checked exception InsufficientFundsException, TransactionException
Custom unchecked exception AccountNotFoundException
Exception chaining TransactionService.transfer() wraps caught exceptions in TransactionException
try-with-resources TransactionLogger implements AutoCloseable, used with try-with-resources in main()
Fail fast validation BankAccount constructor, transfer() method
Specific exception catching Separate catch blocks for InsufficientFundsException and AccountNotFoundException
Meaningful error messages Every exception includes account IDs, amounts, and context
throw vs throws throw new in method bodies; throws in method signatures
Extra data in exceptions Custom getters for account ID, amounts, deficit
Resource cleanup Logger file handle closed automatically via AutoCloseable



Quick Reference

Keyword / Concept Purpose Example
try Wraps code that might throw an exception try { riskyCode(); }
catch Handles a specific exception type catch (IOException e) { ... }
finally Runs always, for cleanup finally { resource.close(); }
throw Throws an exception object throw new IllegalArgumentException("msg");
throws Declares exceptions a method may throw void read() throws IOException
try-with-resources Auto-closes AutoCloseable resources try (var r = new FileReader("f")) { ... }
Multi-catch Catches multiple types in one block catch (IOException | SQLException e)
Exception chaining Wraps an exception as the cause of another throw new AppException("msg", originalException);
Checked exception Must be caught or declared; extends Exception IOException, SQLException
Unchecked exception Not required to catch; extends RuntimeException NullPointerException, IllegalArgumentException



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *