β˜• Java

Java Exception Handling β€” try-catch-finally, Custom Exceptions & Best Practices

Everything you need to know about Java Exception Handling β€” exception hierarchy, try-catch-finally, throw vs throws, checked vs unchecked, multi-catch, try-with-resources, custom exceptions, exception chaining, propagation, anti-patterns, and real-world production code examples.

πŸ“…

Last Updated

March 2026

⏱️

Read Time

28 min

🎯

Level

Beginner to Intermediate

🏷️

Chapter

21 of 35

What is an Exception in Java?

An exception in Java is an unexpected event that disrupts the normal flow of a program during execution. When a problematic situation arises β€” a file that does not exist, a null reference being dereferenced, an invalid array index, a network connection timeout β€” the Java runtime creates an exception object that encapsulates information about the error: its type, a descriptive message, and the stack trace showing exactly where in the code it occurred.

Without exception handling, any runtime error immediately terminates the entire program β€” a poor user experience and a reliability disaster for production systems. Java's exception handling mechanism allows programs to detect these conditions, respond intelligently, log the problem, and continue operating β€” or shut down gracefully with a meaningful error message β€” rather than crashing without explanation.

Java uses five keywords for exception handling: try β€” wraps the risky code that might throw an exception; catch β€” handles a specific exception type; finally β€” always executes, regardless of outcome, used for cleanup; throw β€” manually creates and throws an exception; throws β€” declares in the method signature that a method may propagate a checked exception to its caller.

Exception Hierarchy β€” The Complete Throwable Tree

Java's exception mechanism is built on a class hierarchy rooted at java.lang.Throwable. Every exception and error in Java is an instance of Throwable or one of its subclasses. Understanding this hierarchy is fundamental to writing correct exception handling code β€” because catch blocks use IS-A matching, catching a parent type also catches all its subclasses.

🌳
Throwable (Root)

java.lang.Throwable is the root of all exception and error types. It provides: getMessage() β€” descriptive error message, getCause() β€” the original exception that caused this one, getStackTrace() β€” array of stack frames, printStackTrace() β€” prints the full call stack to stderr. Only Throwable instances (and subclasses) can be thrown with 'throw' and caught with 'catch'.

⚠️
Exception Branch

java.lang.Exception represents conditions a program should anticipate and recover from. Two major subtrees: β€’ Checked Exceptions: extend Exception directly (not via RuntimeException). Compiler enforces handling. Examples: IOException, SQLException, ClassNotFoundException, ParseException. β€’ Unchecked Exceptions: extend RuntimeException. No compiler enforcement. Examples: NullPointerException, IllegalArgumentException, ArrayIndexOutOfBoundsException.

πŸ’₯
Error Branch

java.lang.Error represents severe, typically unrecoverable JVM-level failures. Should NEVER be caught in normal application code. Examples: β€’ OutOfMemoryError β€” heap exhausted β€’ StackOverflowError β€” infinite recursion β€’ NoClassDefFoundError β€” class missing at runtime β€’ AssertionError β€” assertion failed β€’ VirtualMachineError β€” JVM internal error If you see these in production, they indicate infrastructure or code architecture problems β€” not logic bugs to catch.

β˜• JavaExceptionHierarchy.java β€” Visual Tree
/*
 * java.lang.Throwable
 * β”œβ”€β”€ java.lang.Error                    ← Do NOT catch these
 * β”‚   β”œβ”€β”€ OutOfMemoryError
 * β”‚   β”œβ”€β”€ StackOverflowError
 * β”‚   β”œβ”€β”€ NoClassDefFoundError
 * β”‚   β”œβ”€β”€ AssertionError
 * β”‚   └── VirtualMachineError
 * β”‚
 * └── java.lang.Exception               ← Handle these
 *     β”œβ”€β”€ IOException           (CHECKED)
 *     β”‚   β”œβ”€β”€ FileNotFoundException
 *     β”‚   └── SocketException
 *     β”œβ”€β”€ SQLException          (CHECKED)
 *     β”œβ”€β”€ ClassNotFoundException (CHECKED)
 *     β”œβ”€β”€ ParseException        (CHECKED)
 *     β”œβ”€β”€ InterruptedException  (CHECKED)
 *     └── RuntimeException      (UNCHECKED β€” no compiler enforcement)
 *         β”œβ”€β”€ NullPointerException
 *         β”œβ”€β”€ IllegalArgumentException
 *         β”œβ”€β”€ IllegalStateException
 *         β”œβ”€β”€ ArrayIndexOutOfBoundsException
 *         β”œβ”€β”€ ClassCastException
 *         β”œβ”€β”€ ArithmeticException
 *         β”œβ”€β”€ NumberFormatException
 *         β”œβ”€β”€ UnsupportedOperationException
 *         └── ConcurrentModificationException
 */

// Demonstrating hierarchy with instanceof:
public class HierarchyDemo {
    public static void main(String[] args) {
        Exception e = new java.io.FileNotFoundException("config.yaml not found");

        System.out.println(e instanceof Throwable);                  // true
        System.out.println(e instanceof Exception);                  // true
        System.out.println(e instanceof java.io.IOException);        // true
        System.out.println(e instanceof java.io.FileNotFoundException); // true
        System.out.println(e instanceof RuntimeException);           // false

        // Catching a parent type catches all child types
        try {
            throw new java.io.FileNotFoundException("not found");
        } catch (java.io.IOException ioEx) {
            // βœ… Catches FileNotFoundException because it IS-A IOException
            System.out.println("Caught: " + ioEx.getClass().getSimpleName());
        }
    }
}

try-catch-finally β€” The Foundation of Exception Handling

The try-catch-finally construct is the core mechanism for handling exceptions in Java. The try block wraps code that might throw an exception. One or more catch blocks each handle a specific exception type. The optional finally block runs always β€” after normal completion or exception handling β€” making it the perfect place for cleanup code like closing resources.

πŸ›‘οΈ
try Block

Wraps the risky code β€” statements that might throw exceptions. The moment an exception is thrown inside try, execution jumps immediately to the matching catch block β€” remaining statements in the try block are skipped. If no exception occurs, all catch blocks are skipped and finally (if present) executes.

🎯
catch Block

Handles a specific exception type. Catches the exception and its subclasses (IS-A). Multiple catch blocks can follow one try β€” each handles a different exception type. Evaluated top-to-bottom β€” the FIRST matching catch block runs; all others are skipped. NEVER catch a more general type before a more specific one in the same try-catch β€” compile error (unreachable catch block).

πŸ”’
finally Block

Executes ALWAYS β€” whether the try block completes normally, throws an exception that is caught, or throws an exception that is NOT caught. Even a return statement in try or catch does NOT prevent finally from running. Three exceptional cases: System.exit(), JVM crash, or thread death. Use finally for: closing file handles, releasing DB connections, unlocking resources.

β˜• JavaTryCatchFinallyDemo.java
import java.io.*;

public class TryCatchFinallyDemo {

    // ── BASIC try-catch ────────────────────────────────
    public static void basicDemo() {
        try {
            int result = 10 / 0;  // Throws ArithmeticException
            System.out.println("This line never runs");
        } catch (ArithmeticException e) {
            System.out.println("Caught: " + e.getMessage()); // / by zero
        }
        System.out.println("Program continues after catch");
    }

    // ── MULTIPLE catch blocks ──────────────────────────
    public static void multiCatchDemo(String input, int[] arr, int index) {
        try {
            int parsed  = Integer.parseInt(input); // NumberFormatException
            int element = arr[index];              // ArrayIndexOutOfBoundsException
            System.out.println(parsed + element);
        } catch (NumberFormatException e) {
            System.out.println("Invalid number format: " + e.getMessage());
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("Array index out of range: " + index);
        } catch (Exception e) {
            // General fallback β€” catches anything not caught above
            System.out.println("Unexpected error: " + e.getMessage());
        }
    }

