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.
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'.
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.
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.
/*
* 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.
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.
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).
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.
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.
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.
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 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.
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.
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.
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.
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 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
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.
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.
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'.
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.
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.
// ββ 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.
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.
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.
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:'.
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.
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.
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.
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.
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.
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.
// β 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 automaticallyBad 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.
'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.
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.
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 (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.
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.
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.
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);
}
}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.
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"); } }
Easy2. 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()); } }
Easy3. 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"); } }
Easy4. Design a custom exception hierarchy for an e-commerce order system with at least 3 levels. Show constructors and a usage example.
Medium5. 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()); } } }
Medium6. 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! }
Medium7. What is the difference between suppressed exceptions and cause exceptions? Give an example showing both.
Hard8. 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.
HardConclusion β 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.
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. β