Java Exception Handling

Series: Java Core for Beginners Lecture: 10 of 12 Topics: What is an exception · try/catch/finally · Checked vs unchecked · throw & throws · Custom exceptions · Best practices


What Is an Exception?

When a Java program encounters a situation it cannot handle normally — dividing by zero, accessing an array out of bounds, opening a file that does not exist — it signals the problem by throwing an exception. An exception is an object that describes an error condition, including a message, the type of error, and a stack trace showing exactly which methods were executing when the error occurred.

Without exception handling, an unhandled exception causes the JVM to print the stack trace and terminate the program immediately. With exception handling, your code can catch the exception, respond to it gracefully — log the error, show a user-friendly message, retry the operation, use a fallback value — and continue running.

A concrete example

int[] numbers = {10, 20, 30};
System.out.println(numbers[5]);   // index 5 does not exist

Without handling, the JVM terminates and prints:

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException:
    Index 5 out of bounds for length 3
    at Main.main(Main.java:3)

The stack trace tells you:

  • The exception type: ArrayIndexOutOfBoundsException
  • The message: Index 5 out of bounds for length 3
  • Where it happened: Main.java, line 3, in the main method

Reading stack traces fluently is one of the most important practical skills in Java development. The error is always described at the top; the cause usually sits in the first few lines of your own code (as opposed to JDK library code).


The Exception Hierarchy

All exceptions in Java are objects. They form an inheritance hierarchy rooted at Throwable:

java.lang.Throwable
    ├── java.lang.Error
    │     ├── OutOfMemoryError
    │     ├── StackOverflowError
    │     └── ... (serious JVM-level problems — do not catch these)
    │
    └── java.lang.Exception
          ├── java.lang.RuntimeException   (unchecked)
          │     ├── NullPointerException
          │     ├── ArrayIndexOutOfBoundsException
          │     ├── IllegalArgumentException
          │     ├── IllegalStateException
          │     ├── ClassCastException
          │     ├── NumberFormatException
          │     ├── ArithmeticException
          │     └── UnsupportedOperationException
          │
          ├── java.io.IOException          (checked)
          │     ├── FileNotFoundException
          │     └── EOFException
          ├── java.sql.SQLException        (checked)
          └── ... (other checked exceptions)

Error vs Exception

Error represents serious JVM-level problems that your application typically cannot recover from: running out of memory (OutOfMemoryError), infinite recursion blowing the call stack (StackOverflowError). You should not catch Error — if the JVM is out of memory, there is nothing useful your code can do.

Exception represents conditions your application might reasonably handle. This is what you work with in day-to-day Java programming.

The two categories of Exception

The Exception hierarchy splits into two fundamentally different categories — checked and unchecked — which are covered in depth in section 4.


try, catch, and finally

The three keywords that form Java’s core exception handling mechanism.

Basic try-catch

Wrap potentially failing code in a try block. If an exception occurs, execution jumps to the matching catch block:

try {
    int[] numbers = {10, 20, 30};
    System.out.println(numbers[5]);   // throws ArrayIndexOutOfBoundsException
    System.out.println("This line never runs");
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("Caught: " + e.getMessage());   // Index 5 out of bounds for length 3
}

System.out.println("Program continues normally.");

The catch parameter e is the exception object. Common methods on all exceptions:

e.getMessage()        // human-readable description of the error
e.getClass().getName() // fully qualified exception class name
e.printStackTrace()   // prints the full stack trace to stderr
e.toString()          // class name + message (used by println automatically)

Multiple catch blocks

You can have multiple catch blocks to handle different exception types differently:

public static void parseAndDivide(String numberStr, int divisor) {
    try {
        int number = Integer.parseInt(numberStr);   // may throw NumberFormatException
        int result = number / divisor;              // may throw ArithmeticException
        System.out.println("Result: " + result);
    } catch (NumberFormatException e) {
        System.out.println("Invalid number format: " + e.getMessage());
    } catch (ArithmeticException e) {
        System.out.println("Cannot divide by zero.");
    }
}
parseAndDivide("42", 6);     // Result: 7
parseAndDivide("abc", 6);    // Invalid number format: For input string: "abc"
parseAndDivide("42", 0);     // Cannot divide by zero.