    // ── finally block for cleanup ──────────────────────
    public static String readFirstLine(String filePath) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader(filePath));
            return reader.readLine(); // May throw IOException
        } catch (FileNotFoundException e) {
            System.err.println("File not found: " + filePath);
            return null;
        } catch (IOException e) {
            System.err.println("Error reading file: " + e.getMessage());
            return null;
        } finally {
            // ALWAYS runs β€” whether readLine() succeeded or threw
            if (reader != null) {
                try {
                    reader.close(); // Close the resource
                } catch (IOException closeEx) {
                    System.err.println("Failed to close reader");
                }
            }
            System.out.println("finally block: reader closed");
        }
    }

    // ── finally vs return β€” finally WINS ──────────────
    public static String finallyVsReturn() {
        try {
            System.out.println("In try");
            return "From try";   // return is noted, but finally runs first
        } finally {
            System.out.println("In finally"); // ← This ALWAYS runs
            // If we return here, it OVERRIDES the try's return!
            // return "From finally"; // ← Would override 'From try' β€” BAD PRACTICE
        }
    }
    // Output: 'In try' β†’ 'In finally' β†’ returns 'From try'

    public static void main(String[] args) {
        basicDemo();
        multiCatchDemo("abc", new int[]{1,2,3}, 5);
        System.out.println(finallyVsReturn());
    }
}

throw vs throws β€” Two Different Keywords, Two Different Purposes

Java uses two similar-looking keywords β€” throw and throws β€” that serve completely different purposes. Confusing them is one of the most common interview mistakes. Mastering both is essential for writing correct, self-documenting exception handling code.

Aspectthrowthrows
PurposeActually CREATES and THROWS an exception objectDECLARES that a method may propagate checked exceptions
LocationInside a method body β€” a statementIn the method signature β€” after parameter list
Syntaxthrow new ExceptionType("message");void method() throws IOException, SQLException
Exception countThrows EXACTLY ONE exception at a timeCan declare MULTIPLE exception types (comma-separated)
Object requiredYES β€” must provide an exception instanceNO β€” just the exception class name
Who calls it?Developer explicitly, or JVM implicitlyOnly developer β€” in method signature
Required for?Both checked AND unchecked exceptionsOnly CHECKED exceptions (unchecked don't need it)
What it triggers?Immediately transfers control to catch/callerNothing at runtime β€” it's a compile-time declaration
β˜• JavaThrowVsThrowsDemo.java
import java.io.*;
import java.sql.*;

public class ThrowVsThrowsDemo {

    // ── 'throws' in method signature ──────────────────
    // Declares: this method may propagate IOException to caller
    // Caller is FORCED to handle or re-declare it
    public String readConfig(String path) throws IOException {
        // If FileReader throws IOException, it propagates up
        BufferedReader br = new BufferedReader(new FileReader(path));
        return br.readLine();
    }

    // ── 'throw' in method body ────────────────────────
    // Developer explicitly creates and throws an exception
    public double divide(double numerator, double denominator) {
        if (denominator == 0) {
            throw new ArithmeticException("Division by zero is not allowed");
        }
        return numerator / denominator;
    }

    // ── Both together: throw + throws ─────────────────
    public void processAge(int age) throws IllegalArgumentException {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException(
                "Age must be between 0 and 150, received: " + age);
        }
        System.out.println("Processing age: " + age);
    }
    // Note: IllegalArgumentException is unchecked β€” 'throws' is optional here
    // But declaring it documents the failure condition clearly

    // ── Multiple exceptions in throws ──────────────────
    public void saveUserData(String userId, String data)
            throws IOException, SQLException {
        // This method might throw either IOException or SQLException
        if (data == null)
            throw new IOException("Data cannot be null");
        // ... database save operation ...
    }

    // ── Caller must handle declared checked exceptions ─
    public void callerMethod() {
        // Option 1: catch it
        try {
            String config = readConfig("app.yml");
            System.out.println(config);
        } catch (IOException e) {
            System.err.println("Failed to read config: " + e.getMessage());
        }

        // Option 2: re-declare it (propagate further)
        // Add 'throws IOException' to this method's signature
    }

    // ── Re-throwing exceptions ─────────────────────────
    public void processFile(String path) throws IOException {
        try {
            String line = readConfig(path);
            if (line == null)
                throw new IOException("File is empty: " + path);
            // process...
        } catch (IOException e) {
            System.err.println("Processing failed, re-throwing...");
            throw e; // Re-throw the same exception
        }
    }
}

Checked vs Unchecked Exceptions β€” Know the Difference

Java's exception system is divided into checked and unchecked exceptions. This distinction profoundly affects how you write and call methods. The Java compiler actively participates in checked exception handling β€” it verifies at compile time that every checked exception is either handled or declared. Unchecked exceptions bypass this compiler check entirely.

AspectChecked ExceptionUnchecked Exception
Superclassextends Exception (not RuntimeException)extends RuntimeException
Compiler checkβœ… YES β€” must handle or declare with throws❌ NO β€” optional to handle
RepresentsRecoverable external conditionsProgramming errors / bugs
When to useCaller CAN and SHOULD handle itCaller cannot reasonably recover
ExamplesIOException, SQLException, ParseExceptionNullPointerException, IllegalArgumentException
Design intentForces callers to think about failure pathsSignals a bug in the code
API contractPart of the public API contract via throwsNot part of the contract
In frameworksSpring wraps most checked in unchecked (DataAccessException)Used everywhere
β˜• JavaCheckedVsUnchecked.java
import java.io.*;
import java.text.*;

public class CheckedVsUnchecked {

    // ── CHECKED EXCEPTION β€” compiler enforces handling ─

    // Must declare 'throws IOException' β€” it's checked
    public static byte[] readFile(String path) throws IOException {
        return new FileInputStream(path).readAllBytes();
    }

    // Caller MUST handle IOException
    public static void callerOfReadFile() {
        try {
            byte[] content = readFile("data.bin");
            System.out.println("Read " + content.length + " bytes");
        } catch (IOException e) {
            // Meaningful recovery: use default, log, alert ops team
            System.err.println("File read failed: " + e.getMessage());
        }
        // ❌ If we omit the try-catch: compile error!
        // "Unhandled exception type IOException"
    }

    // ── UNCHECKED EXCEPTION β€” no compiler enforcement ──

    // No 'throws' needed β€” IllegalArgumentException is unchecked
    public static double calculateBMI(double weightKg, double heightMetres) {
        if (weightKg <= 0)
            throw new IllegalArgumentException("Weight must be positive: " + weightKg);
        if (heightMetres <= 0)
            throw new IllegalArgumentException("Height must be positive: " + heightMetres);
        return weightKg / (heightMetres * heightMetres);
    }

    // Caller may choose to catch or let it propagate
    public static void callerOfBMI() {
        // βœ… Legal without try-catch (unchecked)
        double bmi = calculateBMI(70, 1.75);
        System.out.println("BMI: " + bmi);

        // βœ… Also legal WITH try-catch for better UX
        try {
            double badBmi = calculateBMI(-5, 1.75);
        } catch (IllegalArgumentException e) {
            System.out.println("Input error: " + e.getMessage());
        }
    }

    // ── REAL-WORLD: When to choose which ──────────────

