Skip to Content
Course content

59: Best Practices for Exception Design

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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: BookAlreadyLoanedException and LoanLimitExceededException.
  • Add a field to LoanLimitExceededException called maxAllowed (an int) so the caller knows the limit.
  • Refactor the loanBook method below to throw these specific exceptions instead of IllegalArgumentException.
// 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);
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.