Java evaluates catch blocks from top to bottom and executes the first one that matches the thrown exception type. More specific exception types must come before more general ones:

// WRONG — IOException is a supertype of FileNotFoundException
// The second catch is unreachable and will not compile
try {
    // ...
} catch (IOException e) {
    // handles all IOExceptions including FileNotFoundException
} catch (FileNotFoundException e) {  // COMPILE ERROR — already caught above
    // never reached
}

// CORRECT — specific first, general second
try {
    // ...
} catch (FileNotFoundException e) {
    System.out.println("File not found: " + e.getMessage());
} catch (IOException e) {
    System.out.println("I/O error: " + e.getMessage());
}

Catching Exception or Throwable

You can catch Exception to handle any exception in one block:

try {
    // risky code
} catch (Exception e) {
    System.out.println("Something went wrong: " + e.getMessage());
}

This is convenient but loses specificity — you handle NullPointerException, NumberFormatException, and IOException all the same way. Use it sparingly and only at the top level of your program where a catch-all makes sense (like a web server’s request handler).

Never catch Throwable — it includes Error subclasses which the JVM cannot recover from.

The finally block

Code in finally always executes — whether the try block succeeds, an exception is thrown, or the exception is caught. It runs even if the catch block rethrows the exception or the method has a return statement:

public static void riskyOperation() {
    System.out.println("Opening resource...");
    try {
        System.out.println("Performing operation...");
        if (true) throw new RuntimeException("Something failed!");
        System.out.println("This does not run.");
    } catch (RuntimeException e) {
        System.out.println("Caught: " + e.getMessage());
    } finally {
        System.out.println("Closing resource — always runs.");
    }
}

Output:

Opening resource...
Performing operation...
Caught: Something failed!
Closing resource — always runs.

The primary use of finally is releasing resources — closing files, database connections, network sockets — that must be freed regardless of whether the operation succeeded. In modern Java (7+), try-with-resources (section 7) handles this more elegantly for AutoCloseable resources. finally is still useful for general-purpose cleanup that does not involve AutoCloseable.

finally and return — a subtle interaction

finally executes even when return is encountered in try or catch:

public static int compute() {
    try {
        return 1;       // return is noted, but finally runs first
    } finally {
        System.out.println("finally runs");
        // If you wrote "return 2" here, it would override the return 1 above!
        // Never return from finally — it suppresses any exception thrown in try.
    }
}
// prints "finally runs" then returns 1

Never use return, break, or throw inside a finally block — it can suppress exceptions thrown in try, making bugs very difficult to diagnose.


Checked vs Unchecked Exceptions

This distinction is fundamental to Java’s exception system and affects how you write and sign method declarations.

Unchecked exceptions (RuntimeException and its subclasses)

Unchecked exceptions represent programming errors — bugs in your code that you should fix rather than handle:

Exception Typical cause
NullPointerException Calling a method on a null reference
ArrayIndexOutOfBoundsException Index outside [0, length-1]
ClassCastException Invalid downcast
NumberFormatException Parsing “abc” as an integer
IllegalArgumentException Invalid method argument
IllegalStateException Method called at wrong time
ArithmeticException Division by zero
UnsupportedOperationException Operation not supported (e.g., modifying immutable list)
StackOverflowError Infinite recursion

You are not required to catch or declare unchecked exceptions. The compiler does not force you to handle them because they represent conditions that should not occur in correctly written code.

Checked exceptions (Exception subclasses except RuntimeException)

Checked exceptions represent external conditions outside your control that a reasonable program might encounter and recover from:

Exception Typical cause
IOException File not found, network failure, disk full
FileNotFoundException Specific file does not exist
SQLException Database connection or query failure
ParseException Parsing a date or number from text
InterruptedException Thread interrupted while sleeping
ClassNotFoundException Class not found on the classpath

You must handle checked exceptions — either with try-catch or by declaring them with throws in the method signature. The compiler enforces this:

// Without handling — COMPILE ERROR
public static void readFile(String path) {
    FileReader fr = new FileReader(path);   // FileNotFoundException is checked — must handle!
}

// Option 1: catch it
public static void readFile(String path) {
    try {
        FileReader fr = new FileReader(path);
        // ...
    } catch (FileNotFoundException e) {
        System.out.println("File not found: " + path);
    }
}

// Option 2: declare it with throws (caller must handle)
public static void readFile(String path) throws FileNotFoundException {
    FileReader fr = new FileReader(path);
    // ...
}

The philosophical difference

The intent behind the distinction:

  • Unchecked: “This should not happen in correct code. If it does, the programmer made a mistake. Fix the code.”
  • Checked: “This might happen even in perfect code — the file might not exist, the network might be down. The caller must decide how to handle this situation.”

In practice, the Java community has shifted toward preferring unchecked exceptions for most application code, reserving checked exceptions for truly recoverable conditions where the caller genuinely needs to make a decision. Frameworks like Spring wrap checked exceptions in unchecked ones precisely because forcing callers to catch exceptions they cannot meaningfully handle adds noise without safety.


throw and throws

throw — throwing an exception

Use throw to explicitly signal an error condition from your code:

public static double sqrt(double value) {
    if (value < 0) {
        throw new IllegalArgumentException(
            "Cannot compute square root of a negative number: " + value);
    }
    return Math.sqrt(value);
}
System.out.println(sqrt(16.0));   // 4.0
System.out.println(sqrt(-1.0));   // throws IllegalArgumentException

throw takes any Throwable object. In practice, you almost always throw new SomeException(message). The new is required — you throw an instance of an exception class, not the class itself.

Execution stops immediately after throw — no code after it in the same block runs (the compiler will warn you if you write code after an unconditional throw).

Validation with throw

A common and important pattern is guarding method inputs at the start of a method — called a guard clause:

public class BankAccount {

    private double balance;

    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException(
                "Deposit amount must be positive, got: " + amount);
        }
        if (Double.isNaN(amount) || Double.isInfinite(amount)) {
            throw new IllegalArgumentException("Deposit amount must be a finite number.");
        }
        balance += amount;
    }

    public void withdraw(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException(
                "Withdrawal amount must be positive, got: " + amount);
        }
        if (amount > balance) {
            throw new IllegalStateException(
                "Insufficient funds. Balance: " + balance + ", requested: " + amount);
        }
        balance -= amount;
    }
}

Guard clauses make the contract of a method explicit and fail fast — errors are detected at their source, not discovered later when their effects propagate elsewhere in the program.

throws — declaring checked exceptions

throws in a method signature tells the caller that this method might throw the listed checked exceptions. The caller must then either catch them or declare them too:

// This method declares it might throw IOException
public static String readFirstLine(String filePath) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
        return reader.readLine();
    }
}
// Caller must handle or rethrow
public static void main(String[] args) {
    try {
        String line = readFirstLine("config.txt");
        System.out.println(line);
    } catch (IOException e) {
        System.out.println("Could not read file: " + e.getMessage());
    }
}

Declaring multiple exceptions

A method can declare multiple checked exceptions:

public static void processFile(String path)
        throws IOException, ParseException, SQLException {
    // ...
}

throws is documentation, not execution

throws does not cause anything to be thrown — it is a declaration. Listing an exception in throws that the method never actually throws is legal (though misleading). The compiler only enforces the reverse: if you call a method that throws a checked exception, you must handle it.


Creating Custom Exceptions