    // Checked: external resource β€” caller SHOULD handle
    public void connectToDatabase(String url, String user, String pass)
            throws java.sql.SQLException {
        // Network/DB failures are recoverable β€” caller should retry/fail gracefully
        java.sql.DriverManager.getConnection(url, user, pass);
    }

    // Unchecked: programming contract β€” caller shouldn't reach this if correct
    public void setPositiveAge(int age) {
        if (age < 0)
            throw new IllegalArgumentException(
                "Age cannot be negative. Caller has a bug.");
        // If caller passes valid age, this never throws
    }
}

Multi-catch & Exception Order β€” Catching Multiple Types

Java provides two patterns for catching multiple exception types: separate catch blocks (one per exception type) and multi-catch (Java 7+, multiple types in one block separated by |). Understanding the ordering rules prevents the most common multi-catch compile errors.

πŸ“‹
Catch Block Order Rules

Catch blocks are evaluated TOP-TO-BOTTOM. The FIRST matching catch block runs β€” all remaining are skipped. Rule: always place MORE SPECIFIC exceptions BEFORE LESS SPECIFIC ones. Wrong order: catch(Exception e) before catch(IOException e) β†’ compile error: 'exception IOException has already been caught'. Correct order: specific first, general last. A 'catch (Exception e)' as the last catch is a common pattern to handle any unexpected exception.

⚑
Multi-catch (Java 7+) β€” The | Operator

Syntax: catch (ExceptionA | ExceptionB | ExceptionC e) When multiple exceptions need the SAME handling logic, multi-catch eliminates code duplication. Constraint: the caught variable 'e' is implicitly final β€” cannot be reassigned inside the block. Constraint: cannot use related types (parent and child) in the same multi-catch β€” compile error: 'type is already caught by the alternative'. Benefit: cleaner code without repeating the same handler body.

🎯
Catching Throwable vs Exception

catch(Exception e): catches all checked and unchecked exceptions β€” appropriate as a last resort fallback. catch(Throwable t): catches Errors too β€” almost never appropriate. Errors (OutOfMemoryError etc.) signal JVM failure β€” catching them can hide serious problems or prevent proper JVM shutdown. Rule: catch the most specific type you can meaningfully handle. Use catch(Exception e) sparingly as a global fallback. Never catch Throwable in normal application code.

β˜• JavaMultiCatchDemo.java
import java.io.*;
import java.sql.*;
import java.text.*;

public class MultiCatchDemo {

    // ── SEPARATE CATCH BLOCKS β€” different handling ─────
    public static void separateCatchDemo(String filePath, String dateStr) {
        try {
            File file = new File(filePath);
            if (!file.exists())
                throw new FileNotFoundException("Config missing: " + filePath);

            SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy");
            java.util.Date date = sdf.parse(dateStr);

            System.out.println("File: " + file.getName() + ", Date: " + date);

        } catch (FileNotFoundException e) {
            // Specific handling: use a default config
            System.err.println("Config not found, using defaults: " + e.getMessage());
        } catch (ParseException e) {
            // Specific handling: reject the request
            System.err.println("Invalid date format at position " + e.getErrorOffset());
        } catch (Exception e) {
            // General fallback: log and alert
            System.err.println("Unexpected error: " + e.getClass().getName());
        }
    }

    // ── MULTI-CATCH (Java 7+) β€” same handling for multiple types ─
    public static void processUserRequest(String userId, String amount) {
        try {
            if (userId == null) throw new IllegalArgumentException("userId is null");
            double parsedAmount = Double.parseDouble(amount);
            int    parsedId     = Integer.parseInt(userId);
            System.out.println("Processing " + parsedAmount + " for user " + parsedId);

        } catch (NumberFormatException | IllegalArgumentException e) {
            // βœ… Multi-catch: both get same 'invalid input' handling
            System.err.println("[VALIDATION] Invalid input: " + e.getMessage());
            // 'e' is implicitly final here β€” cannot do: e = new Exception();
        } catch (Exception e) {
            System.err.println("[SYSTEM] Unexpected: " + e.getMessage());
        }
    }

    // ── CORRECT ORDER β€” specific before general ────────
    public static void correctOrderDemo() {
        try {
            Object[] arr = new String[3];
            arr[10] = "test"; // ArrayIndexOutOfBoundsException

        // βœ… Most specific first
        } catch (ArrayIndexOutOfBoundsException e) {
            System.err.println("Array index error: " + e.getMessage());
        } catch (RuntimeException e) {
            System.err.println("Runtime error: " + e.getMessage());
        } catch (Exception e) {
            System.err.println("General error: " + e.getMessage());
        }
    }

    // ── WRONG ORDER β€” compile error example ───────────
    // public static void wrongOrder() {
    //     try { throw new FileNotFoundException(); }
    //     catch (IOException e)           { }  // Catches parent
    //     catch (FileNotFoundException e) { }  // ❌ COMPILE ERROR!
    //     // FileNotFoundException already caught by IOException above
    // }
}

try-with-resources (Java 7+) β€” Automatic Resource Management

try-with-resources (Java 7+, JEP 334 improved in Java 9) automatically closes resources that implement AutoCloseable (or its subinterface Closeable) when the try block exits β€” regardless of whether it exits normally or via exception. It is the recommended pattern for all resource management in modern Java, replacing the verbose and error-prone try-finally pattern.

πŸ”‘
How try-with-resources Works

Resources declared in the parentheses after 'try' are automatically closed when the try block exits. Close() is called in REVERSE order of declaration (last opened, first closed). If both the try body and close() throw exceptions, the body's exception propagates and the close() exception becomes a SUPPRESSED exception β€” accessible via getSuppressed(). This is the key advantage over try-finally.

πŸ“‹
Multiple Resources

Multiple resources are separated by semicolons: try (Resource1 r1 = ...; Resource2 r2 = ...; Resource3 r3 = ...) { } Closed in reverse: r3.close(), r2.close(), r1.close(). Java 9+: effectively-final resources declared outside the try can be used: Resource res = getResource(); try (res) { } // No need to re-declare if effectively final

🏭
Implementing AutoCloseable

Any class implementing AutoCloseable (one method: void close() throws Exception) works with try-with-resources. Implementing Closeable (throws IOException only) is more specific. Custom resources like DB connections, HTTP clients, thread pools should implement AutoCloseable to participate in try-with-resources. This is how modern Java resource management is designed.

β˜• JavaTryWithResourcesDemo.java
import java.io.*;
import java.sql.*;

public class TryWithResourcesDemo {

