Java
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Object-Oriented Java
-
Section 4: Collections Framework
-
Section 5: Exception Handling
-
Section 6: Generics
-
Section 7: Functional Java
-
Section 8: Concurrency
-
Section 9: I/O and NIO
-
Section 10: JVM Internals
-
Section 11: Modern Java Features
-
Section 12: Build Tools and Project Structure
-
Section 13: Testing
-
Section 14: Databases and Persistence
-
Section 15: Networking
-
Section 16: Design and Best Practices
-
Section 17: Reflection and Annotations
-
Section 18: Logging and Diagnostics
-
Section 19: Date, Time, and Internationalization
-
Section 20: Java Platform Module System
-
Section 21: Security in Java
-
Section 22: Advanced Collections and Data Structures
-
Section 23: More Concurrency Patterns
-
Section 24: Compression, Files, and System Integration
-
Section 25: GUI Programming
-
Section 26: Practical Projects
-
Section 27: Data Structures and Algorithms
-
Section 28: Interview and Algorithm Practice
-
Section 29: JSON and Data Interchange
-
Section 30: More Concurrency Utilities
-
Section 31: More Collections and Streams Practice
-
Section 32: More File and System Programming
-
Section 33: Standard Library Deep Dive
-
Section 34: More Practice and Drills
-
Section 35: More Testing and Quality
-
Section 36: More Design Patterns and Architecture
-
Section 37: Career and Ecosystem
-
Section 38: More OOP and Architecture Practice
-
Section 39: More Enterprise Concepts
-
Section 40: Advanced JavaFX
-
Section 41: More Interview Practice
59: Best Practices for Exception Design
By now, you know how to use try-catch blocks and how to throw an exception. But there is a massive difference between making the code work and designing an exception strategy that other developers (including your future self) will actually appreciate. When I first started out, I just threw RuntimeException for everything. It was fast, but it made my APIs a nightmare to integrate with because the caller had no way to programmatically distinguish between a "user error" and a "system crash."
Let's build a small PaymentProcessor to see how we can move from "generic" to "professional" exception design.
Starting with the "Lazy" Approach
Imagine we're building a method to deduct money from a user's account. In a rush, I might write something like this:
public void processPayment(String accountId, double amount) {
Account account = database.findAccount(accountId);
if (account == null) {
throw new RuntimeException("Account not found");
}
if (account.getBalance() < amount) {
throw new RuntimeException("Insufficient funds");
}
account.withdraw(amount);
}
Technically, this works. The program stops, and a message is printed. But look at it from the perspective of the person calling this method. If they want to show a specific "Please top up your account" message to the user when funds are low, they have to do something disgusting like this:
try {
processor.processPayment("123", 100.0);
} catch (RuntimeException e) {
if (e.getMessage().contains("Insufficient funds")) {
// Handle low balance
} else {
// Handle something else?
}
}
Parsing exception messages is a huge red flag. If I change the word "Insufficient" to "Not enough" in the processor, I've just broken the caller's logic without changing the method signature. We need a better way.
Building a Meaningful Hierarchy
The goal is to let the caller use catch blocks to decide how to react. I'll start by creating a base exception for my domain. This allows a caller to catch any payment-related error if they don't care about the specifics, or catch a specific one if they do.
public class PaymentException extends Exception {
public PaymentException(String message) {
super(message);
}
}
public class InsufficientFundsException extends PaymentException {
public InsufficientFundsException(String message) {
super(message);
}
}
public class AccountNotFoundException extends PaymentException {
public AccountNotFoundException(String message) {
super(message);
}
}
Now, I'll update the processor. Notice I've switched to checked exceptions here. I'm doing this intentionally because "insufficient funds" is a business reality—not a programming bug. The caller should be forced to decide what happens when a payment fails.
public void processPayment(String accountId, double amount) throws PaymentException {
Account account = database.findAccount(accountId);
if (account == null) {
throw new AccountNotFoundException("Account " + accountId + " does not exist.");
}
if (account.getBalance() < amount) {
throw new InsufficientFundsException("Balance too low.");
}
account.withdraw(amount);
}
Adding State to Our Exceptions
Here is where we move from "good" to "great." A string message is for humans; data is for programs. If the user has insufficient funds, the UI probably wants to tell them exactly how much more they need. Instead of making the caller guess, I'll bake that data directly into the exception.
public class InsufficientFundsException extends PaymentException {
private final double shortfall;
public InsufficientFundsException(double shortfall) {
super("Insufficient funds. You are missing: " + shortfall);
this.shortfall = shortfall;
}
public double getShortfall() {
return shortfall;
}
}
Now the implementation is clean and provides real value:
if (account.getBalance() < amount) {
double missing = amount - account.getBalance();
throw new InsufficientFundsException(missing);
}
The caller can now do this: catch (InsufficientFundsException e) { double needed = e.getShortfall(); ... }. That is an API that is a joy to use.
The Checked vs. Unchecked Trade-off
You might be wondering: "Why not just make these all RuntimeException to avoid the throws keyword?"
Here is my rule of thumb: if the caller can reasonably be expected to recover from the error, make it checked. If the error represents a developer mistake (like passing a null to a method that forbids it) or an unrecoverable system failure (like the database server exploding), make it unchecked.
In our payment example, a missing account or low balance is a standard part of the business flow. Forcing the developer to handle it via a checked exception prevents them from "forgetting" to handle a common edge case that would otherwise crash the app in production.
📋 Practical Task
Refactoring the Library Loan System
You have been handed a LibraryService class written by a junior developer. Currently, it throws generic IllegalArgumentException for everything, making it impossible for the UI to distinguish between a book that is already checked out and a user who has too many books on their account.
Your Task:
- Create a base checked exception called
LibraryException. - Create two specialized subclasses:
BookAlreadyLoanedExceptionandLoanLimitExceededException. - Add a field to
LoanLimitExceededExceptioncalledmaxAllowed(an int) so the caller knows the limit. - Refactor the
loanBookmethod below to throw these specific exceptions instead ofIllegalArgumentException.
// CURRENT BROKEN IMPLEMENTATION
public class LibraryService {
public void loanBook(String bookId, User user) {
if (BookDb.isLoaned(bookId)) {
throw new IllegalArgumentException("Book is already out");
}
if (user.getLoanCount() >= 5) {
throw new IllegalArgumentException("Too many books");
}
BookDb.markAsLoaned(bookId, user);
}
}There are no comments for now.