The JDK provides many exception types, but for domain-specific errors in your application, you should create your own. Custom exceptions make error handling more expressive and meaningful:

// Generic
throw new RuntimeException("Account balance cannot be negative");

// Specific and meaningful
throw new InsufficientFundsException(requested, available);

Creating an unchecked custom exception

Extend RuntimeException for exceptions that represent programming errors or conditions callers are not expected to recover from in most cases:

public class InsufficientFundsException extends RuntimeException {

    private final double requested;
    private final double available;

    public InsufficientFundsException(double requested, double available) {
        super(String.format(
            "Insufficient funds: requested %.2f but only %.2f available.",
            requested, available));
        this.requested = requested;
        this.available = available;
    }

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

Creating a checked custom exception

Extend Exception for conditions that callers should explicitly handle:

public class UserNotFoundException extends Exception {

    private final String userId;

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

    // Constructor that preserves the original cause (exception chaining)
    public UserNotFoundException(String userId, Throwable cause) {
        super("No user found with ID: " + userId, cause);
        this.userId = userId;
    }

    public String getUserId() { return userId; }
}

Exception constructor patterns

Well-designed custom exceptions typically offer these constructor forms:

public class AppException extends RuntimeException {

    // Message only
    public AppException(String message) {
        super(message);
    }

    // Message + cause (for wrapping lower-level exceptions)
    public AppException(String message, Throwable cause) {
        super(message, cause);
    }

    // Cause only (message is inferred from cause)
    public AppException(Throwable cause) {
        super(cause);
    }
}

Using custom exceptions

public class UserService {

    private final Map<String, String> userStore = new HashMap<>();

    public void addUser(String id, String name) {
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("User ID must not be blank.");
        }
        if (userStore.containsKey(id)) {
            throw new IllegalStateException("User with ID " + id + " already exists.");
        }
        userStore.put(id, name);
    }

    public String getUser(String id) throws UserNotFoundException {
        String name = userStore.get(id);
        if (name == null) {
            throw new UserNotFoundException(id);
        }
        return name;
    }
}
UserService service = new UserService();
service.addUser("U001", "Alice");
service.addUser("U002", "Bob");

try {
    System.out.println(service.getUser("U001"));   // Alice
    System.out.println(service.getUser("U999"));   // throws UserNotFoundException
} catch (UserNotFoundException e) {
    System.out.println("Error: " + e.getMessage());
    System.out.println("Searched ID: " + e.getUserId());
}

try-with-resources

Any resource that implements java.lang.AutoCloseable (which includes Closeable) can be used in a try-with-resources statement. Java automatically calls close() on it when the try block exits — whether normally, via exception, or via return.

Without try-with-resources (verbose and error-prone)

BufferedReader reader = null;
try {
    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 reading file: " + e.getMessage());
} finally {
    if (reader != null) {
        try {
            reader.close();   // close() itself can throw IOException!
        } catch (IOException e) {
            System.out.println("Error closing reader: " + e.getMessage());
        }
    }
}

With try-with-resources (clean and correct)

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 reading file: " + e.getMessage());
}
// reader.close() is called automatically — even if an exception occurs

The resource declaration goes in the parentheses after try. Multiple resources can be declared, separated by semicolons — they are closed in reverse declaration order:

try (Connection conn = dataSource.getConnection();
     PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users");
     ResultSet rs = stmt.executeQuery()) {

    while (rs.next()) {
        System.out.println(rs.getString("name"));
    }
} catch (SQLException e) {
    System.out.println("Database error: " + e.getMessage());
}
// rs closed first, then stmt, then conn

Making your own AutoCloseable

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

public class DatabaseConnection implements AutoCloseable {

    private final String url;
    private boolean open;

    public DatabaseConnection(String url) {
        this.url  = url;
        this.open = true;
        System.out.println("Opened connection to " + url);
    }

    public void query(String sql) {
        if (!open) throw new IllegalStateException("Connection is closed.");
        System.out.println("Executing: " + sql);
    }