    // ── OLD WAY: try-finally β€” verbose and error-prone ─
    public static String readFileOldWay(String path) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader(path));
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) sb.append(line).append("\n");
            return sb.toString();
        } catch (IOException e) {
            System.err.println("Read error: " + e.getMessage());
            return null;
        } finally {
            if (reader != null) {
                try { reader.close(); }           // Must try-catch AGAIN
                catch (IOException e) { /* suppress */ }
            }
        }
    }

    // ── NEW WAY: try-with-resources β€” clean, safe ──────
    public static String readFileNewWay(String path) {
        try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) sb.append(line).append("\n");
            return sb.toString();
        } catch (IOException e) {
            System.err.println("Read error: " + e.getMessage());
            return null;
        }
        // reader.close() called automatically β€” even if exception is thrown
    }

    // ── MULTIPLE RESOURCES β€” closed in reverse order ───
    public static void copyFile(String srcPath, String destPath) throws IOException {
        try (
            InputStream  in  = new FileInputStream(srcPath);   // Opens first
            OutputStream out = new FileOutputStream(destPath)  // Opens second
        ) {
            byte[] buffer = new byte[4096];
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                out.write(buffer, 0, bytesRead);
            }
            System.out.println("File copied successfully");
        }
        // out.close() called FIRST (reverse), then in.close()
        // Both closed even if exception occurs mid-copy
    }

    // ── CUSTOM AutoCloseable ───────────────────────────
    static class DatabaseTransaction implements AutoCloseable {
        private final Connection connection;
        private       boolean    committed;

        DatabaseTransaction(Connection conn) throws SQLException {
            this.connection = conn;
            this.connection.setAutoCommit(false);
            System.out.println("Transaction started");
        }

        public void commit() throws SQLException {
            connection.commit();
            committed = true;
            System.out.println("Transaction committed");
        }

        @Override
        public void close() throws SQLException {
            if (!committed) {
                connection.rollback(); // Auto-rollback if not committed
                System.out.println("Transaction rolled back");
            }
        }
    }

    public static void processOrder(Connection conn, Order order) throws SQLException {
        try (DatabaseTransaction tx = new DatabaseTransaction(conn)) {
            // Business logic
            saveOrder(conn, order);
            reserveInventory(conn, order);
            tx.commit(); // ← Only reaches here if no exception
        }
        // If saveOrder() or reserveInventory() throw: tx.close() rolls back
        // If commit() succeeds: tx.close() sees committed=true, skips rollback
    }

    // ── SUPPRESSED EXCEPTIONS ─────────────────────────
    public static void suppressedDemo() {
        try (
            AutoCloseable resource = () -> { throw new Exception("close() failed"); }
        ) {
            throw new RuntimeException("try block failed");
        } catch (Exception e) {
            System.out.println("Primary: " + e.getMessage()); // try block failed
            for (Throwable suppressed : e.getSuppressed()) {
                System.out.println("Suppressed: " + suppressed.getMessage()); // close() failed
            }
        }
    }

    static void saveOrder(Connection c, Order o) throws SQLException {}
    static void reserveInventory(Connection c, Order o) throws SQLException {}
    record Order(String id) {}
}

Custom Exceptions β€” Designing Domain-Specific Exceptions

Java's built-in exceptions cover general programming errors, but real applications need domain-specific exceptions that carry meaningful business context. A PaymentDeclinedException is far more informative than a generic RuntimeException. Custom exceptions make error handling code self-documenting, enable callers to react differently to different failure modes, and carry structured data about what went wrong.

πŸ—οΈ
Custom Exception Design Rules

1. Choose the right superclass: extend RuntimeException for unchecked (most domain exceptions), extend Exception for checked (when callers should be forced to handle). 2. Always provide BOTH constructors: (String message) and (String message, Throwable cause) β€” cause enables exception chaining. 3. Add domain-specific fields for structured context: errorCode, orderId, userId, retryAfterSeconds. 4. Make fields final β€” exceptions should be immutable. 5. Follow the naming convention: end with 'Exception'.

🎯
Exception Hierarchy for Your Domain

Group related exceptions under a base domain exception. This lets callers catch the entire category if they choose: ApplicationException (base) β”œβ”€β”€ ValidationException β”‚ β”œβ”€β”€ InvalidEmailException β”‚ └── InvalidPhoneException └── BusinessException β”œβ”€β”€ InsufficientFundsException └── AccountFrozenException Callers can catch ValidationException to handle all validation failures, or InsufficientFundsException to handle just that specific case.

πŸ“‹
When to Create Custom Exceptions

Create custom exceptions when: (1) The exception needs domain-specific context (orderId, errorCode, retryAfterMs). (2) Callers need to distinguish this failure from others β€” different recovery strategies. (3) The built-in exception names don't clearly communicate the business situation. (4) You're building a library or SDK β€” custom exceptions create a stable API contract. Don't create custom exceptions for every method β€” use IllegalArgumentException, IllegalStateException for basic validation.

β˜• JavaCustomExceptions.java
// ── BASE DOMAIN EXCEPTION ─────────────────────────────
public class ApplicationException extends RuntimeException {

    private final String errorCode;
    private final String userFriendlyMessage;

    public ApplicationException(String errorCode,
                                String techMessage,
                                String userMessage) {
        super(techMessage);
        this.errorCode          = errorCode;
        this.userFriendlyMessage = userMessage;
    }

    public ApplicationException(String errorCode,
                                String techMessage,
                                String userMessage,
                                Throwable cause) {
        super(techMessage, cause);
        this.errorCode          = errorCode;
        this.userFriendlyMessage = userMessage;
    }

    public String getErrorCode()          { return errorCode; }
    public String getUserFriendlyMessage() { return userFriendlyMessage; }
}

// ── VALIDATION EXCEPTIONS ─────────────────────────────
public class ValidationException extends ApplicationException {

    private final String fieldName;
    private final Object rejectedValue;

    public ValidationException(String fieldName, Object rejectedValue, String reason) {
        super("VALIDATION_ERROR",
              "Validation failed for '" + fieldName + "': " + reason,
              "Please check " + fieldName + " and try again.");
        this.fieldName     = fieldName;
        this.rejectedValue = rejectedValue;
    }

    public String getFieldName()    { return fieldName; }
    public Object getRejectedValue(){ return rejectedValue; }
}

// ── PAYMENT DOMAIN EXCEPTIONS ─────────────────────────
public class PaymentException extends ApplicationException {

    private final String transactionId;

    public PaymentException(String errorCode, String message,
                            String transactionId) {
        super(errorCode, message, "Payment could not be processed.");
        this.transactionId = transactionId;
    }

    public PaymentException(String errorCode, String message,
                            String transactionId, Throwable cause) {
        super(errorCode, message, "Payment could not be processed.", cause);
        this.transactionId = transactionId;
    }

    public String getTransactionId() { return transactionId; }
}

public class InsufficientFundsException extends PaymentException {

    private final double availableBalance;
    private final double requestedAmount;

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

    public double getAvailableBalance() { return availableBalance; }
    public double getRequestedAmount()  { return requestedAmount; }

    public double getShortfallAmount() {
        return requestedAmount - availableBalance;
    }
}

// ── RESOURCE NOT FOUND ────────────────────────────────
public class ResourceNotFoundException extends ApplicationException {

    private final String resourceType;
    private final Object resourceId;

    public ResourceNotFoundException(String resourceType, Object id) {
        super("RESOURCE_NOT_FOUND",
              resourceType + " not found with id: " + id,
              resourceType + " not found.");
        this.resourceType = resourceType;
        this.resourceId   = id;
    }

    public String getResourceType() { return resourceType; }
    public Object getResourceId()   { return resourceId; }
}

// ── USAGE ─────────────────────────────────────────────
// try {
//     paymentService.processPayment(userId, amount);
// } catch (InsufficientFundsException e) {
//     // Specific recovery: show available balance to user
//     ui.showError("Need β‚Ή" + e.getShortfallAmount() + " more to complete payment");
// } catch (PaymentException e) {
//     // General payment failure
//     ui.showError(e.getUserFriendlyMessage() + " [" + e.getErrorCode() + "]");
// } catch (ApplicationException e) {
//     // Any application-level error
//     logger.error("App error: {}", e.getErrorCode(), e);
// }

Exception Chaining & Cause β€” Preserving Error Context

Exception chaining (also called exception wrapping) is the practice of catching a low-level exception and re-throwing it as a higher-level, more meaningful exception β€” while preserving the original exception as the cause. This is critical for debuggability: without the cause, the root problem is hidden; with it, the full chain of errors is visible in the stack trace.

πŸ”—
Why Chain Exceptions

A service layer should not expose JDBC's SQLException to a UI layer. Instead, it catches SQLException and throws a domain-meaningful DatabaseException β€” but includes the original SQLException as the cause. The caller sees a BusinessException. But when debugging, getCause() retrieves the original SQLException with its full stack trace. This is the principle of 'translate, not suppress' β€” convert the exception type but never lose the original context.

