Skip to Content
Course content

53: Checked vs Unchecked Exceptions

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

I've seen a lot of developers enter Java with a mindset that "more safety is always better." When they first encounter checked exceptions, it seems like a superpower. The compiler basically forces you to think about what could go wrong. But if you apply that logic blindly, you end up creating a codebase that is practically impossible to maintain. Let's look at where this goes wrong and how we actually handle this in a professional production environment.

The Trap of the "Throws" Chain

Imagine we're building a payment processing system. A naive approach is to create a custom checked exception for everything that isn't a "success." You might create a PaymentException that extends Exception, and use it for every possible failure—from a declined credit card to a database timeout.

public class PaymentService {
    public void processPayment(PaymentRequest request) throws PaymentException {
        if (request.getAmount() <= 0) {
            throw new PaymentException("Invalid amount");
        }
        // Imagine a network call here
        if (!gateway.isAvailable()) {
            throw new PaymentException("Gateway down");
        }
    }
}

At first, this looks responsible. But here is where the "exception pollution" starts. Because PaymentException is checked, every single method that calls processPayment must now also declare throws PaymentException or wrap the call in a try-catch block. If your payment logic is buried five layers deep in your service layer, you end up with five different methods all declaring throws PaymentException just to pass the buck upward. It's a leaky abstraction; your high-level business logic suddenly knows far too much about the low-level failures of the payment gateway.

When this happens, developers get tired of the boilerplate. They start doing the one thing you should never do: the empty catch block. You'll see catch (PaymentException e) { /* TODO: fix this later */ } scattered everywhere. Now you've lost all the safety the checked exception was supposed to provide, and you've added a massive amount of noise to your code.

Drawing the Line Between Bugs and Failures

The key to getting this right is asking yourself one question: Can the caller actually do anything meaningful to recover from this?

If the answer is "no," use an unchecked exception (extend RuntimeException). If the answer is "yes," a checked exception is a valid tool. I generally split my exceptions into two camps: programming errors and environmental failures.

Take that PaymentRequest example again. If the amount is less than or equal to zero, that's not a "failure" in the sense that the network went down; it's a bug. The developer who called the method passed invalid data. They shouldn't be "forced" to catch that—they should be forced to fix the bug. For this, I'd use an IllegalArgumentException, which is unchecked.

public void processPayment(PaymentRequest request) {
    if (request.getAmount() <= 0) {
        // This is a developer error. No need to force a try-catch.
        throw new IllegalArgumentException("Amount must be positive");
    }
    
    try {
        gateway.charge(request);
    } catch (GatewayTimeoutException e) {
        // This is an environmental failure. The caller MUST decide 
        // if they want to retry or tell the user to try again later.
        throw new PaymentRetryableException("Gateway timed out", e);
    }
}

In this version, the IllegalArgumentException doesn't clutter the method signatures. It just crashes the thread (or is caught by a global error handler) because it's a logic error. Meanwhile, the PaymentRetryableException (which we'll make a checked exception) signals to the UI layer that it's time to show a "Retry" button to the user.

The Cost of the Choice

The trade-off here is between strictness and ergonomics. Checked exceptions provide a strict contract, but they are brittle. If you add a new checked exception to a method in a shared library, you've just broken every single piece of code that calls that method across your entire organization.

That's why modern Java frameworks—and languages like Kotlin or Scala—have moved almost entirely toward unchecked exceptions. I personally lean toward RuntimeException for 90% of my cases. I only reach for a checked exception when the failure is a common, expected part of the business flow that the calling code absolutely must handle to maintain system integrity. If you're unsure, go with unchecked. It's much easier to make an exception checked later than it is to remove a checked exception from a public API once a hundred people are using it.




📋 Practical Task

Refactoring the UserAccountManager Exception Hierarchy

You have inherited a piece of code for a UserAccountManager. Currently, it uses a single checked exception AccountException for everything, which has led to a mess of empty catch blocks in the UI layer. Your task is to refactor the exception handling to distinguish between programming errors (which should be unchecked) and recoverable business failures (which should remain checked).

The Requirements:

  • Identify the "Developer Error" (e.g., passing a null username) and change it to throw a NullPointerException or IllegalArgumentException.
  • Identify the "Recoverable Failure" (e.g., the account is locked due to too many password attempts) and keep it as a checked exception (e.g., AccountLockedException).
  • Remove the unnecessary throws AccountException from the method signatures where you have implemented unchecked exceptions.
// STARTING CODE
public class AccountException extends Exception { 
    public AccountException(String msg) { super(msg); } 
}

public class UserAccountManager {
    public void updatePassword(String username, String newPassword) throws AccountException {
        if (username == null) {
            throw new AccountException("Username cannot be null"); 
        }
        if (isAccountLocked(username)) {
            throw new AccountException("Account is locked"); 
        }
        // update logic here...
    }

    private boolean isAccountLocked(String username) {
        return true; // Mock implementation
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.