    @Override
    public void close() {
        if (open) {
            open = false;
            System.out.println("Closed connection to " + url);
        }
    }
}
try (DatabaseConnection conn = new DatabaseConnection("jdbc:postgresql://localhost/mydb")) {
    conn.query("SELECT * FROM users");
    conn.query("SELECT * FROM orders");
}   // conn.close() called automatically here
// Output:
// Opened connection to jdbc:postgresql://localhost/mydb
// Executing: SELECT * FROM users
// Executing: SELECT * FROM orders
// Closed connection to jdbc:postgresql://localhost/mydb

Multi-catch and Exception Chaining

Multi-catch (Java 7+)

When you want to handle multiple exception types the same way, multi-catch combines them in a single catch block using |:

// Without multi-catch — repetitive
try {
    process(input);
} catch (NumberFormatException e) {
    logger.error("Invalid input: " + e.getMessage());
} catch (IllegalArgumentException e) {
    logger.error("Invalid input: " + e.getMessage());
} catch (ArithmeticException e) {
    logger.error("Invalid input: " + e.getMessage());
}

// With multi-catch — clean
try {
    process(input);
} catch (NumberFormatException | IllegalArgumentException | ArithmeticException e) {
    logger.error("Invalid input: " + e.getMessage());
}

In a multi-catch block, e is effectively final — you cannot reassign it. The exception types in a multi-catch must not be in a subtype relationship (if A is a subtype of B, catching A | B makes A redundant and the compiler reports an error).

Exception chaining (wrapping exceptions)

When you catch a low-level exception and throw a higher-level one, always preserve the original cause by passing it to the new exception’s constructor. This maintains the full diagnostic chain:

public class DataLoader {

    public List<String> loadFromFile(String path) {
        try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
            List<String> lines = new ArrayList<>();
            String line;
            while ((line = reader.readLine()) != null) {
                lines.add(line);
            }
            return lines;
        } catch (FileNotFoundException e) {
            // Wrap in domain exception, preserving the original cause
            throw new DataLoadException("Configuration file not found: " + path, e);
        } catch (IOException e) {
            throw new DataLoadException("Failed to read configuration from: " + path, e);
        }
    }
}
public class DataLoadException extends RuntimeException {
    public DataLoadException(String message, Throwable cause) {
        super(message, cause);
    }
}

When the stack trace is printed, both exceptions appear — the high-level message first, then “Caused by:” showing the original low-level exception. This gives you the full picture:

Exception in thread "main" DataLoadException: Configuration file not found: config.txt
    at DataLoader.loadFromFile(DataLoader.java:14)
    at Main.main(Main.java:8)
Caused by: java.io.FileNotFoundException: config.txt (No such file or directory)
    at java.io.FileInputStream.open0(Native Method)
    ...

Never swallow the cause like this:

// BAD — original exception is lost, debugging becomes very hard
} catch (IOException e) {
    throw new DataLoadException("Failed to load data.");  // where did e go?
}

Best Practices and Common Pitfalls

Do: catch specific exceptions

// BAD — catches everything, hides bugs
try {
    processOrder(order);
} catch (Exception e) {
    log.error("Something went wrong");
}

// GOOD — specific handling for each condition
try {
    processOrder(order);
} catch (InsufficientFundsException e) {
    notifyCustomer(e.getMessage());
} catch (ProductOutOfStockException e) {
    suggestAlternatives(order);
} catch (DatabaseException e) {
    log.error("Database failure processing order", e);
    throw e;   // re-throw — cannot recover here, let it propagate
}

Do: always log the exception object, not just the message

// BAD — stack trace is lost
log.error("Error: " + e.getMessage());

// GOOD — full exception including stack trace is logged
log.error("Failed to process payment", e);

Don’t: swallow exceptions silently

// TERRIBLE — problem disappears silently, bug is hidden
try {
    riskyOperation();
} catch (Exception e) {
    // empty catch — the exception is swallowed
}