πŸ“‹
Cause Constructor Pattern

Every well-designed exception class should have a constructor that accepts a Throwable cause: new DatabaseException("Failed to save user", sqlEx); This cause is passed to super(message, cause) in the exception class constructor, which stores it in Throwable. getCause() returns it. printStackTrace() shows the entire chain with 'Caused by:' labels.

πŸ”
Suppressed Exceptions

Java 7+ introduced addSuppressed(Throwable) for cases where multiple exceptions occur simultaneously β€” most commonly in try-with-resources where both the try body and close() throw. The primary exception propagates; the close() exception is suppressed but attached. getSuppressed() retrieves them. They appear in the stack trace as 'Suppressed:' entries, not as 'Caused by:'.

β˜• JavaExceptionChainingDemo.java
import java.sql.*;

// ── EXCEPTION CHAINING ────────────────────────────────
public class UserRepository {

    // ❌ BAD: Swallowing the original exception
    public User findUserBad(String userId) {
        try {
            return queryDatabase(userId);
        } catch (SQLException e) {
            // Original exception lost β€” impossible to diagnose root cause
            throw new RuntimeException("Database error");
        }
    }

    // ❌ ALSO BAD: Logging and rethrowing same type
    public User findUserAlsoBad(String userId) throws SQLException {
        try {
            return queryDatabase(userId);
        } catch (SQLException e) {
            // Logs it here AND caller might log again β€” duplicate logs
            System.err.println("DB error: " + e.getMessage());
            throw e; // Same exception β€” OK, but causes are lost if wrapped later
        }
    }

    // βœ… CORRECT: Wrap with cause β€” translate level, preserve context
    public User findUser(String userId) {
        try {
            return queryDatabase(userId);
        } catch (SQLException e) {
            // Higher-level exception with original cause preserved
            throw new DatabaseException(
                "Failed to retrieve user with id: " + userId, e);
            // ↑ 'e' stored as cause β€” getCause() returns it
        }
    }

    // ── READING THE CHAIN ─────────────────────────────
    public static void demonstrateChain() {
        try {
            // Simulate a chain: SocketException β†’ SQLException β†’ DatabaseException
            java.net.SocketException socketEx =
                new java.net.SocketException("Connection reset by peer");

            SQLException sqlEx = new SQLException(
                "Query execution failed", socketEx);

            DatabaseException dbEx = new DatabaseException(
                "User lookup failed", sqlEx);

            throw dbEx;

        } catch (DatabaseException e) {
            System.out.println("Top level: " + e.getMessage());

            // Walk the cause chain
            Throwable cause = e.getCause();
            int depth = 1;
            while (cause != null) {
                System.out.printf("  Cause[%d]: %s β€” %s%n",
                    depth++, cause.getClass().getSimpleName(), cause.getMessage());
                cause = cause.getCause();
            }
        }
        // Output:
        // Top level: User lookup failed
        //   Cause[1]: SQLException β€” Query execution failed
        //   Cause[2]: SocketException β€” Connection reset by peer
    }

    private User queryDatabase(String id) throws SQLException { return new User(id); }
    record User(String id) {}
}

class DatabaseException extends RuntimeException {
    public DatabaseException(String msg) { super(msg); }
    public DatabaseException(String msg, Throwable cause) { super(msg, cause); }
}

Exception Propagation β€” The Call Stack Journey

When an exception is thrown and not caught in the current method, it propagates up the call stack β€” the current method is exited immediately, its local variables are discarded, and the exception is passed to the calling method. This continues frame by frame until either a matching catch block is found or the exception reaches the JVM's main thread handler and the thread terminates.

πŸ“Ά
Propagation Rules

Unchecked exceptions propagate automatically β€” no declaration needed at each level. Checked exceptions require either catch or throws declaration at each stack frame. When a method declares 'throws CheckedException', it is telling the compiler: 'I'm not handling this, my caller must.' The exception propagates until: (1) a catch block matches it, (2) it reaches main() and is uncaught, or (3) it reaches a thread's uncaught exception handler.

πŸ”
Reading a Stack Trace

A stack trace reads BOTTOM-UP for origin β€” the bottom line is where the exception originated; each line above it is a calling method. The top line is where the exception was caught (or the thread that died). Each line shows: ClassName.methodName(FileName.java:lineNumber). 'Caused by:' sections show the exception chain. 'Suppressed:' shows close() exceptions from try-with-resources.

🎯
Catch at the Right Level

The correct level to catch an exception is the level that has enough CONTEXT to handle it meaningfully. A data access method shouldn't catch its own SQLException and show a UI dialog β€” it has no UI context. The UI layer should catch and display appropriately. Repository layer: translate technical exceptions to domain exceptions. Service layer: handle business rule violations. Presentation layer: translate to user-friendly messages.

β˜• JavaExceptionPropagationDemo.java
public class ExceptionPropagationDemo {

    // ── PROPAGATION CHAIN ─────────────────────────────
    // Level 3 β€” deepest β€” throws, does not catch
    static void level3() throws java.io.IOException {
        System.out.println("β†’ Entering level3");
        throw new java.io.IOException("Disk read failed at sector 47");
    }

    // Level 2 β€” middle β€” declares throws, does not catch
    static void level2() throws java.io.IOException {
        System.out.println("β†’ Entering level2");
        level3();  // Exception propagates up from level3
        System.out.println("← Leaving level2"); // Never reached
    }

    // Level 1 β€” catches the exception
    static void level1() {
        System.out.println("β†’ Entering level1");
        try {
            level2();
            System.out.println("← Leaving level2 (no exception)"); // Never reached
        } catch (java.io.IOException e) {
            System.out.println("β˜… Caught in level1: " + e.getMessage());
        }
        System.out.println("← Leaving level1 normally");
    }

    // Output when level1() is called:
    // β†’ Entering level1
    // β†’ Entering level2
    // β†’ Entering level3
    // β˜… Caught in level1: Disk read failed at sector 47
    // ← Leaving level1 normally

    // ── LAYERED ARCHITECTURE PROPAGATION ──────────────
    // This is how enterprise apps are SUPPOSED to handle exceptions

    // Data Access Layer β€” translates SQL to domain exception
    static User findUserInDb(String id) {
        try {
            // Simulate SQL call
            if (id == null) throw new java.sql.SQLException("Null parameter");
            return new User(id, "Test User");
        } catch (java.sql.SQLException e) {
            // Translate β€” don't expose JDBC to service layer
            throw new DatabaseException("DB lookup failed for user: " + id, e);
        }
    }

    // Service Layer β€” translates domain exception to business exception
    static User getUserService(String id) {
        try {
            return findUserInDb(id);
        } catch (DatabaseException e) {
            // Service-level context added
            throw new ServiceException("Cannot retrieve user profile", e);
        }
    }

    // Controller Layer β€” translates to HTTP response or UI message
    static String getUserController(String id) {
        try {
            User user = getUserService(id);
            return "200 OK: " + user;
        } catch (ServiceException e) {
            // Log with full cause chain, return user-friendly message
            System.err.println("Service error: " + e.getMessage());
            System.err.println("Root cause: " + e.getCause().getCause().getMessage());
            return "500 Internal Error: Unable to load profile";
        }
    }

    record User(String id, String name) {}
    static class ServiceException  extends RuntimeException {
        ServiceException(String m, Throwable c) { super(m, c); }
    }
}

Common Java Exceptions Explained β€” What Causes Them & How to Fix

