Exception Handling and OOP in Java
Exception Handling and OOP
The Intermediate tutorial covered try, catch, finally, and the basics of throwing exceptions. This chapter revisits exceptions from a design perspective: how to build a meaningful custom exception hierarchy of your own, using the same OOP tools, inheritance, encapsulation, and polymorphism, that shape the rest of your codebase.
Creating Custom Exception Classes
A well-designed custom exception does more than carry a message string, it carries structured data about exactly what went wrong, so the code that catches it can react intelligently rather than just printing text.
class InsufficientFundsException extends Exception {
private final double requestedAmount;
private final double availableBalance;
public InsufficientFundsException(double requested, double available) {
super(String.format("Requested %.2f but only %.2f available", requested, available));
this.requestedAmount = requested;
this.availableBalance = available;
}
public double getShortfall() {
return requestedAmount - availableBalance;
}
}
class BankAccount {
private double balance = 100;
void withdraw(double amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(amount, balance);
}
balance -= amount;
}
}
try {
new BankAccount().withdraw(250);
} catch (InsufficientFundsException e) {
System.out.println("Short by: " + e.getShortfall()); // Short by: 150.0
}
Because getShortfall() exposes structured information, the catching code can make a real decision, perhaps suggesting a top-up amount, rather than only being able to display e.getMessage() as plain text.
Checked vs Unchecked Exceptions
The choice between extending Exception (checked) and RuntimeException (unchecked) is itself a design decision, not just a syntax choice, and it should follow from whether you expect the caller to realistically recover from the problem.
Choose Checked (extends Exception) When... | Choose Unchecked (extends RuntimeException) When... |
The failure is an expected, recoverable possibility (a file might not exist, a network call might time out) | The failure represents a programming bug or a violated precondition (a null argument, an invalid array index) |
You want to force every caller to consciously decide how to handle the failure | Forcing every caller to handle it would add noise without adding real value |
Example: InsufficientFundsException, a real, expected business outcome | Example: an IllegalArgumentException thrown because a constructor received a negative age |
// An unchecked exception, used for a precondition violation, not an expected business outcome
class InvalidAgeException extends RuntimeException {
public InvalidAgeException(int age) {
super("Age cannot be negative: " + age);
}
}
class Person {
int age;
Person(int age) {
if (age < 0) {
throw new InvalidAgeException(age); // no 'throws' declaration needed, and none should be added
}
this.age = age;
}
}
Exception Hierarchy and Inheritance
Custom exceptions benefit from the exact same inheritance design thinking as any other class hierarchy. Rather than one flat exception type per problem, a well-organised application often defines a small hierarchy, letting calling code catch broadly or narrowly depending on what it actually needs to do.
// A base exception for an entire application domain
class PaymentException extends Exception {
public PaymentException(String message) {
super(message);
}
}
// More specific exceptions, each describing a distinct failure mode
class InsufficientFundsException extends PaymentException {
public InsufficientFundsException(String message) { super(message); }
}
class CardExpiredException extends PaymentException {
public CardExpiredException(String message) { super(message); }
}
class PaymentProcessor {
void charge(double amount) throws PaymentException {
// ... validation logic that throws one of the specific subtypes above
}
}
try {
new PaymentProcessor().charge(500);
} catch (InsufficientFundsException e) {
System.out.println("Offer a top-up: " + e.getMessage());
} catch (CardExpiredException e) {
System.out.println("Prompt for a new card: " + e.getMessage());
} catch (PaymentException e) {
System.out.println("Generic payment failure: " + e.getMessage()); // catches anything else in the hierarchy
}
This mirrors exactly how you would design any other class hierarchy: a shared abstract concept at the top (PaymentException), and specific, meaningful variations below it. Code that only cares about the general category can catch the base type; code that needs to react differently per failure can catch the specific subtypes, and Java's catch-block ordering rule (specific before general) governs both identically.
Did You Know?
Just like with regular classes, catch blocks must be ordered from most specific to most general, catching PaymentException before InsufficientFundsException would make the second catch block unreachable, and the compiler will refuse to compile such code, flagging it as dead code rather than letting the mistake through silently.