If you genuinely must catch and not rethrow, at minimum log the exception with a clear explanation of why it is safe to ignore.

Don’t: use exceptions for normal control flow

// BAD — using exceptions to detect list end (slow, wrong idiom)
try {
    int i = 0;
    while (true) {
        process(list.get(i++));
    }
} catch (IndexOutOfBoundsException e) {
    // end of list
}

// GOOD — use normal control flow
for (int i = 0; i < list.size(); i++) {
    process(list.get(i));
}

Exceptions are expensive to create (the JVM captures the entire call stack). Using them for flow control is both slow and stylistically wrong.

Do: fail fast with clear messages

// BAD — vague, hard to debug
if (name == null) throw new RuntimeException("Error");

// GOOD — immediately actionable
if (name == null) throw new IllegalArgumentException(
    "Customer name must not be null. Received null in createOrder().");

A good exception message answers: what went wrong, what value was involved, and where/why it is a problem.

Do: use try-with-resources for all AutoCloseable resources

Never manage Closeable resources with manual finally blocks in modern Java. try-with-resources is safer, shorter, and handles the edge case where close() itself throws.

Don’t: catch and rethrow without adding value

// BAD — pointless, adds noise to the stack trace
try {
    doSomething();
} catch (SomeException e) {
    throw e;   // no added value — just let it propagate naturally
}

// GOOD — rethrow when you add context or wrap in a domain exception
try {
    doSomething();
} catch (LowLevelException e) {
    throw new DomainException("Failed to complete order #" + orderId, e);
}

Exception hierarchy guidelines

  • Use IllegalArgumentException for invalid method arguments.
  • Use IllegalStateException for operations called at wrong times.
  • Use NullPointerException for unexpected null arguments (or Objects.requireNonNull()).
  • Use UnsupportedOperationException for unimplemented optional operations.
  • Extend RuntimeException for custom application exceptions.
  • Extend Exception only when the caller genuinely must handle the condition.

Putting It Together — A Complete Example

Let us build a robust OrderProcessor that demonstrates all the concepts in this lecture:

// Custom exception hierarchy
public class OrderException extends RuntimeException {
    public OrderException(String message) { super(message); }
    public OrderException(String message, Throwable cause) { super(message, cause); }
}

public class InvalidOrderException extends OrderException {
    private final String field;
    public InvalidOrderException(String field, String reason) {
        super("Invalid order field '" + field + "': " + reason);
        this.field = field;
    }
    public String getField() { return field; }
}

public class InsufficientInventoryException extends OrderException {
    private final String productId;
    private final int requested;
    private final int available;
    public InsufficientInventoryException(String productId, int requested, int available) {
        super(String.format("Product '%s': requested %d but only %d in stock.",
              productId, requested, available));
        this.productId = productId;
        this.requested = requested;
        this.available = available;
    }
    public String getProductId() { return productId; }
    public int getRequested()    { return requested; }
    public int getAvailable()    { return available; }
}
public class Order {
    private final String orderId;
    private final String productId;
    private final int quantity;
    private final String customerEmail;

    public Order(String orderId, String productId, int quantity, String customerEmail) {
        this.orderId       = orderId;
        this.productId     = productId;
        this.quantity      = quantity;
        this.customerEmail = customerEmail;
    }

    public String getOrderId()       { return orderId; }
    public String getProductId()     { return productId; }
    public int getQuantity()         { return quantity; }
    public String getCustomerEmail() { return customerEmail; }
}
public class OrderProcessor {

    private final Map<String, Integer> inventory = new HashMap<>();
    private final List<String> processedOrders  = new ArrayList<>();

    public OrderProcessor() {
        inventory.put("PROD-001", 50);
        inventory.put("PROD-002", 10);
        inventory.put("PROD-003", 0);
    }