These are the exceptions you will encounter most frequently in Java development. Understanding exactly what triggers each one β€” and how to prevent or handle it β€” saves hours of debugging time.

ExceptionRoot CausePrevention / Fix
NullPointerExceptionCalling a method or accessing a field on a null referenceNull checks, Optional<T>, Objects.requireNonNull(), @NonNull annotations
ArrayIndexOutOfBoundsExceptionAccessing arr[i] where i < 0 or i >= arr.lengthCheck arr.length before indexing; use enhanced for loops
ClassCastExceptionCasting an object to a type it is not (fails instanceof check)Check instanceof before casting; use generics to avoid casts
NumberFormatExceptionInteger.parseInt("abc") β€” string is not a valid numberValidate format before parsing; use try-catch for user input
StackOverflowErrorInfinite recursion β€” no base case or base case never reachedVerify base case exists and is reachable; use iteration for deep recursion
OutOfMemoryErrorJVM heap exhausted β€” too many objects or memory leakProfile with VisualVM; fix leaks; increase heap with -Xmx if justified
ConcurrentModificationExceptionModifying a collection while iterating it with IteratorUse Iterator.remove(); copy before modifying; use CopyOnWriteArrayList
IllegalArgumentExceptionMethod received an argument that violates its contractValidate inputs at method start; document preconditions clearly
IllegalStateExceptionMethod called at a wrong time or in wrong object stateCheck object state before calling; use state machine patterns
UnsupportedOperationExceptionOperation not supported by this implementationCheck if collection is modifiable; use mutable collection types
β˜• JavaCommonExceptionsDemo.java
import java.util.*;

public class CommonExceptionsDemo {

    // NullPointerException β€” and how to prevent it
    public static void npeDemo() {
        // ❌ NPE: calling on null
        String s = null;
        // s.length(); // Throws NullPointerException

        // βœ… Prevention 1: explicit null check
        if (s != null) System.out.println(s.length());

        // βœ… Prevention 2: Optional
        Optional.ofNullable(s).ifPresent(str -> System.out.println(str.length()));

        // βœ… Prevention 3: Objects.requireNonNull for constructor params
        String name = Objects.requireNonNull(s, "name cannot be null");
    }

    // ConcurrentModificationException β€” modifying during iteration
    public static void cmeDemo() {
        List<String> list = new ArrayList<>(List.of("A", "B", "C", "D"));

        // ❌ ConcurrentModificationException
        // for (String s : list) {
        //     if (s.equals("B")) list.remove(s);
        // }

        // βœ… Fix 1: Iterator.remove()
        Iterator<String> it = list.iterator();
        while (it.hasNext()) {
            if (it.next().equals("B")) it.remove();
        }

        // βœ… Fix 2: removeIf (Java 8+) β€” cleanest
        list.removeIf(s -> s.equals("C"));

        System.out.println(list); // [A, D]
    }

    // StackOverflowError β€” missing base case
    public static int badFactorial(int n) {
        // ❌ No base case β€” infinite recursion
        return n * badFactorial(n - 1); // StackOverflowError
    }

    public static long goodFactorial(int n) {
        if (n < 0) throw new IllegalArgumentException("n must be >= 0");
        if (n <= 1) return 1; // βœ… Base case
        return n * goodFactorial(n - 1);
    }

    // NumberFormatException β€” parsing user input
    public static Optional<Integer> safeParseInt(String s) {
        try {
            return Optional.of(Integer.parseInt(s));
        } catch (NumberFormatException e) {
            return Optional.empty(); // Return empty Optional instead of throwing
        }
    }
    // Usage: safeParseInt("42") β†’ Optional[42]
    //        safeParseInt("abc") β†’ Optional.empty
}

Common Mistakes & Pitfalls β€” Errors That Trip Everyone Up

These exception handling mistakes appear consistently in Java beginner and intermediate code. Each one either hides real problems, corrupts error context, or leads to resource leaks that only surface in production.

β˜• JavaExceptionMistakes.java
// ❌ MISTAKE 1: Empty catch block β€” swallowing the exception silently
try {
    processPayment();
} catch (Exception e) {
    // ❌ Exception silently disappears β€” impossible to diagnose in production
}
// βœ… Fix: always at minimum log it
try {
    processPayment();
} catch (Exception e) {
    logger.error("Payment processing failed", e); // Log with full stack trace
    throw e; // Re-throw or throw a domain exception
}

// ❌ MISTAKE 2: Catching Exception (too broad) instead of specific types
try {
    int value = Integer.parseInt(userInput);
    File f = new File(filePath);
} catch (Exception e) {
    System.out.println("Something went wrong"); // Which thing? Can't tell
}
// βœ… Fix: specific catch blocks
try {
    int value = Integer.parseInt(userInput);
    File f = new File(filePath);
} catch (NumberFormatException e) {
    System.out.println("Invalid number format: " + e.getMessage());
} catch (SecurityException e) {
    System.out.println("File access denied: " + e.getMessage());
}

// ❌ MISTAKE 3: Catching exception without the cause β€” losing root cause
try {
    executeQuery();
} catch (SQLException e) {
    throw new RuntimeException("Database error"); // ❌ Cause lost!
}
// βœ… Fix: always pass cause to new exception
catch (SQLException e) {
    throw new DatabaseException("Query failed: " + e.getMessage(), e); // βœ…
}

// ❌ MISTAKE 4: Using exceptions for normal control flow
public boolean userExists(String id) {
    try {
        getUser(id); // throws UserNotFoundException if not found
        return true;
    } catch (UserNotFoundException e) {
        return false; // ❌ Exception as conditional β€” expensive, poor design
    }
}
// βœ… Fix: return Optional or design API to check first
public Optional<User> findUser(String id) { /* ... */ return Optional.empty(); }

// ❌ MISTAKE 5: re-declaring unchecked exceptions in throws
// Not an error, but misleading and unnecessary
public void setAge(int age) throws NullPointerException { // ❌ Unnecessary
    if (age < 0) throw new IllegalArgumentException("Negative age");
}
// βœ… Fix: don't declare unchecked exceptions in throws (unless documenting)

// ❌ MISTAKE 6: Finally block with return statement
public int getValue() {
    try {
        return 1;
    } finally {
        return 2; // ❌ Overrides try's return AND swallows any exception!
    }
}
// Always returns 2, any exception in try is silently swallowed

// ❌ MISTAKE 7: Not closing resources in finally (before Java 7)
// or not using try-with-resources (Java 7+)
Connection conn = getConnection();
try {
    execute(conn);
} catch (SQLException e) {
    log(e);
    throw new RuntimeException(e);
    // ❌ conn.close() never reached β€” connection leak!
}
// βœ… Fix: try-with-resources
try (Connection conn2 = getConnection()) {
    execute(conn2);
} catch (SQLException e) {
    throw new DatabaseException("Execution failed", e);
} // conn2.close() called automatically

Bad Practices & Anti-Patterns β€” What Senior Developers Reject

These exception handling anti-patterns are among the top reasons for failed code reviews in professional Java teams. Each one either hides bugs, corrupts logs, leaks resources, or makes systems impossible to diagnose in production.

🚫
Pokemon Exception Handling β€” Gotta Catch 'Em All

'catch (Exception e) {}' β€” catching everything and doing nothing. Named after the game's 'catch all PokΓ©mon' theme. This is the single most destructive exception handling pattern: bugs disappear without a trace, systems fail silently, and production issues become nearly impossible to diagnose. Rule: if you catch an exception, you must either handle it meaningfully or re-throw (with cause). An empty catch block should be a code review blocker.

🚫
Log-and-Throw (Duplicate Logging)

