Java Exceptions — Why Empty Catch Blocks Cause Duplicate Charges
Empty catch blocks caused duplicate charges when IOException was swallowed.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Checked exceptions extend Exception directly; the compiler forces handling.
- Unchecked exceptions extend RuntimeException; the compiler leaves you alone.
- The split exists to keep signal-to-noise ratio sane: external failures vs programming bugs.
- Rule: Checked = realistic external failure caller must plan for. Unchecked = programmer error or contract violation.
- Biggest mistake: wrapping a checked exception without preserving the cause — kills debugging.
Imagine you're booking a flight. The airline knows there's a real chance your preferred seat might be taken, so they force you to acknowledge that before you even finish booking — that's a checked exception, the compiler forces you to deal with a problem that's genuinely likely. An unchecked exception is more like someone trying to divide a restaurant bill by zero people — that's a programming blunder, not something the system should force every caller to prepare for. Checked exceptions say 'this realistic problem will happen, plan for it.' Unchecked exceptions say 'you wrote something wrong, fix your code.'
Every Java application that touches the outside world — files, databases, networks, APIs — is one bad moment away from something going wrong. The file doesn't exist. The database is down. The network times out. Java's exception system is how your code communicates those failures, but not all failures are created equal. The language designers made a deliberate, architectural choice: split exceptions into two categories with very different rules, and that choice shapes how you design APIs, how you write business logic, and ultimately how maintainable your codebase is.
The problem checked exceptions solve is straightforward: when a method does something risky that the caller absolutely must prepare for, the compiler becomes your teammate and refuses to let you ship code that ignores that risk. Unchecked exceptions solve the opposite problem — if every method that might accidentally receive a null pointer forced every caller to write a try-catch block, Java code would be unreadable noise. The split exists to keep the signal-to-noise ratio sane.
By the end of this article you'll know the exact inheritance hierarchy that separates the two categories, why the language was designed this way, how to write your own custom exceptions correctly, and — most importantly — how to make the judgment call about which type to throw in your own APIs. You'll also see the most common mistakes developers make and how to sidestep them cleanly.
Why Java Forces You to Handle Checked Exceptions
Checked exceptions are compile-time constraints: the compiler forces the caller to either handle or declare any exception that extends Exception but not RuntimeException. Unchecked exceptions (RuntimeException and its subclasses) carry no such obligation. The core mechanic is that checked exceptions represent recoverable conditions the caller should anticipate — like a missing file or a network timeout — while unchecked exceptions signal programming errors, such as null dereferences or array bounds violations. In practice, checked exceptions propagate through method signatures via throws clauses, and the compiler verifies every call site. This design pushes error handling to the surface, making APIs self-documenting about failure modes. However, it also creates a temptation: empty catch blocks to silence the compiler. That pattern is dangerous because it swallows the exception, leaving the system in an inconsistent state. In production, a swallowed IOException during a payment transaction can silently skip a rollback, causing duplicate charges. The rule: never catch an exception unless you can either recover from it, log it with context, or rethrow it as a domain-specific exception.
The Inheritance Tree That Controls Everything
Every exception in Java lives inside a class hierarchy, and your position in that tree determines whether the compiler watches you or leaves you alone.
At the top sits Throwable. It has two direct children: Error and Exception. Errors (like OutOfMemoryError) represent JVM-level catastrophes you can't reasonably recover from — ignore them for now. Everything we care about lives under Exception.
Here's the rule that governs everything: any class that extends Exception directly is a checked exception. Any class that extends RuntimeException — which itself extends Exception — is an unchecked exception.
That's the whole rule. There's no annotation, no keyword. It's purely about which class you extend.
RuntimeException was introduced because the designers recognised a class of bugs — null dereferences, bad array indices, illegal arguments — that are caused by programmer mistakes rather than environmental conditions. Wrapping those in try-catch blocks would punish correct code for the sins of incorrect code elsewhere. Checked exceptions are for recoverable, external conditions. Unchecked exceptions are for programming errors.
Keep this hierarchy in your head and every other rule falls out naturally.
Writing Custom Exceptions That Actually Communicate Intent
Throwing Exception or RuntimeException directly is the exception equivalent of logging 'something went wrong'. Custom exceptions are how you make failures self-documenting.
The decision of which to extend is a design contract. When you extend Exception, you're telling every caller: 'this failure mode is realistic and environmental — you need to have a plan.' A PaymentGatewayException should be checked because a payment gateway being unreachable is a real-world condition your caller must handle gracefully.
When you extend RuntimeException, you're saying: 'this is a programming contract violation — if you use my API correctly, this never fires.' An InvalidOrderStateException for a state machine, where transitioning from SHIPPED back to PENDING is logically impossible, belongs as unchecked. It means the calling code has a bug.
A practical pattern: create a checked base exception for your domain (e.g., InventoryException) and let specific subtypes inherit it. This lets callers catch broadly when they need to, or narrowly when they can recover from specific cases. Always provide a constructor that accepts a cause parameter — wrapping lower-level exceptions preserves the stack trace and is critical for debugging production issues.
Throwable cause to your custom exceptions — even if you don't use it today. Wrapping a low-level SQLException inside your RepositoryException without the cause discards the original stack trace, making production debugging nearly impossible.The Real-World Pattern: Where Each Exception Type Belongs
Knowing the definition is one thing. Knowing where to put each type in a layered application is what separates a junior from a mid-level engineer.
In a typical web application you have an infrastructure layer (database, HTTP clients, file I/O), a service layer (business logic), and a presentation layer (controllers, API endpoints). Checked exceptions are native to the infrastructure layer — SQLException, IOException, SSLException. These are environmental realities.
Here's the key pattern: you should almost always catch the checked infrastructure exception at the boundary between infrastructure and service layers and wrap it in an unchecked domain exception before rethrowing. Why? Because your service layer shouldn't be coupled to java.sql.SQLException. It should speak domain language. And if you force every service method to throws SQLException, that implementation detail leaks all the way up to your controller.
Modern frameworks like Spring lean heavily on this — Spring Data wraps SQLExceptions into unchecked DataAccessExceptions precisely so your business logic stays clean. This wrapping pattern also means the original cause is preserved for your logs, while callers aren't burdened with handling infrastructure concerns they can't meaningfully recover from anyway.
Use checked exceptions when your direct caller can realistically take a different action based on the failure. Use unchecked when the failure means 'the code calling me has a bug' or 'no caller can meaningfully recover from this.'
SQLException or IOException leak through your service layer interface. The moment your UserService.findById() signature says throws SQLException, your business logic is coupled to your database driver — a change of DB technology means rewriting every caller. Wrap and rethrow as unchecked domain exceptions at the repository boundary.Common Mistakes That Trip Up Intermediate Developers
Even developers who understand the theory make these mistakes under pressure. Here are the three that cause the most damage in real codebases.
Swallowing exceptions is the silent killer. An empty catch block turns a detectable failure into a ghost — the system behaves wrongly with no evidence of why. If you genuinely can't handle an exception, log it and rethrow, or convert it to an unchecked exception. Never leave a catch block empty in production code.
Exception pollution is the checked-exception version of the problem. When a method deep in the stack throws a checked exception, inexperienced developers propagate it up every method signature rather than wrapping it. You end up with controllers declaring throws SQLException — a leaky abstraction that defeats the whole layered architecture.
Catching Exception or Throwable too broadly masks completely different failure modes under one handler. Catching Exception to log-and-continue will silently swallow NullPointerException from your own bugs alongside the IOException you intended to catch. Catch the most specific type you can act on.
Checked vs Unchecked in Modern Java Frameworks: Why Spring Prefers Unchecked
If you look at modern Java frameworks like Spring Boot, you'll notice they almost never throw checked exceptions. Spring's DataAccessException is unchecked. JPA's EntityNotFoundException is unchecked. Even the @Transactional annotation doesn't force you to handle commit failures at each call site.
This isn't an accident. The framework designers made a deliberate choice: most failures that originate from infrastructure are not recoverable at the point where they occur. If the database is down, what is a controller supposed to do? Retrying might help, but that should be handled at the repository or service level, not forced on every endpoint.
Checked exceptions make sense when the caller can actually react in a different way — like choosing a different file path, or skipping a non-critical service. But in a typical web application, the caller (controller) cannot fix a database outage or a broken network. So forcing it to catch SQLException or IOException is just boilerplate that obscures the actual business logic.
That's why modern best practice leans heavily toward unchecked exceptions for most application-level use cases. Checked exceptions are reserved for API boundaries where the caller is a different team or system, and where the failure mode is both predictable and recoverable.
Unchecked Exceptions — The Controversy That Refuses to Die
Every Java dev hits this wall. You're staring at a codebase where someone threw a NullPointerException from a service layer and called it a day. No declaration. No documentation. Just a runtime surprise waiting for the next deploy.
The language designers made unchecked exceptions unchecked for a reason: they represent programming errors — null checks you forgot, array bounds you didn't validate, arithmetic you didn't guard. These aren't conditions your caller should plan for. They're bugs. Fix them at the source.
But here's where the controversy bites: teams use unchecked exceptions as a get-out-of-jail-free card. They wrap everything in RuntimeException because it's easier than designing a proper exception hierarchy. That's not using the type system. That's abusing it.
The rule is simple: if the error is recoverable, make it checked. If it's a programming mistake, make it unchecked. The moment you throw an unchecked exception for a condition the caller could reasonably handle — like a failed configuration load — you've betrayed the intent of the design. Your callers will thank you when they don't have to grep through logs at 3 AM.
The Performance Cost Nobody Talks About
You've seen the pattern. Some dev wraps a loop in try-catch(Exception) because they're too lazy to validate inputs. Or they throw exceptions for control flow — because throwing a custom exception is 'cleaner' than returning a status code. Stop. That's not clean. That's a performance bomb waiting to detonate.
Creating an exception object is cheap. Filling in the stack trace — that's where the cost lives. When you throw an exception, the JVM walks the call stack, captures every frame, and builds a string representation. In hot paths — high-frequency trading, real-time processing, web request handlers — this adds microseconds per throw. Microseconds you don't have.
Use exceptions for exceptional conditions, not routine business logic. If you're throwing exceptions to signal 'user not found' in a login flow, you're burning CPU cycles for something that happens every second. Return an Optional. Return a result object. Save the stack trace for things that actually need debugging.
The rule: if you can predict the failure, handle it without exceptions. If you can't — genuine I/O errors, network partitions, corrupted data — then throw. Your profiler will thank you.
Overview: The One Rule That Dictates Every Exception Decision
Before you write a single try-catch, you need to understand why Java has this split in the first place. Checked exceptions were a noble experiment: force the caller to acknowledge that something might go wrong. In theory, it prevents silent failures. In practice, it creates cascading throws that bloat your codebase and punish refactoring.
Unchecked exceptions (RuntimeException and its kids) exist because some errors are simply not recoverable—null references, array bounds, illegal arguments. No amount of compiler nagging will fix a bug. The real decision rule isn't academic: if the caller can reasonably recover, make it checked. If the caller can't do anything useful, make it unchecked. Period.
This isn't about dogma. It's about what keeps your production system stable. Every time you reach for a checked exception, ask: "Will the caller actually handle this, or just wrap it in a RuntimeException and move on?" The answer tells you which side of the tree you belong on.
Conclusion: One Rule to Ship Java Code That Doesn't Burn
Here's the brutal truth: the checked vs unchecked war is over, and unchecked won in practice. Spring, Hibernate, JPA — every major framework went unchecked because checked exceptions forced unnatural abstractions and killed productivity. But don't throw out the baby with the bathwater.
Your job is to enforce the boundary where it matters. Checked exceptions at service boundaries where you can offer fallback logic. Unchecked everywhere else. Custom exceptions should carry context — error codes, correlation IDs, stack traces that don't lie. And never, ever catch Exception or Throwable unless you're writing a framework boundary and know exactly why.
The next time a junior asks you which to use, give them this: "Would I want the caller to have to think about this, or is it my problem?" If it's their problem, checked. If it's your bug, unchecked. Ship it.
The Silent Payment Failure: When a Checked Exception Was Swallowed
- A catch block without at least a log is a bug. Period.
- Checked exceptions signal recoverable failures — ignoring them is not recovery.
- Always preserve the original exception as the cause when wrapping.
grep -rn 'catch (' src/main/java | grep -v 'logger' | grep -v 'e)'Add logging: e.printStackTrace() or logger.error("error", e) — never empty.| File | Command / Code | Purpose |
|---|---|---|
| ExceptionHierarchyDemo.java | public class ExceptionHierarchyDemo { | The Inheritance Tree That Controls Everything |
| CustomExceptionDesign.java | class PaymentGatewayException extends Exception { | Writing Custom Exceptions That Actually Communicate Intent |
| LayeredExceptionPattern.java | class UserRepositoryException extends RuntimeException { | The Real-World Pattern |
| ExceptionMistakesAndFixes.java | public class ExceptionMistakesAndFixes { | Common Mistakes That Trip Up Intermediate Developers |
| SpringExceptionPattern.java | @Repository | Checked vs Unchecked in Modern Java Frameworks |
| ExceptionBoundary.java | public class PaymentService { | Unchecked Exceptions |
| HotPathException.java | public User lookupUser(String id) { | The Performance Cost Nobody Talks About |
| ExceptionDecision.java | public class ConfigLoader { | Overview |
| CleanExceptionPattern.java | public class OrderService { | Conclusion |
Key takeaways
Interview Questions on This Topic
Can you explain the difference between checked and unchecked exceptions in Java, and give a design reason why both exist rather than just having one type?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Exception Handling. Mark it forged?
8 min read · try the examples if you haven't