    // Validation — throws InvalidOrderException for bad input
    private void validateOrder(Order order) {
        if (order.getOrderId() == null || order.getOrderId().isBlank()) {
            throw new InvalidOrderException("orderId", "must not be blank");
        }
        if (order.getProductId() == null || order.getProductId().isBlank()) {
            throw new InvalidOrderException("productId", "must not be blank");
        }
        if (order.getQuantity() <= 0) {
            throw new InvalidOrderException("quantity",
                "must be positive, got " + order.getQuantity());
        }
        if (order.getCustomerEmail() == null || !order.getCustomerEmail().contains("@")) {
            throw new InvalidOrderException("customerEmail",
                "must be a valid email address");
        }
    }

    // Inventory check — throws InsufficientInventoryException if stock too low
    private void checkInventory(String productId, int quantity) {
        Integer stock = inventory.get(productId);
        if (stock == null) {
            throw new OrderException("Product not found: " + productId);
        }
        if (stock < quantity) {
            throw new InsufficientInventoryException(productId, quantity, stock);
        }
    }

    // Main processing method — coordinates validation, inventory, and recording
    public String processOrder(Order order) {
        validateOrder(order);
        checkInventory(order.getProductId(), order.getQuantity());

        // Deduct inventory
        inventory.merge(order.getProductId(), -order.getQuantity(), Integer::sum);

        // Record the order
        processedOrders.add(order.getOrderId());

        return "Order " + order.getOrderId() + " processed successfully.";
    }

    // Batch processing — continues even if individual orders fail
    public void processBatch(List<Order> orders) {
        for (Order order : orders) {
            try {
                String result = processOrder(order);
                System.out.println("[OK]   " + result);
            } catch (InvalidOrderException e) {
                System.out.println("[SKIP] Invalid order " + order.getOrderId()
                    + " — field '" + e.getField() + "': " + e.getMessage());
            } catch (InsufficientInventoryException e) {
                System.out.println("[SKIP] Stock issue for order " + order.getOrderId()
                    + " — " + e.getMessage());
            } catch (OrderException e) {
                System.out.println("[FAIL] Unexpected error for order " + order.getOrderId()
                    + ": " + e.getMessage());
            }
        }
        System.out.println("\nProcessed " + processedOrders.size()
            + " of " + orders.size() + " orders.");
    }
}
public class Main {
    public static void main(String[] args) {
        OrderProcessor processor = new OrderProcessor();

        List<Order> batch = List.of(
            new Order("ORD-001", "PROD-001", 5,   "alice@example.com"),  // OK
            new Order("ORD-002", "PROD-002", 15,  "bob@example.com"),    // insufficient stock
            new Order("ORD-003", "PROD-003", 1,   "charlie@example.com"),// out of stock
            new Order("ORD-004", "",         2,   "dave@example.com"),   // invalid productId
            new Order("ORD-005", "PROD-001", -3,  "eve@example.com"),    // invalid quantity
            new Order("ORD-006", "PROD-001", 10,  "not-an-email"),       // invalid email
            new Order("ORD-007", "PROD-002", 8,   "frank@example.com")   // OK
        );

        processor.processBatch(batch);
    }
}

Output:

[OK]   Order ORD-001 processed successfully.
[SKIP] Stock issue for order ORD-002 — Product 'PROD-002': requested 15 but only 10 in stock.
[SKIP] Stock issue for order ORD-003 — Product 'PROD-003': requested 1 but only 0 in stock.
[SKIP] Invalid order ORD-004 — field 'productId': Invalid order field 'productId': must not be blank
[SKIP] Invalid order ORD-005 — field 'quantity': Invalid order field 'quantity': must be positive, got -3
[SKIP] Invalid order ORD-006 — field 'customerEmail': Invalid order field 'customerEmail': must be a valid email address
[OK]   Order ORD-007 processed successfully.

Processed 2 of 7 orders.

Notice how the exception hierarchy lets processBatch handle different error types with different strategies — validation errors and inventory errors are “skippable,” while unexpected OrderException is flagged as a failure. The batch continues even when individual orders fail.