Logging the exception AND throwing it, causing the same error to appear multiple times in the logs β€” at every layer of the stack. Production log files become noisy, making real issues hard to spot. Fix: log at the TOP-MOST handler level only (where you can fully handle it). Let exceptions propagate silently through intermediate layers (just re-throw or chain). The boundary between application code and framework (Spring MVC, JAX-RS) is where logging belongs.

🚫
Using Exceptions for Control Flow

Throwing exceptions for expected, normal conditions β€” 'throw UserNotFoundException' to signal 'user not found' in a lookup. Exceptions have a significant performance cost (capturing the stack trace). They make code harder to read. They are meant for EXCEPTIONAL conditions β€” errors outside normal program flow. Use Optional<T>, null (carefully), or boolean flags for normal conditions. Reserve exceptions for genuinely exceptional situations.

🚫
Catching Throwable or Error

Catching Throwable or Error (OutOfMemoryError, StackOverflowError) in application code is almost always wrong. These errors signal JVM-level failures. Catching them can prevent proper JVM cleanup, hide catastrophic failures, make the system appear healthy when it's in an unrecoverable state. Spring's @ExceptionHandler and Tomcat's error pages handle unrecoverable errors at the framework level. Application code should only catch Exception at most.

🚫
Overusing Checked Exceptions

Declaring every possible checked exception in every method signature creates 'exception pollution' β€” methods that do nothing but pass checked exceptions up the call stack. This forces callers to handle exceptions they cannot meaningfully address. The Spring framework famously wraps all SQL exceptions in unchecked DataAccessException precisely because most callers cannot recover from a database failure. Design principle: checked exceptions for conditions callers CAN recover from; unchecked for everything else.

🚫
Missing Exception Context

throw new RuntimeException("error") β€” no context, no cause, no identifying information. When this appears in production logs, it is nearly impossible to diagnose. Always include: what failed, what the input/state was, and the cause exception. Good: 'throw new OrderProcessingException("Failed to save order #" + orderId + " for customer " + customerId, sqlException)'. Bad: 'throw new RuntimeException("Database error")'. Context is everything in production debugging.

Real-World Production Code Examples β€” Exception Handling in Context

The following examples demonstrate professional exception handling patterns from real enterprise Java applications β€” showing layered exception translation, custom exception hierarchies, and production-ready resource management.

β˜• JavaPaymentService.java β€” Enterprise Exception Handling
package com.techsustainify.payment.service;

import java.sql.*;
import java.util.Optional;
import java.util.logging.*;

public class PaymentService {

    private static final Logger log = Logger.getLogger(PaymentService.class.getName());

    private final PaymentRepository     paymentRepo;
    private final PaymentGatewayClient  gatewayClient;
    private final NotificationService   notificationService;
    private final AuditService          auditService;

    public PaymentService(PaymentRepository repo,
                          PaymentGatewayClient gateway,
                          NotificationService notifications,
                          AuditService audit) {
        this.paymentRepo       = java.util.Objects.requireNonNull(repo, "repo required");
        this.gatewayClient     = java.util.Objects.requireNonNull(gateway, "gateway required");
        this.notificationService = notifications;
        this.auditService        = audit;
    }

    /**
     * Processes a payment with full error handling.
     * Demonstrates: validation, checked/unchecked translation,
     * try-with-resources, exception chaining, and layered handling.
     */
    public PaymentResult processPayment(PaymentRequest request) {

        // Input validation β€” unchecked, detected early
        validatePaymentRequest(request);

        String txId = generateTransactionId();
        auditService.logAttempt(txId, request);

        try {
            // Gateway call β€” may throw GatewayException (unchecked)
            GatewayResponse response = gatewayClient.charge(
                request.getAmount(),
                request.getCurrency(),
                request.getPaymentMethod()
            );

            if (!response.isApproved()) {
                // Business failure β€” NOT an exception, just a result
                auditService.logDecline(txId, response.getDeclineCode());
                return PaymentResult.declined(response.getDeclineCode(),
                                             response.getDeclineMessage());
            }

            // Persist in DB using try-with-resources
            savePaymentRecord(txId, request, response);

            // Async notification β€” failures should NOT affect payment result
            notifyAsyncSafely(request, response, txId);

            auditService.logSuccess(txId);
            return PaymentResult.success(txId, response.getAuthCode());

        } catch (GatewayTimeoutException e) {
            // Retriable β€” caller can retry after delay
            log.log(Level.WARNING, "Payment gateway timeout for tx: " + txId, e);
            auditService.logFailure(txId, "GATEWAY_TIMEOUT");
            throw new RetryablePaymentException(
                "Payment gateway timed out β€” please retry",
                txId, 30, e);

        } catch (GatewayException e) {
            // Non-retriable gateway error
            log.log(Level.SEVERE, "Gateway error for tx: " + txId, e);
            auditService.logFailure(txId, "GATEWAY_ERROR");
            throw new PaymentProcessingException(
                "Payment gateway error: " + e.getMessage(),
                txId, e);

        } catch (DatabaseException e) {
            // Payment may have succeeded at gateway but not saved β€” critical
            log.log(Level.SEVERE,
                    "CRITICAL: Payment processed by gateway but save failed. tx=" + txId, e);
            auditService.logCriticalFailure(txId, e);
            // Alert ops team immediately
            throw new PaymentProcessingException(
                "Payment record save failed β€” manual intervention required. tx=" + txId,
                txId, e);
        }
    }

    private void validatePaymentRequest(PaymentRequest req) {
        if (req == null)
            throw new IllegalArgumentException("PaymentRequest cannot be null");
        if (req.getAmount() <= 0)
            throw new IllegalArgumentException("Amount must be positive: " + req.getAmount());
        if (req.getCurrency() == null || req.getCurrency().isBlank())
            throw new IllegalArgumentException("Currency is required");
        if (req.getPaymentMethod() == null)
            throw new IllegalArgumentException("Payment method is required");
    }

    private void savePaymentRecord(String txId,
                                    PaymentRequest req,
                                    GatewayResponse resp) {
        try (Connection conn = paymentRepo.getConnection()) {
            paymentRepo.save(conn, txId, req, resp);
        } catch (SQLException e) {
            // Translate JDBC exception to domain exception with full context
            throw new DatabaseException(
                "Failed to save payment record for tx: " + txId, e);
        } catch (Exception e) {
            throw new DatabaseException(
                "Unexpected DB error saving payment tx: " + txId, e);
        }
    }

    /** Notification failures must never affect payment success */
    private void notifyAsyncSafely(PaymentRequest req,
                                    GatewayResponse resp, String txId) {
        try {
            notificationService.sendPaymentConfirmation(req, resp, txId);
        } catch (Exception e) {
            // βœ… Intentional: notification failure does NOT fail the payment
            log.log(Level.WARNING, "Notification failed for tx: " + txId +
                    " β€” payment still successful", e);
        }
    }

    private String generateTransactionId() {
        return "TXN-" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16);
    }
}
β˜• JavaFileProcessor.java β€” try-with-resources Production Pattern
package com.techsustainify.file;

import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.logging.*;

public class FileProcessor {

    private static final Logger log = Logger.getLogger(FileProcessor.class.getName());
    private static final int    BUFFER_SIZE = 8192;

