Java Try-Catch โ Exception Handling, Custom Exceptions & Best Practices
A complete guide to Java exception handling โ exception hierarchy, try-catch-finally, multi-catch, try-with-resources, throw vs throws, custom exception design, exception chaining, anti-patterns, and production-ready patterns used in enterprise Java.
Last Updated
March 2026
Read Time
26 min
Level
Intermediate
Chapter
23 of 35
What is an Exception in Java?
An exception in Java is an event that disrupts the normal flow of program execution at runtime. It is not a compile-time problem โ the code compiles perfectly โ but when the program runs, an unexpected condition arises: a file is missing, a network times out, a user passes a null where an object was expected, or the program attempts to divide by zero. Without a mechanism to handle these, the program simply crashes with a stack trace.
Java models exceptions as objects. When an exception occurs, the JVM creates an instance of the appropriate exception class, populates it with a message, the current stack trace, and optionally a cause โ and then throws it. The runtime searches the call stack for a matching catch block. If one is found, execution jumps to that handler. If none is found anywhere in the call stack, the default exception handler terminates the thread and prints the stack trace.
Java's exception handling has two core goals: separation of concerns (keep error-handling code away from business logic) and structured recovery (give the program a chance to clean up resources and communicate failure meaningfully, rather than simply crashing). Done well, exception handling makes code significantly more readable, maintainable, and resilient.
Exception Hierarchy โ The Throwable Family Tree
Every throwable object in Java descends from java.lang.Throwable. Understanding the hierarchy is essential โ it explains why some exceptions must be caught, why others cannot be caught (or shouldn't be), and how a catch(Exception e) block differs from catch(Throwable t).
java.lang.Throwable โ Root of everything throwable
โโโ java.lang.Error โ JVM-level problems โ do NOT catch
โ โโโ OutOfMemoryError โ JVM ran out of heap memory
โ โโโ StackOverflowError โ Infinite recursion consumed call stack
โ โโโ VirtualMachineError โ Severe JVM internal error
โ โโโ AssertionError โ assert statement failed
โ โโโ LinkageError โ Class loading / linking problem
โ โโโ NoClassDefFoundError โ Class found at compile time but not runtime
โ
โโโ java.lang.Exception โ Application-level problems
โโโ java.lang.RuntimeException โ UNCHECKED โ compiler does not enforce
โ โโโ NullPointerException โ Dereference of null reference
โ โโโ ArrayIndexOutOfBoundsException โ Array index out of range
โ โโโ ClassCastException โ Invalid type cast
โ โโโ ArithmeticException โ e.g., division by zero
โ โโโ IllegalArgumentException โ Method received illegal argument
โ โโโ IllegalStateException โ Method called at wrong time
โ โโโ NumberFormatException โ Invalid string โ number conversion
โ โโโ IndexOutOfBoundsException โ General index out of range
โ โ โโโ StringIndexOutOfBoundsException
โ โโโ UnsupportedOperationException โ Operation not supported
โ โโโ ConcurrentModificationException โ Collection modified during iteration
โ โโโ NoSuchElementException โ Iterator has no more elements
โ
โโโ IOException โ CHECKED โ I/O operation failed
โ โโโ FileNotFoundException โ File does not exist
โ โโโ SocketException โ Network socket error
โโโ SQLException โ CHECKED โ Database operation failed
โโโ ParseException โ CHECKED โ Parsing failed
โโโ CloneNotSupportedException โ CHECKED โ clone() not supported
โโโ InterruptedException โ CHECKED โ Thread was interruptedChecked vs Unchecked Exceptions โ The Compiler's Contract
Java's most debated design decision is the checked exception system. Checked exceptions force the compiler to ensure every caller either handles or explicitly propagates certain exceptions. The philosophy: some failures (missing file, network unavailable, DB connection failed) are foreseeable โ the API designer says 'caller: you MUST think about this failure path.' Unchecked exceptions represent programming errors or unrecoverable states that callers typically cannot fix.
import java.io.*;
import java.sql.*;
public class CheckedVsUnchecked {
// โ
Checked โ IOException must be caught or declared
public String readFileContent(String path) throws IOException {
// FileNotFoundException is a checked exception (subclass of IOException)
// If we don't throw IOException, we MUST catch it here โ compiler enforces this
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
StringBuilder content = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
content.append(line).append("\n");
}
return content.toString();
}
// Removing 'throws IOException' without a try-catch โ COMPILE ERROR
}
// โ
Unchecked โ IllegalArgumentException doesn't require declaration
public double divide(double numerator, double denominator) {
if (denominator == 0) {
// No 'throws ArithmeticException' needed in signature
throw new IllegalArgumentException(
"Denominator cannot be zero โ received: " + denominator);
}
return numerator / denominator;
}
// โ
Both in the same method
public void saveUserToDatabase(User user) throws SQLException {
// Precondition check โ unchecked (programming contract)
if (user == null) {
throw new IllegalArgumentException("User cannot be null");
}
if (user.getName() == null || user.getName().isBlank()) {
throw new IllegalArgumentException("User name cannot be blank");
}
// Database failure โ checked (external, recoverable)
try (Connection conn = getConnection()) {
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (name, email) VALUES (?, ?)");
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
ps.executeUpdate();
}
// SQLException propagated to caller who can decide: retry, log, alert
}
private Connection getConnection() throws SQLException {
return DriverManager.getConnection("jdbc:...", "user", "pass");
}
}try-catch Block โ Basic Syntax & Execution Flow
The try-catch block is the foundation of Java exception handling. Code that might throw an exception goes inside the try block. If an exception is thrown, the JVM immediately stops executing the try block at the point of the exception (remaining statements in the try block are skipped) and searches for a matching catch block. If a match is found, that handler executes and then program execution continues normally after the entire try-catch construct.
public class TryCatchBasics {
public static void main(String[] args) {
// โ
Basic try-catch
try {
int result = 10 / 0; // Throws ArithmeticException
System.out.println(result); // โ NEVER reached
} catch (ArithmeticException e) {
System.out.println("Cannot divide by zero: " + e.getMessage());
}
System.out.println("Program continues..."); // โ Always reached
// โ
The exception object carries useful info
try {
String text = null;
int length = text.length(); // Throws NullPointerException
} catch (NullPointerException e) {
System.out.println("Exception type: " + e.getClass().getName());
System.out.println("Message: " + e.getMessage());
System.out.println("Stack trace:");
e.printStackTrace(); // Prints to System.err
}
// โ
Catching a superclass catches all subclasses
try {
int[] arr = new int[3];
arr[10] = 99; // Throws ArrayIndexOutOfBoundsException
} catch (RuntimeException e) {
// Catches ArrayIndexOutOfBoundsException and ALL RuntimeExceptions
System.out.println("Caught: " + e.getClass().getSimpleName());
}
// โ
When no exception is thrown โ catch block is SKIPPED
try {
int result = 10 / 2; // No exception
System.out.println("Result: " + result); // 5 โ this prints
} catch (ArithmeticException e) {
System.out.println("This never prints"); // Skipped
}
// Output: Result: 5
// โ
Exception object methods
try {
Integer.parseInt("not-a-number");
} catch (NumberFormatException e) {
System.out.println(e.getMessage()); // For input string: "not-a-number"
System.out.println(e.getClass().getName()); // java.lang.NumberFormatException
// e.getCause() โ the causing exception (null if none)
// e.getStackTrace() โ StackTraceElement[]
// e.toString() โ class name + message
}
}
}Multiple catch Blocks โ The Specificity Ladder
A single try block can have multiple catch blocks, each handling a different exception type. Java evaluates them top-to-bottom and executes the first matching catch block โ all subsequent catch blocks are skipped. This creates a critical ordering rule: more specific exceptions must come before less specific ones. Placing a superclass catch block before a subclass causes a compile error.
import java.io.*;
public class MultipleCatchBlocks {
public void parseAndProcessFile(String filePath, String numberStr) {
try {
// Multiple potential failure points
BufferedReader reader = new BufferedReader(new FileReader(filePath));
String content = reader.readLine();
int number = Integer.parseInt(numberStr);
int result = 100 / number;
System.out.println("Result: " + result);
} catch (FileNotFoundException e) {
// Most specific IOException subclass โ must come BEFORE IOException
System.err.println("File not found: " + filePath);
System.err.println("Please check the file path and try again.");
} catch (IOException e) {
// General I/O failure โ comes AFTER FileNotFoundException
System.err.println("Failed to read file: " + e.getMessage());
} catch (NumberFormatException e) {
// Parsing failure
System.err.println("'" + numberStr + "' is not a valid integer.");
} catch (ArithmeticException e) {
// Division by zero
System.err.println("Cannot divide by zero. Provide a non-zero number.");
} catch (Exception e) {
// Catch-all โ most general, MUST come last
System.err.println("Unexpected error: " + e.getMessage());
e.printStackTrace();
}
}
// โ COMPILE ERROR โ unreachable catch block
public void wrongOrder() {
try {
new FileReader("file.txt");
} catch (IOException e) { // IOException is superclass of
System.out.println("IO");
} catch (FileNotFoundException e) { // โ COMPILE ERROR โ already caught above!
System.out.println("File"); // FileNotFoundException extends IOException
}
}
}Multi-catch (Java 7+) โ One Block, Many Exception Types
Java 7 introduced multi-catch โ a single catch block that handles multiple unrelated exception types using the pipe (|) operator. This is ideal when different exception types require the same handling logic โ eliminating code duplication without compromising specificity. Multi-catch is a pure syntax improvement: at bytecode level it generates the same structure as separate catch blocks.
import java.io.*;
import java.sql.*;
import java.lang.reflect.*;
public class MultiCatch {
public void processData(String input) {
// โ BEFORE Java 7 โ repetitive catch blocks with identical handling
try {
// ... code ...
} catch (IOException e) {
logError(e);
notifyAdmin(e);
} catch (SQLException e) {
logError(e); // Exact same code โ copy-paste
notifyAdmin(e);
} catch (ParseException e) {
logError(e); // Exact same code โ copy-paste
notifyAdmin(e);
}
// โ
Java 7+ โ multi-catch with | operator
try {
performOperation(input);
} catch (IOException | SQLException | ParseException e) {
// 'e' is effectively final here โ cannot be reassigned
logError(e);
notifyAdmin(e);
}
// โ
Multi-catch with different handling for different groups
try {
riskyOperation(input);
} catch (NullPointerException | IllegalArgumentException e) {
// Input validation failures โ client error
System.err.println("Invalid input: " + e.getMessage());
throw new BadRequestException("Invalid request data", e);
} catch (IOException | SQLException e) {
// Infrastructure failures โ server error
System.err.println("Infrastructure failure: " + e.getMessage());
throw new ServiceUnavailableException("Service temporarily unavailable", e);
}
}
// โ Multi-catch does NOT allow superclass and subclass together
public void invalidMultiCatch() {
try {
new FileReader("x");
} catch (IOException | FileNotFoundException e) { // โ COMPILE ERROR
// FileNotFoundException is a subclass of IOException โ
// they cannot appear together in multi-catch (redundant)
}
}
private void performOperation(String s) throws IOException, SQLException, ParseException {}
private void riskyOperation(String s) throws IOException, SQLException {}
private void logError(Throwable t) {}
private void notifyAdmin(Throwable t) {}
}finally Block โ The Guaranteed Cleanup
The finally block is a block of code that always executes after the try block completes โ whether it completed normally, threw an exception that was caught, threw an exception that was not caught, or exited via return, break, or continue. It is Java's mechanism for guaranteed cleanup: releasing resources, closing connections, releasing locks, or resetting state โ regardless of what happened in the try block.
import java.io.*;
public class FinallyBlock {
// โ
Classic use case: resource cleanup before try-with-resources
public String readFileLegacy(String path) throws IOException {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(path));
return reader.readLine();
} catch (FileNotFoundException e) {
System.err.println("File not found: " + path);
return null;
} finally {
// โ
Always runs โ whether exception was thrown or not
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
System.err.println("Failed to close reader: " + e.getMessage());
}
}
System.out.println("Cleanup complete."); // Always prints
}
}
// โ
finally with return โ TRICKY behaviour
public int trickyReturn() {
try {
System.out.println("Try block");
return 1; // โ Starts to return 1...
} finally {
System.out.println("Finally block"); // โ ...but this runs first
return 2; // โ Return in finally OVERRIDES return in try!
}
// Output: Try block โ Finally block
// Actual return value: 2 โ finally wins
}
// โ
finally with exception โ also tricky
public void trickyException() throws Exception {
try {
throw new IOException("From try");
} finally {
throw new RuntimeException("From finally"); // โ Suppresses IOException!
}
// Only RuntimeException propagates โ IOException is SILENTLY LOST
// This is why throwing from finally is an anti-pattern
}
// โ
finally without catch โ try-finally (no catch needed)
public void updateWithLock(ReentrantLock lock) {
lock.lock();
try {
performUpdate(); // If this throws, lock still gets released
} finally {
lock.unlock(); // ALWAYS releases the lock
}
}
// โ When does finally NOT run? Only in these rare cases:
public void whenFinallySkips() {
try {
System.exit(0); // โ JVM terminates โ finally DOES NOT run
} finally {
System.out.println("This never prints when System.exit() is called");
}
}
private void performUpdate() {}
static class ReentrantLock {
void lock() {}
void unlock() {}
}
}try-with-resources (Java 7+) โ AutoCloseable Done Right
Introduced in Java 7, try-with-resources is the modern, correct way to manage resources that must be closed after use. Any object implementing java.lang.AutoCloseable (or its subinterface java.io.Closeable) can be declared in the try header โ Java guarantees that close() is called on each resource when the block exits, in reverse order of declaration, whether the block completes normally or via exception. This is strictly superior to manually-coded try-finally for resource management.
import java.io.*;
import java.sql.*;
public class TryWithResources {
// โ
Single resource โ simplest form
public String readFirstLine(String path) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
return reader.readLine();
// reader.close() is called automatically here โ always
}
// Compare: no finally block needed at all
}
// โ
Multiple resources โ closed in REVERSE order of declaration
public void copyFile(String sourcePath, String destPath) throws IOException {
try (FileInputStream fis = new FileInputStream(sourcePath); // opened 1st
FileOutputStream fos = new FileOutputStream(destPath); // opened 2nd
BufferedInputStream in = new BufferedInputStream(fis); // opened 3rd
BufferedOutputStream out = new BufferedOutputStream(fos)) {// opened 4th
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
// Close order: out โ in โ fos โ fis (reverse of opening)
}
}
// โ
JDBC โ try-with-resources for connections, statements, result sets
public User findUserById(Connection conn, long userId) throws SQLException {
String sql = "SELECT id, name, email FROM users WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql);
// Note: Connection is NOT declared here โ it's passed in and
// managed by the connection pool, not closed here
) {
ps.setLong(1, userId);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return new User(rs.getLong("id"), rs.getString("name"),
rs.getString("email"));
}
return null;
}
// rs.close() called automatically
}
// ps.close() called automatically
}
// โ
Java 9+: Effectively-final variables in try header (no redeclaration)
public void java9Style() throws IOException {
BufferedReader reader = new BufferedReader(new FileReader("data.txt"));
// In Java 9+, effectively-final variables can be used directly:
try (reader) { // No need to redeclare โ Java 9+
System.out.println(reader.readLine());
}
}
// โ
Custom AutoCloseable โ any class can participate
static class DatabaseTransaction implements AutoCloseable {
private final Connection connection;
private boolean committed = false;
DatabaseTransaction(Connection connection) throws SQLException {
this.connection = connection;
this.connection.setAutoCommit(false);
System.out.println("Transaction started");
}
public void commit() throws SQLException {
connection.commit();
committed = true;
}
@Override
public void close() throws SQLException {
if (!committed) {
System.out.println("Auto-rolling back transaction");
connection.rollback(); // Rollback if not committed
}
connection.setAutoCommit(true);
}
}
public void transferFunds(Connection conn, long fromId,
long toId, double amount) throws SQLException {
try (DatabaseTransaction tx = new DatabaseTransaction(conn)) {
deductBalance(conn, fromId, amount);
addBalance(conn, toId, amount);
tx.commit(); // Only commits if both succeed
// If exception occurs before commit โ auto-rollback via close()
}
}
private void deductBalance(Connection c, long id, double amt) throws SQLException {}
private void addBalance(Connection c, long id, double amt) throws SQLException {}
private static class User {
User(long id, String name, String email) {}
}
}throw โ Firing an Exception Manually
The throw keyword explicitly throws an exception from your code โ interrupting the current execution flow at that point. It is used to signal that a method's precondition has been violated, a business rule has been broken, or an operation cannot proceed due to an invalid state. You can throw any instance of Throwable, but in practice you always throw Exception subclasses โ never Error subclasses.
public class ThrowKeyword {
// โ
Throwing unchecked โ precondition validation (guard clause pattern)
public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException(
"Age must be between 0 and 150. Received: " + age);
}
this.age = age;
}
// โ
Throwing unchecked โ null check (prefer Objects.requireNonNull)
public void processOrder(Order order) {
if (order == null) {
throw new NullPointerException("order must not be null");
}
// โ
Better idiom (Java 7+):
java.util.Objects.requireNonNull(order, "order must not be null");
java.util.Objects.requireNonNull(order.getId(), "order ID must not be null");
}
// โ
Throwing checked โ callers must handle or declare
public byte[] readBytes(String path) throws IOException {
java.io.File file = new java.io.File(path);
if (!file.exists()) {
throw new java.io.FileNotFoundException(
"File not found at path: " + path);
}
if (!file.canRead()) {
throw new java.io.IOException(
"No read permission for file: " + path);
}
return java.nio.file.Files.readAllBytes(file.toPath());
}
// โ
Re-throwing โ catch, do something, rethrow same exception
public void processWithLogging(String data) throws IOException {
try {
riskyOperation(data);
} catch (IOException e) {
System.err.println("Failed to process: " + data); // Log
throw e; // Rethrow โ caller still handles it
}
}
// โ
Wrapping โ catch one type, throw another with more context
public User loadUser(String userId) {
try {
return userRepository.findById(userId);
} catch (SQLException e) {
// Translate infrastructure exception to domain exception
throw new UserServiceException(
"Failed to load user with ID: " + userId, e); // 'e' becomes the cause
}
}
// โ
throw in a conditional chain โ useful for state machines
public void cancelOrder(Order order) {
if (order.getStatus() == OrderStatus.DELIVERED) {
throw new IllegalStateException(
"Cannot cancel order " + order.getId() + " โ already delivered.");
}
if (order.getStatus() == OrderStatus.CANCELLED) {
throw new IllegalStateException(
"Order " + order.getId() + " is already cancelled.");
}
order.setStatus(OrderStatus.CANCELLED);
}
private int age;
private Object userRepository;
private void riskyOperation(String s) throws IOException {}
private static class Order {
Object getId() { return null; }
OrderStatus getStatus() { return null; }
void setStatus(OrderStatus s) {}
}
private enum OrderStatus { DELIVERED, CANCELLED }
}throws โ Declaring Propagated Exceptions
The throws keyword in a method signature declares which checked exceptions the method might propagate to its caller. It is a contract: 'I, this method, might produce these exception types โ caller, you must deal with them.' It does NOT handle the exception โ it propagates it up the call stack. The compiler requires this declaration for checked exceptions; it is optional but recommended for unchecked exceptions when they are part of the documented API contract.
import java.io.*;
import java.sql.*;
public class ThrowsKeyword {
// โ
Declaring a single checked exception
public String readFile(String path) throws IOException {
return new String(java.nio.file.Files.readAllBytes(
java.nio.file.Paths.get(path)));
}
// โ
Declaring multiple checked exceptions
public User authenticateAndLoad(String token) throws IOException, SQLException {
String userId = validateToken(token); // throws IOException
return loadFromDatabase(userId); // throws SQLException
}
// โ
Propagating up a chain of methods
// Level 3 โ throws declared
private String validateToken(String token) throws IOException {
// Calls a method that throws IOException
return readFile("/tokens/" + token);
}
// Level 2 โ propagates declared exception
private User loadFromDatabase(String userId) throws SQLException {
Connection conn = DriverManager.getConnection("jdbc:...");
// ...query...
return null;
}
// โ
throws in interface / abstract class โ callers of ALL implementations must handle
interface DataRepository {
User findById(long id) throws SQLException;
void save(User user) throws SQLException;
}
// โ
Overriding: subclass can declare FEWER exceptions, not more
static class BaseService {
public void execute() throws IOException, SQLException { }
}
static class ConcreteService extends BaseService {
@Override
public void execute() throws IOException { } // โ
Removed SQLException โ allowed
// throws IOException, SQLException, RuntimeException โ โ
can add unchecked
// throws IOException, SQLException, Exception โ โ COMPILE ERROR: broader checked
}
// โ
Documenting unchecked exceptions in throws (optional but professional)
/**
* @throws IllegalArgumentException if amount is negative or zero
* @throws InsufficientFundsException if account balance is insufficient
*/
public void withdraw(double amount) throws InsufficientFundsException {
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive");
}
if (this.balance < amount) {
throw new InsufficientFundsException(this.balance, amount);
}
this.balance -= amount;
}
private double balance = 5000;
private static class User {}
private static class InsufficientFundsException extends RuntimeException {
InsufficientFundsException(double balance, double amount) {
super(String.format("Insufficient funds. Balance: %.2f, Requested: %.2f",
balance, amount));
}
}
}Custom Exceptions โ Domain-Specific Error Vocabulary
Standard Java exceptions are general-purpose โ IOException tells you something went wrong with I/O but not what or why in your domain. Custom exceptions give your application its own error vocabulary: InsufficientFundsException, UserNotFoundException, PaymentGatewayException. They carry domain-specific data, produce meaningful log messages, and allow callers to handle specific failure types precisely.
For checked exceptions (callers must handle): extend Exception. For unchecked (programming errors / domain invariants): extend RuntimeException. For errors the JVM should never recover from: extend Error (almost never done in application code). Modern preference: lean towards RuntimeException subclasses โ most frameworks (Spring, Hibernate) use unchecked exceptions to avoid cluttering service interfaces with checked exception declarations.
Always provide: (1) Exception(String message), (2) Exception(String message, Throwable cause), (3) Exception(Throwable cause), (4) Exception() โ the no-arg. These mirror the standard Java exception pattern. The cause constructors are critical for exception chaining โ wrapping a lower-level exception in a domain exception without losing the original stack trace.
The best custom exceptions carry more than just a string message โ they carry the DOMAIN DATA that caused the failure. InsufficientFundsException should carry the available balance and requested amount. UserNotFoundException should carry the userId that wasn't found. This data enables logging systems, monitoring tools, and error handlers to produce actionable alerts without parsing string messages.
Exception fields should be final โ exceptions are value objects representing a single failure event. Making fields mutable allows them to be modified after creation, which destroys the immutability invariant and creates debugging nightmares. Always set all custom fields in the constructor and provide only getters.
// โ
Base domain exception โ all app exceptions extend this
public class AppException extends RuntimeException {
private final String errorCode;
public AppException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public AppException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
// โ
Domain-specific: carries failure context as typed fields
public class InsufficientFundsException extends AppException {
private final double availableBalance;
private final double requestedAmount;
private final String accountId;
public InsufficientFundsException(String accountId,
double availableBalance,
double requestedAmount) {
super("INSUFFICIENT_FUNDS",
String.format("Account %s has insufficient funds. " +
"Available: %.2f, Requested: %.2f",
accountId, availableBalance, requestedAmount));
this.accountId = accountId;
this.availableBalance = availableBalance;
this.requestedAmount = requestedAmount;
}
public double getAvailableBalance() { return availableBalance; }
public double getRequestedAmount() { return requestedAmount; }
public String getAccountId() { return accountId; }
public double getDeficit() { return requestedAmount - availableBalance; }
}
// โ
Resource-not-found exception โ generic, parameterised
public class ResourceNotFoundException extends AppException {
private final String resourceType;
private final Object resourceId;
public ResourceNotFoundException(String resourceType, Object resourceId) {
super("RESOURCE_NOT_FOUND",
resourceType + " not found with ID: " + resourceId);
this.resourceType = resourceType;
this.resourceId = resourceId;
}
// Convenient static factories
public static ResourceNotFoundException forUser(long userId) {
return new ResourceNotFoundException("User", userId);
}
public static ResourceNotFoundException forOrder(String orderId) {
return new ResourceNotFoundException("Order", orderId);
}
public String getResourceType() { return resourceType; }
public Object getResourceId() { return resourceId; }
}
// โ
Checked exception โ external API call failed
public class PaymentGatewayException extends Exception {
private final int gatewayErrorCode;
private final String transactionId;
private final boolean retryable;
public PaymentGatewayException(int gatewayErrorCode, String transactionId,
boolean retryable, String message) {
super(message);
this.gatewayErrorCode = gatewayErrorCode;
this.transactionId = transactionId;
this.retryable = retryable;
}
public PaymentGatewayException(int code, String txId,
boolean retryable, String msg, Throwable cause) {
super(msg, cause);
this.gatewayErrorCode = code;
this.transactionId = txId;
this.retryable = retryable;
}
public int getGatewayErrorCode() { return gatewayErrorCode; }
public String getTransactionId() { return transactionId; }
public boolean isRetryable() { return retryable; }
}Exception Chaining โ Preserving the Root Cause
Exception chaining (also called exception wrapping) is the practice of catching a lower-level exception and rethrowing a higher-level exception that wraps the original as its cause. This is critical for production debugging: the high-level exception tells you what failed at the domain level; the chained cause tells you why at the infrastructure level โ without exposing implementation details to the caller.
public class ExceptionChaining {
// โ BAD: Losing the original cause โ debugging becomes impossible
public User findUserBad(long id) {
try {
return userDao.findById(id);
} catch (SQLException e) {
// e is swallowed โ DBA can never find out what SQL failed
throw new RuntimeException("Failed to find user");
}
}
// โ ALSO BAD: Logging AND rethrowing โ the exception gets logged twice
public User findUserAlsoBad(long id) {
try {
return userDao.findById(id);
} catch (SQLException e) {
log.error("DB error", e); // Logged here
throw new RuntimeException(e); // AND rethrown โ logged again by caller
}
}
// โ
GOOD: Wrap with cause โ original stack trace fully preserved
public User findUser(long userId) {
try {
return userDao.findById(userId);
} catch (SQLException e) {
// 'e' becomes the cause โ full chain preserved in stack trace
throw new UserServiceException(
"Failed to retrieve user with ID: " + userId, e);
}
}
// โ
Reading the chain โ traversing causes programmatically
public static void printExceptionChain(Throwable t) {
System.out.println("Exception chain:");
int level = 0;
Throwable current = t;
while (current != null) {
System.out.println(" ".repeat(level) +
"[" + level + "] " + current.getClass().getSimpleName() +
": " + current.getMessage());
current = current.getCause();
level++;
}
}
// โ
Full chain visible in stack trace output:
// com.app.UserServiceException: Failed to retrieve user with ID: 42
// at com.app.UserService.findUser(UserService.java:35)
// ...
// Caused by: java.sql.SQLException: Connection refused to host: db.example.com
// at com.mysql.jdbc.Driver.connect(Driver.java:...)
// ...
// Caused by: java.net.ConnectException: Connection refused
// at java.net.Socket.connect(Socket.java:...)
// โ
Useful cause-inspection utility
public static boolean hasCause(Throwable t, Class<? extends Throwable> type) {
Throwable current = t;
while (current != null) {
if (type.isInstance(current)) return true;
current = current.getCause();
}
return false;
}
private Object userDao;
private Object log;
private static class UserServiceException extends RuntimeException {
UserServiceException(String msg, Throwable cause) { super(msg, cause); }
}
}Exception vs Error โ Know the Boundary
Both Exception and Error extend Throwable, but they represent fundamentally different situations with different handling strategies. Understanding this boundary prevents a class of serious bugs: catching Error (or Throwable) and attempting to continue when the JVM is in an inconsistent state.
public class ExceptionVsError {
// โ DANGEROUS: Catching Error โ looks safe, is a lie
public void dangerousCatch() {
try {
processHugeDataSet();
} catch (OutOfMemoryError e) {
System.out.println("Caught OOM โ continuing..."); // FALSE SAFETY
// Reality: heap is still full, next allocation will fail again
// JVM may be in corrupt state โ undefined behaviour ahead
}
}
// โ ALSO DANGEROUS: Catching Throwable in business code
public void catchThrowable() {
try {
businessLogic();
} catch (Throwable t) { // Catches Error too โ almost always wrong
log(t);
}
}
// โ
The ONE legitimate use: graceful shutdown handler at app boundary
public static void main(String[] args) {
try {
Application.start();
} catch (Throwable t) {
// Top-level catch at JVM entry point โ only acceptable location
System.err.println("FATAL: Application crashed: " + t.getMessage());
t.printStackTrace();
System.exit(1); // Explicit exit with error code
}
}
// โ
StackOverflowError โ caused by unguarded recursion
public int factorial(int n) {
// โ No base case โ will throw StackOverflowError
return n * factorial(n - 1);
}
public int factorialSafe(int n) {
if (n < 0) throw new IllegalArgumentException("n must be non-negative");
if (n == 0) return 1; // โ
Base case prevents StackOverflowError
return n * factorialSafe(n - 1);
}
private void processHugeDataSet() {}
private void businessLogic() {}
private void log(Throwable t) {}
private static class Application { static void start() {} }
}Bad Practices & Anti-Patterns โ What Causes Silent Failures
Exception-handling anti-patterns are uniquely dangerous because they convert loud, visible failures into silent, invisible ones. A program that crashes with a stack trace is bad. A program that swallows exceptions and silently continues with incorrect data is far worse โ it corrupts business-critical information while reporting success. These are the most common exception handling failures in production Java code.
catch (Exception e) { } is the single most dangerous pattern in Java. The exception is caught, completely ignored, and execution continues as if nothing happened. The program may produce incorrect results for hours or days before anyone notices. Minimum: always log. Better: log + rethrow, or log + return a safe default that signals failure to the caller.
catch (Exception e) in a business method catches NullPointerException, ClassCastException, and IndexOutOfBoundsException โ programming bugs that should be fixed, not silently handled. Broad catches hide real bugs. Rule: catch only the specific exception types you know how to handle. If you need a safety net, catch Exception at the outermost boundary only โ never deep inside business logic.
try { return map.get(key); } catch (NullPointerException e) { return default; } is an abuse of exceptions. Exceptions are for EXCEPTIONAL conditions โ rare, unexpected failures. Checking whether a key exists (map.containsKey(key)) is normal flow, not exceptional. Using exceptions for expected conditions is slow (exception creation builds a stack trace โ expensive), unreadable, and misleading.
catch (Exception e) { log.error('Failed', e); throw e; } causes the same exception to be logged 2-3 times in a layered application. Log files become unreadable โ the same error appears in service layer, repository layer, and controller layer logs. Rule: log ONCE at the boundary where you actually HANDLE the exception (not just rethrow). Every other layer just wraps and rethrows.
catch (Exception e) { return null; } shifts the error handling problem to the caller โ but without the exception context. The caller gets null back with no indication of why, leading to a NullPointerException far from the original failure. If you must return a value from a catch block, use Optional.empty() or a Result/Either type to communicate failure explicitly.
catch (Exception e) { if (e.getMessage().contains('duplicate key')) { ... } } is fragile and wrong. Exception messages are human-readable strings โ they change between DB drivers, JVM versions, and locale settings. Instead: catch the specific exception type (SQLIntegrityConstraintViolationException), check the SQL error code (e.getErrorCode()), or better yet, check the constraint BEFORE attempting the operation.
// โ ANTI-PATTERN 1: Swallowing โ the silent killer
try {
saveOrderToDatabase(order);
} catch (Exception e) {
// NOTHING HERE โ order silently not saved, user thinks it was
}
// โ
BETTER: At minimum, log. Ideally rethrow or return failure signal.
try {
saveOrderToDatabase(order);
} catch (SQLException e) {
log.error("Failed to save order {}: {}", order.getId(), e.getMessage(), e);
throw new OrderPersistenceException("Could not save order " + order.getId(), e);
}
// โ ANTI-PATTERN 2: Exception as flow control โ slow and misleading
public User findUser(String id) {
try {
return userCache.get(id); // Throws if not found
} catch (CacheException e) {
return null; // Using exception to signal 'not found' โ wrong
}
}
// โ
BETTER: Check, don't catch
public Optional<User> findUser(String id) {
if (userCache.contains(id)) {
return Optional.of(userCache.get(id));
}
return Optional.empty(); // Explicit 'not found' without exception
}
// โ ANTI-PATTERN 3: Returning null from catch
public Config loadConfig(String path) {
try {
return parseConfigFile(path);
} catch (IOException e) {
return null; // Caller has no idea WHY it's null
}
}
// โ
BETTER: Throw with context or return Optional
public Config loadConfig(String path) {
try {
return parseConfigFile(path);
} catch (IOException e) {
throw new ConfigurationException(
"Cannot load config from: " + path, e);
}
}Real-World Production Code Examples โ Exception Handling in Enterprise Java
The following examples demonstrate production-grade exception handling in a Spring Boot microservice โ covering REST API global error handling, service layer patterns, and repository layer exception translation.
package com.techsustainify.api.exception;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.*;
/**
* Centralised exception handler for all REST controllers.
* Translates domain exceptions into HTTP responses.
* Logs once โ no other layer logs these exceptions.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(GlobalExceptionHandler.class);
// โ
Domain: Resource not found โ 404
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
log.warn("Resource not found: {} with ID {}",
ex.getResourceType(), ex.getResourceId());
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of("RESOURCE_NOT_FOUND", ex.getMessage()));
}
// โ
Domain: Insufficient funds โ 422 Unprocessable Entity
@ExceptionHandler(InsufficientFundsException.class)
public ResponseEntity<ErrorResponse> handleInsufficientFunds(
InsufficientFundsException ex) {
log.info("Insufficient funds for account {}: balance={}, requested={}",
ex.getAccountId(), ex.getAvailableBalance(), ex.getRequestedAmount());
Map<String, Object> details = new LinkedHashMap<>();
details.put("availableBalance", ex.getAvailableBalance());
details.put("requestedAmount", ex.getRequestedAmount());
details.put("deficit", ex.getDeficit());
return ResponseEntity
.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(ErrorResponse.of("INSUFFICIENT_FUNDS", ex.getMessage(), details));
}
// โ
Validation: Bad request input โ 400
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
log.debug("Bad request: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of("INVALID_REQUEST", ex.getMessage()));
}
// โ
External: Payment gateway failure
@ExceptionHandler(PaymentGatewayException.class)
public ResponseEntity<ErrorResponse> handlePaymentGateway(
PaymentGatewayException ex) {
log.error("Payment gateway error: code={}, txId={}, retryable={}",
ex.getGatewayErrorCode(), ex.getTransactionId(), ex.isRetryable(), ex);
HttpStatus status = ex.isRetryable()
? HttpStatus.SERVICE_UNAVAILABLE
: HttpStatus.BAD_GATEWAY;
return ResponseEntity.status(status)
.body(ErrorResponse.of("PAYMENT_FAILED", ex.getMessage()));
}
// โ
Catch-all: Unexpected server errors โ 500
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
log.error("Unexpected error โ this should be investigated", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of("INTERNAL_ERROR",
"An unexpected error occurred. Please try again."));
}
// Standard error response body
public record ErrorResponse(
String errorCode,
String message,
Instant timestamp,
Map<String, Object> details
) {
static ErrorResponse of(String code, String message) {
return new ErrorResponse(code, message, Instant.now(), Map.of());
}
static ErrorResponse of(String code, String message,
Map<String, Object> details) {
return new ErrorResponse(code, message, Instant.now(), details);
}
}
}package com.techsustainify.payment.service;
import java.util.Objects;
public class PaymentService {
private final AccountRepository accountRepo;
private final PaymentGatewayClient gatewayClient;
private final TransactionRepository txRepo;
/**
* Processes a payment with full exception handling:
* - Precondition validation (unchecked)
* - Business rule enforcement (domain exception)
* - Infrastructure failure translation (exception wrapping)
* - Resource management (try-with-resources)
*
* @throws IllegalArgumentException if request is invalid (programming error)
* @throws ResourceNotFoundException if account not found (domain error)
* @throws InsufficientFundsException if balance too low (domain error)
* @throws PaymentGatewayException if external gateway fails (infrastructure)
*/
public TransactionResult processPayment(PaymentRequest request) {
// โ
Precondition checks โ fast fail with clear messages
Objects.requireNonNull(request, "PaymentRequest must not be null");
Objects.requireNonNull(request.getAccountId(), "Account ID required");
if (request.getAmount() <= 0) {
throw new IllegalArgumentException(
"Payment amount must be positive. Received: " + request.getAmount());
}
// โ
Load resource โ throws domain exception if not found
Account account = accountRepo.findById(request.getAccountId())
.orElseThrow(() -> ResourceNotFoundException.forAccount(
request.getAccountId()));
// โ
Business rule โ throws domain exception with context
if (account.getBalance() < request.getAmount()) {
throw new InsufficientFundsException(
account.getId(),
account.getBalance(),
request.getAmount()
);
}
// โ
Infrastructure call โ wrap checked exceptions in domain exception
GatewayResponse gatewayResponse;
try {
gatewayResponse = gatewayClient.charge(
account.getPaymentToken(),
request.getAmount(),
request.getCurrency()
);
} catch (GatewayTimeoutException e) {
throw new PaymentGatewayException(
e.getCode(), request.getIdempotencyKey(),
true, // retryable โ timeout may be transient
"Payment gateway timed out", e);
} catch (GatewayRejectionException e) {
throw new PaymentGatewayException(
e.getCode(), request.getIdempotencyKey(),
false, // not retryable โ card rejected
"Payment rejected by gateway: " + e.getReason(), e);
}
// โ
Persist transaction โ catch and wrap SQL exception
try {
Transaction tx = Transaction.builder()
.accountId(account.getId())
.amount(request.getAmount())
.gatewayRef(gatewayResponse.getTransactionId())
.build();
txRepo.save(tx);
account.deductBalance(request.getAmount());
accountRepo.save(account);
return TransactionResult.success(tx.getId(), gatewayResponse);
} catch (Exception e) {
// Gateway charged but DB failed โ log for manual reconciliation
log.error("CRITICAL: Payment charged by gateway ({}) but not persisted!",
gatewayResponse.getTransactionId(), e);
throw new PaymentReconciliationException(
gatewayResponse.getTransactionId(),
"Payment processed externally but internal record failed", e);
}
}
}Exception Flow Diagram โ What Happens When an Exception is Thrown
Understanding the JVM's exception propagation flow prevents the most common exception-handling bugs.
Code Execution Flow โ from source to output
Java Try-Catch Interview Questions โ Beginner to Advanced
These questions appear in Java developer interviews from fresher to senior level, in OCPJP certification exams, and in technical design discussions about error handling strategy.
Practice Questions โ Test Your Exception Handling Knowledge
Attempt each question independently before reading the answer. These questions test real-world exception-handling reasoning โ the exact scenarios encountered in Java interviews and code reviews.
1. What is the output? try { System.out.println("1"); int x = 5 / 0; System.out.println("2"); } catch (ArithmeticException e) { System.out.println("3"); } catch (Exception e) { System.out.println("4"); } finally { System.out.println("5"); } System.out.println("6");
Easy2. What is wrong with this code? Fix it. public int parseAge(String input) { try { return Integer.parseInt(input); } catch (Exception e) { return -1; } }
Easy3. Will this compile? Explain why or why not. try { FileReader fr = new FileReader("data.txt"); } catch (FileNotFoundException e) { System.out.println("File not found"); } catch (IOException e) { System.out.println("IO error"); }
Easy4. Refactor this legacy code to use try-with-resources. BufferedReader br = null; try { br = new BufferedReader(new FileReader(path)); return br.readLine(); } catch (IOException e) { throw new RuntimeException("Read failed", e); } finally { if (br != null) try { br.close(); } catch (IOException ignore) {} }
Medium5. Design a custom exception class for a banking application where a transfer fails because the source account is frozen. What fields should it have?
Medium6. What is the output and why? public static int getValue() { try { return 10; } finally { return 20; } } System.out.println(getValue());
Hard7. What is wrong with this exception-handling pattern in a Spring service? @Service public class OrderService { public void processOrder(Order o) { try { orderRepo.save(o); } catch (Exception e) { log.error("Error saving order", e); throw e; } } }
Hard8. Write a method that reads JSON from a URL, parses it, and returns the result โ with proper exception handling at each layer.
HardConclusion โ Exception Handling: The Difference Between Brittle and Robust Code
Exception handling is where the quality gap between junior and senior Java developers is most visible. Junior code swallows exceptions, returns null from catch blocks, catches Exception broadly, and logs-then-rethrows at every layer. Senior code uses specific exceptions, carries domain context in custom exception fields, chains causes for full stack visibility, logs exactly once at the boundary, and uses try-with-resources for all resource management.
The most important insight about exception handling is that it is ultimately about communication โ between the code that detects a problem and the code (or human) that needs to respond. A catch(Exception e){} block says 'something went wrong but I'm hiding it from everyone.' A PaymentGatewayException with isRetryable(), getGatewayErrorCode(), and a chained cause says exactly what failed, why, and what to do about it.
Your next step: Java Collections Framework โ where you'll see exception handling in action as many collection operations (like NoSuchElementException from iterators and ConcurrentModificationException from unsafe iteration) require proper exception awareness. Understanding exceptions deeply makes every other Java topic more robust. โ