Summary

  • An exception is an object that signals an error condition. The JVM terminates unhandled exceptions with a stack trace. Exception handling lets you respond gracefully and continue running.
  • The hierarchy: ThrowableError (do not catch) / ExceptionRuntimeException (unchecked) + checked exceptions.
  • try-catch-finally: try wraps risky code; catch handles specific exceptions (most specific first); finally always runs — use it for cleanup.
  • Unchecked exceptions (RuntimeException and subclasses) represent programming errors. The compiler does not require you to handle them.
  • Checked exceptions (Exception subclasses except RuntimeException) represent recoverable external conditions. You must catch them or declare them with throws.
  • throw explicitly signals an exception from your code. Use guard clauses at method boundaries to fail fast with clear messages.
  • throws in a method signature declares that the method may throw checked exceptions. The caller must handle or propagate them.
  • Custom exceptions make your domain’s error conditions explicit. Extend RuntimeException for unchecked, Exception for checked. Always provide a message+cause constructor.
  • try-with-resources automatically closes AutoCloseable resources. Prefer it over manual finally for all resource management.
  • Multi-catch combines handling of multiple exception types with |. Exception chaining preserves the original cause when wrapping exceptions — always pass cause to the super constructor.
  • Key practices: catch specific types, log the full exception object, never swallow silently, fail fast with clear messages, never use exceptions for normal flow control.

Exercises

Exercise 1 — Reading stack traces Write a program with a call chain: mainprocessDataparseValueInteger.parseInt. Call it with the string "hello". Let the NumberFormatException propagate uncaught, observe the full stack trace, and explain in comments: (a) where the exception was thrown, (b) how execution propagated back up the call stack, (c) what information each line of the stack trace provides.

Exercise 2 — Robust calculator Write a calculate(String expression) method that parses strings like "10 / 2" and "5 + 3". Handle: NumberFormatException (non-numeric operands), ArithmeticException (division by zero), ArrayIndexOutOfBoundsException (malformed expression — too few parts). Return a double result or throw a custom CalculationException that wraps the original cause for all error types.

Exercise 3 — Custom exception hierarchy Design a custom exception hierarchy for a library system:

  • LibraryException (base, unchecked)

BookNotFoundException (with isbn field) – BookNotAvailableException (with isbn and dueDate fields) – MemberNotFoundException (with memberId field) – MaxBorrowLimitException (with limit field)

Implement a Library class with borrowBook(memberId, isbn) and returnBook(memberId, isbn) methods that throw the appropriate exceptions. Write a main that demonstrates each exception type.

Exercise 4 — try-with-resources Implement an AutoCloseable class Timer that records a start time in its constructor and prints elapsed milliseconds in close(). Use it in a try-with-resources block around code that performs a task (e.g., sorting a large list). Verify that close() is called even if an exception occurs in the middle of the timed block.

Exercise 5 — Exception chaining and wrapping Write a ConfigLoader class with a load(String filename) method that:

  1. Reads the file (may throw IOException)
  2. Parses each line as key=value (may encounter malformed lines)
  3. Returns a Map

Wrap all IOExceptions in ConfigLoadException (custom unchecked) and all parse errors in ConfigParseException (custom unchecked). Always preserve the original cause. Write a caller that catches each type separately and prints a meaningful message.

Exercise 6 — Batch processing with recovery Write a DataPipeline class that processes a List of raw records (each is a comma-separated name,age,salary). For each record: parse it, validate it (name not blank, age 18–65, salary > 0), and add it to a results list. Collect all errors — do not stop on the first failure. Use a List to accumulate error messages. At the end, print both the successfully parsed records and the list of errors. Test with a mix of valid and invalid records.


Up next: Lecture 11 — File I/O & Streams — where you will learn how to read and write files, work with paths and directories, and understand the difference between byte streams and character streams.

Leave a Reply

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