    /**
     * Reads a CSV file, processes each line, writes results.
     * Demonstrates: try-with-resources chaining, per-line error recovery,
     * and structured error reporting without aborting the whole batch.
     */
    public BatchResult processCSV(Path inputPath, Path outputPath) {
        List<String> errors       = new ArrayList<>();
        int          successCount = 0;
        int          lineNumber   = 0;

        // Both reader and writer auto-closed β€” writer closed FIRST (reverse order)
        try (
            BufferedReader reader = Files.newBufferedReader(inputPath);
            BufferedWriter writer = Files.newBufferedWriter(outputPath)
        ) {
            String line;
            while ((line = reader.readLine()) != null) {
                lineNumber++;
                final int currentLine = lineNumber; // Effectively final for lambda

                try {
                    // Per-line processing β€” errors DON'T abort the entire batch
                    String processed = processLine(line);
                    writer.write(processed);
                    writer.newLine();
                    successCount++;

                } catch (DataParsingException e) {
                    // Record error but continue with next line
                    String errorEntry = String.format(
                        "Line %d: %s β€” %s", currentLine, e.getRawData(), e.getMessage());
                    errors.add(errorEntry);
                    log.warning(errorEntry);

                } catch (Exception e) {
                    // Unexpected per-line error β€” record and continue
                    String errorEntry = String.format(
                        "Line %d: Unexpected error β€” %s", currentLine, e.getMessage());
                    errors.add(errorEntry);
                    log.log(Level.WARNING, errorEntry, e);
                }
            }

        } catch (NoSuchFileException e) {
            // Input file doesn't exist β€” fail fast, nothing to process
            throw new FileProcessingException(
                "Input file not found: " + inputPath, e);

        } catch (AccessDeniedException e) {
            throw new FileProcessingException(
                "Permission denied reading: " + inputPath +
                " or writing: " + outputPath, e);

        } catch (IOException e) {
            throw new FileProcessingException(
                "IO error during batch processing", e);
        }

        int totalLines = lineNumber;
        log.info(String.format(
            "Batch complete: %d/%d lines processed successfully, %d errors",
            successCount, totalLines, errors.size()));

        return new BatchResult(successCount, errors, totalLines);
    }

    private String processLine(String line) {
        if (line == null || line.isBlank())
            return "";
        String[] parts = line.split(",");
        if (parts.length < 3)
            throw new DataParsingException(
                "Expected 3+ columns, found " + parts.length, line);
        return String.join("|", parts).toUpperCase();
    }

    record BatchResult(int successCount, List<String> errors, int totalLines) {
        boolean hasErrors() { return !errors.isEmpty(); }
        double  successRate() { return totalLines == 0 ? 0 :
                                    (double) successCount / totalLines * 100; }
    }
}

class DataParsingException extends RuntimeException {
    private final String rawData;
    DataParsingException(String msg, String rawData) {
        super(msg); this.rawData = rawData;
    }
    String getRawData() { return rawData; }
}

class FileProcessingException extends RuntimeException {
    FileProcessingException(String msg, Throwable cause) { super(msg, cause); }
}

Exception Flow Diagram β€” What Happens When an Exception Is Thrown

This flowchart shows the complete journey of an exception from the moment it is thrown to its final resolution.

πŸ’₯ Exception thrownthrow new X() or JVM creates X
check
πŸ” Matching catch in same method?check each catch block top-to-bottom
YES β€” match found
βœ… catch block executeshandler runs, exception handled
after catch
πŸ”’ finally block?if present, always executes
YES β€” has finally
🧹 finally executescleanup: close resources, unlock
propagate
πŸ“€ Propagate to callercurrent method exits, exception moves up
check stack
πŸ” More frames in call stack?is there a calling method?
YES β€” check caller
πŸ’€ Uncaught β€” thread terminatesJVM prints stack trace, thread dies
➑️ Program continuesafter the catch block or finally

Code Execution Flow β€” from source to output

Java Exception Handling Interview Questions β€” Beginner to Advanced

These questions are consistently asked in Java fresher and experienced developer interviews, campus placements, and OCPJP certification exams.

Practice Questions β€” Test Your Exception Handling Knowledge

Attempt each question independently before reading the answer β€” active recall significantly improves retention and understanding.

1. What is the output? public class Q1 { public static void main(String[] args) { try { System.out.println("A"); int x = 10 / 0; System.out.println("B"); } catch (ArithmeticException e) { System.out.println("C"); } finally { System.out.println("D"); } System.out.println("E"); } }

Easy

2. What is the output? public class Q2 { static int method() { try { return 1; } finally { return 2; } } public static void main(String[] args) { System.out.println(method()); } }

Easy

3. Will this compile? Explain why or why not. public void method() { try { throw new IOException("test"); } catch (Exception e) { System.out.println("caught"); } catch (IOException e) { System.out.println("io"); } }

Easy

4. Design a custom exception hierarchy for an e-commerce order system with at least 3 levels. Show constructors and a usage example.

Medium

5. What is the output? Explain the exception chaining. public class Q5 { public static void method3() throws Exception { throw new RuntimeException("Root cause"); } public static void method2() { try { method3(); } catch (Exception e) { throw new IllegalStateException("Method2 failed", e); } } public static void main(String[] args) { try { method2(); } catch (IllegalStateException e) { System.out.println(e.getMessage()); System.out.println(e.getCause().getMessage()); } } }

Medium

6. Rewrite this code using try-with-resources and fix the resource leak: public String readAndProcess(String path) { FileInputStream fis = null; BufferedReader br = null; try { fis = new FileInputStream(path); br = new BufferedReader(new InputStreamReader(fis)); return br.readLine(); } catch (IOException e) { return null; } finally { try { if (br != null) br.close(); } catch (IOException ignore) {} } // BUG: fis is never closed if br creation fails! }

Medium

7. What is the difference between suppressed exceptions and cause exceptions? Give an example showing both.

Hard

8. Write a generic retry utility method that retries a Supplier<T> up to maxRetries times, with exponential backoff, and throws a custom RetryExhaustedException if all retries fail.

Hard

Conclusion β€” Exception Handling: The Mark of Production-Ready Code

Exception handling is not an afterthought β€” it is a first-class design concern. The difference between code that works in a demo and code that works reliably in production is often entirely in how exceptions are handled. Programs that crash on the first bad input, silently swallow errors, or produce unhelpful 'Something went wrong' messages are not production-ready, regardless of how correct the happy path is.

Professional Java developers think about exceptions at the design level: What can go wrong here? Can the caller recover? Should this be checked or unchecked? What context does the exception need to carry for diagnosis? They use try-with-resources universally for resources, chain exceptions to preserve root causes, create meaningful custom exception hierarchies, and catch at the layer that has sufficient context to handle meaningfully.

ConceptKey RuleExample
try-catch-finallytry: risky code; catch: handle; finally: always cleanuptry { conn.query(); } finally { conn.close(); }
throwCreates and throws an exception β€” action statementthrow new IllegalArgumentException("age < 0");
throwsDeclares checked exceptions in method signaturevoid readFile() throws IOException
Checked exceptionCompiler-enforced β€” caller must catch or declareIOException, SQLException
Unchecked exceptionNo compiler enforcement β€” programming errorsNullPointerException, IllegalArgumentException
Multi-catch (Java 7+)Specific before general; | for same-handling typescatch (IOException | SQLException e)
try-with-resourcesAuto-close AutoCloseable β€” reverse order; suppressed exceptionstry (Connection c = getConn()) { ... }
Custom exceptionExtend RuntimeException; add context fields; always provide cause ctorclass PaymentException extends RuntimeException
Exception chainingWrap with cause β€” translate level, never lose root causethrow new ServiceEx("msg", originalException)
Anti-patternsNever: empty catch, log+rethrow, exceptions for flow, catch Errorcatch (Exception e) {} ← NEVER

Your next step: Java Collections Framework β€” where you will learn to work with Lists, Sets, Maps, and Queues, and see how exception handling integrates with collection operations to build robust, data-processing code. β˜•

Frequently Asked Questions β€” Java Exception Handling