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.
Java's exception system is a compile-time enforcement mechanism that forces you to decide how your code fails. Checked exceptions (subclasses of Exception but not RuntimeException) must be declared or caught — the compiler literally won't let you ignore them.
Unchecked exceptions (RuntimeException and its subclasses, plus Error) can slip through silently. This distinction exists because checked exceptions represent recoverable, foreseeable failures (like a missing file or a closed network socket) that callers should plan for, while unchecked exceptions signal programming bugs (null pointer, array index out of bounds) or catastrophic system failures (out of memory) that you typically can't meaningfully recover from at the call site.
The inheritance tree is simple: Throwable branches into Error (JVM-level failures you should never catch) and Exception. Exception splits into checked (everything except RuntimeException) and unchecked (RuntimeException and its descendants). The controversy?
Checked exceptions were designed to improve reliability, but in practice they often lead to empty catch blocks — developers swallow exceptions just to make the code compile. That empty catch (Exception e) {} is where duplicate charges happen: a payment processing failure gets silently ignored, the code continues as if nothing went wrong, and the transaction retries or completes incorrectly.
In modern frameworks like Spring, unchecked exceptions dominate. Spring's DataAccessException hierarchy is entirely unchecked because framework code can't know your recovery strategy. The pattern that works: use checked exceptions for client-facing APIs where the caller can reasonably take alternative action (file not found, insufficient funds), and unchecked exceptions for internal framework code or conditions that indicate programming errors.
Custom exceptions should extend RuntimeException unless you're building a library where callers must handle specific failures — and even then, consider whether an unchecked exception with clear documentation serves better than forcing try-catch noise on every caller.
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.
public class ExceptionHierarchyDemo { public static void main(String[] args) { // --- Checked Exception Example --- // IOException extends Exception directly, so the compiler // FORCES us to handle or declare it. Removing the try-catch // causes a compile error: "unreported exception IOException". try { readConfigFile("/etc/app/config.properties"); } catch (java.io.IOException ioException) { // We handle the realistic possibility that the file isn't there System.out.println("Config file problem: " + ioException.getMessage()); } // --- Unchecked Exception Example --- // NumberFormatException extends RuntimeException, so no compile // error if we skip the try-catch. The compiler trusts us. // But at runtime, passing a bad string will blow up here. String rawUserInput = "42"; // pretend this came from a form field int parsedAge = Integer.parseInt(rawUserInput); // safe with "42" System.out.println("Parsed age: " + parsedAge); // Now intentionally cause an unchecked exception to show the output String corruptInput = "forty-two"; try { int badParse = Integer.parseInt(corruptInput); } catch (NumberFormatException numberFormatException) { // NumberFormatException is unchecked — we only catch it // at the boundary where raw input enters our system System.out.println("Bad input caught at boundary: " + numberFormatException.getMessage()); } // Demonstrate the hierarchy programmatically NumberFormatException nfe = new NumberFormatException("demo"); System.out.println("Is RuntimeException? " + (nfe instanceof RuntimeException)); // true System.out.println("Is Exception? " + (nfe instanceof Exception)); // true java.io.IOException ioe = new java.io.IOException("demo"); System.out.println("IOException is RuntimeException? " + (ioe instanceof RuntimeException)); // false System.out.println("IOException is Exception? " + (ioe instanceof Exception)); // true } // 'throws IOException' is mandatory — omitting it is a compile error static void readConfigFile(String filePath) throws java.io.IOException { java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(filePath)); } }
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.
// ── Checked: realistic external failure the caller must plan for ── class PaymentGatewayException extends Exception { private final int httpStatusCode; public PaymentGatewayException(String message, int httpStatusCode) { super(message); this.httpStatusCode = httpStatusCode; } // Always include a cause constructor — wrapping preserves stack traces public PaymentGatewayException(String message, int httpStatusCode, Throwable cause) { super(message, cause); // passes the original exception up the chain this.httpStatusCode = httpStatusCode; } public int getHttpStatusCode() { return httpStatusCode; } } // ── Unchecked: programming contract violation, not environmental failure ── class InvalidOrderStateException extends RuntimeException { private final String fromState; private final String toState; public InvalidOrderStateException(String fromState, String toState) { super(String.format( "Illegal state transition: cannot move order from [%s] to [%s]", fromState, toState )); this.fromState = fromState; this.toState = toState; } public String getFromState() { return fromState; } public String getToState() { return toState; } } // ── Service that uses both ── class OrderService { enum OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED } // 'throws PaymentGatewayException' is REQUIRED by the compiler // because PaymentGatewayException is checked. This is the contract // telling every caller: handle the gateway being unavailable. public void chargeCustomer(String customerId, double amountInDollars) throws PaymentGatewayException { boolean gatewayReachable = simulateGatewayCall(); if (!gatewayReachable) { // Wrap the low-level HTTP detail with a meaningful domain exception throw new PaymentGatewayException( "Payment gateway timed out for customer: " + customerId, 504 ); } System.out.println("Charged $" + amountInDollars + " to customer " + customerId); } // No 'throws' declaration needed — unchecked exceptions are silent contracts public void transitionOrderStatus(OrderStatus current, OrderStatus next) { // SHIPPED → PENDING is a programmer error, not an environmental condition if (current == OrderStatus.SHIPPED && next == OrderStatus.PENDING) { throw new InvalidOrderStateException(current.name(), next.name()); } System.out.println("Order moved: " + current + " → " + next); } private boolean simulateGatewayCall() { return false; // simulating an unreachable gateway } } public class CustomExceptionDesign { public static void main(String[] args) { OrderService orderService = new OrderService(); // Checked exception — compiler REQUIRES this try-catch try { orderService.chargeCustomer("CUST-9912", 149.99); } catch (PaymentGatewayException paymentException) { // Caller handles the recoverable failure: maybe retry, or alert the user System.out.println("Payment failed (HTTP " + paymentException.getHttpStatusCode() + "): " + paymentException.getMessage()); System.out.println("Queuing payment for retry..."); } // Unchecked exception — no try-catch required by compiler // Only catch it at your application's outermost error boundary orderService.transitionOrderStatus( OrderService.OrderStatus.CONFIRMED, OrderService.OrderStatus.SHIPPED ); // This next line would throw InvalidOrderStateException at runtime // because SHIPPED → PENDING is a logic bug in the calling code try { orderService.transitionOrderStatus( OrderService.OrderStatus.SHIPPED, OrderService.OrderStatus.PENDING ); } catch (InvalidOrderStateException stateException) { System.out.println("Bug caught: " + stateException.getMessage()); } } }
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.'
import java.sql.*; // ── Domain-level unchecked exception — service layer speaks in domain terms ── class UserRepositoryException extends RuntimeException { public UserRepositoryException(String message, Throwable cause) { super(message, cause); // ALWAYS preserve the cause for stack trace logging } } class UserNotFoundException extends RuntimeException { private final long userId; public UserNotFoundException(long userId) { super("No user found with ID: " + userId); this.userId = userId; } public long getUserId() { return userId; } } // ── Infrastructure layer: wraps checked JDBC exceptions into unchecked domain ones ── class UserRepository { private final Connection databaseConnection; public UserRepository(Connection databaseConnection) { this.databaseConnection = databaseConnection; } public String findUsernameById(long userId) { String query = "SELECT username FROM users WHERE id = ?"; try { PreparedStatement statement = databaseConnection.prepareStatement(query); statement.setLong(1, userId); ResultSet results = statement.executeQuery(); if (!results.next()) { // Business rule violation — unchecked, the caller should validate input throw new UserNotFoundException(userId); } return results.getString("username"); } catch (SQLException sqlException) { // KEY PATTERN: catch the infrastructure checked exception here, // wrap it in our domain unchecked exception, preserve the cause. // The service layer never sees SQLException — it stays decoupled. throw new UserRepositoryException( "Database error while fetching user ID: " + userId, sqlException // <-- cause preserved for logging ); } } } // ── Service layer: clean business logic, no SQL concerns ── class UserProfileService { private final UserRepository userRepository; public UserProfileService(UserRepository userRepository) { this.userRepository = userRepository; } // Notice: no 'throws' clause. The service layer is clean. // If UserRepositoryException propagates, the controller's global // error handler catches it and returns a 500. Clean separation. public String buildWelcomeMessage(long userId) { String username = userRepository.findUsernameById(userId); // no try-catch needed return "Welcome back, " + username + "!"; } } // ── Presentation layer: only catches what it can meaningfully respond to ── class UserController { private final UserProfileService profileService; public UserController(UserProfileService profileService) { this.profileService = profileService; } public String handleGetProfile(long userId) { try { return profileService.buildWelcomeMessage(userId); } catch (UserNotFoundException notFoundException) { // Caught specifically — return a 404 response return "404: User " + notFoundException.getUserId() + " not found"; } // UserRepositoryException (database failure) is NOT caught here. // It propagates to the framework's global exception handler → 500 response. // That's the correct behaviour: the controller can't fix a database outage. } } public class LayeredExceptionPattern { public static void main(String[] args) throws Exception { // Simulate the lookup of a non-existent user (no real DB needed) // We'll mock the behaviour directly to show the flow // Simulating UserNotFoundException path UserController controller = buildMockController(false); String response = controller.handleGetProfile(99L); System.out.println("Controller response: " + response); } // Creates a controller backed by a fake repository for demo purposes static UserController buildMockController(boolean userExists) { UserRepository mockRepo = new UserRepository(null) { @Override public String findUsernameById(long userId) { if (!userExists) { throw new UserNotFoundException(userId); } return "alice"; } }; return new UserController(new UserProfileService(mockRepo)); } }
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.
import java.io.*; import java.util.logging.*; public class ExceptionMistakesAndFixes { private static final Logger logger = Logger.getLogger(ExceptionMistakesAndFixes.class.getName()); // ════════════════════════════════════ // MISTAKE 1: Swallowing the exception // ════════════════════════════════════ static void badReadFile(String filePath) { try { new FileInputStream(filePath); } catch (FileNotFoundException e) { // ❌ WRONG: empty catch block. The failure disappears. // The caller gets null behaviour with zero explanation. } } static void goodReadFile(String filePath) throws FileNotFoundException { try { new FileInputStream(filePath); } catch (FileNotFoundException fileNotFoundException) { // ✅ RIGHT: log it, then either handle it or rethrow logger.severe("Cannot open config file at: " + filePath); throw fileNotFoundException; // rethrow so the caller knows } } // ═══════════════════════════════════════════════ // MISTAKE 2: Catching Exception too broadly // ═══════════════════════════════════════════════ static void badBroadCatch(String[] items, int index, String filePath) { try { String item = items[index]; // might throw ArrayIndexOutOfBoundsException new FileInputStream(filePath); // might throw FileNotFoundException Integer.parseInt(item); // might throw NumberFormatException } catch (Exception e) { // ❌ WRONG: all three failures look identical here. // A bug in your index logic is hidden alongside a missing file. System.out.println("Something failed: " + e.getMessage()); } } static void goodSpecificCatch(String[] items, int index, String filePath) { // ✅ RIGHT: handle each failure mode where you can act on it specifically if (index < 0 || index >= items.length) { // Validate input before use — don't rely on catching your own bug throw new IllegalArgumentException( "Index " + index + " is out of range for array of size " + items.length ); } String item = items[index]; try { new FileInputStream(filePath); } catch (FileNotFoundException fileNotFoundException) { logger.warning("File not found: " + filePath); // Handle the file-missing case specifically — maybe use a default } try { Integer.parseInt(item); } catch (NumberFormatException numberFormatException) { logger.warning("Item '" + item + "' is not a valid integer"); // Handle the bad-format case specifically } } // ═══════════════════════════════════════════════════════ // MISTAKE 3: Losing the original cause when wrapping // ═══════════════════════════════════════════════════════ static class DataLoadException extends RuntimeException { public DataLoadException(String message, Throwable cause) { super(message, cause); } } static void badWrap(String filePath) { try { new FileInputStream(filePath); } catch (FileNotFoundException fileNotFoundException) { // ❌ WRONG: the original FileNotFoundException and its stack // trace are thrown away. In production logs, you'll only see // DataLoadException with no trace back to the root cause. throw new DataLoadException("Could not load data", null); } } static void goodWrap(String filePath) { try { new FileInputStream(filePath); } catch (FileNotFoundException fileNotFoundException) { // ✅ RIGHT: pass the original exception as the cause. // Both exceptions appear in the stack trace. throw new DataLoadException( "Could not load data from: " + filePath, fileNotFoundException // <-- cause preserved ); } } public static void main(String[] args) { // Demonstrate the cause-preservation difference try { goodWrap("/no/such/file.csv"); } catch (DataLoadException dataLoadException) { System.out.println("Top-level message: " + dataLoadException.getMessage()); System.out.println("Root cause: " + dataLoadException.getCause().getMessage()); } } }
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.
import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Repository; import org.springframework.web.bind.annotation.*; // ── Spring Repository: throws unchecked DataAccessException ── @Repository class UserRepository { public String findUsernameById(Long userId) { // Spring Data JPA would wrap SQLExceptions into DataAccessException // Here we simulate it: if (userId == null || userId < 1) { throw new IllegalArgumentException("userId must be positive"); // unchecked, programming bug } // Simulating a database failure: boolean databaseDown = Math.random() < 0.1; // 10% chance if (databaseDown) { throw new DataAccessException("Cannot connect to database") {}; // unchecked } // Simulating user not found: if (userId > 1000) { throw new jakarta.persistence.EntityNotFoundException("User not found with id: " + userId); // unchecked } return "alice" + userId; } } // ── Controller: no checked exception handling needed ── @RestController class UserController { private final UserRepository repository; public UserController(UserRepository repository) { this.repository = repository; } @GetMapping("/users/{id}/username") public String getUsername(@PathVariable Long id) { // No try-catch for DataAccessException or EntityNotFoundException // Spring's global handler (@ControllerAdvice) will handle them return repository.findUsernameById(id); } // But we CAN optionally catch specific unchecked exceptions if we want to // @ExceptionHandler(EntityNotFoundException.class) // public ResponseEntity<String> handleNotFound(EntityNotFoundException e) { // return ResponseEntity.status(404).body(e.getMessage()); // } }
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.
// io.thecodeforge — java tutorial // Don't do this: hiding recoverable failures as unchecked public class PaymentService { public void processPayment(Order order) { try { PaymentGateway.charge(order.getTotal()); } catch (GatewayTimeoutException e) { // THIS IS WRONG: timeout is recoverable via retry throw new RuntimeException("Payment failed", e); } } } // Do this: let callers decide how to recover public class PaymentService { public void processPayment(Order order) throws RetryablePaymentException { try { PaymentGateway.charge(order.getTotal()); } catch (GatewayTimeoutException e) { throw new RetryablePaymentException( "Gateway timed out, retry with backoff", e ); } } }
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.
// io.thecodeforge — java tutorial // AVOID: exception as control flow public User lookupUser(String id) { try { return userCache.get(id); } catch (UserNotFoundException e) { // Every cache miss throws — expensive on high traffic return fetchFromDatabase(id); } } // PREFER: return a sentinel or Optional public Optional<User> lookupUser(String id) { User cached = userCache.getIfPresent(id); if (cached != null) { return Optional.of(cached); } return Optional.ofNullable(fetchFromDatabase(id)); }
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.
// io.thecodeforge — java tutorial // Recoverable: caller can retry or use fallback public class ConfigLoader { public Config load(String path) throws IOException { // Checked — caller can try different path return new Config(Files.readString(Path.of(path))); } } // Not recoverable: programming mistake public class PaymentProcessor { public void charge(Account acct, double amount) { if (acct == null) { throw new IllegalArgumentException("Account cannot be null"); } // Unchecked — null means bug, not recoverable state } }
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.
// io.thecodeforge — java tutorial public class OrderService { // Checked at boundary — caller can retry or use cache public Order fetch(long orderId) throws RepositoryException { return repo.findById(orderId) .orElseThrow(() -> new RepositoryException("Order not found: " + orderId)); } // Unchecked internally — null is a bug public void validate(Order order) { if (order.getTotal() < 0) { throw new IllegalArgumentException("Negative total: " + order.getTotal()); } } // Never catch blindly public void process(long orderId) { try { Order order = fetch(orderId); validate(order); } catch (RepositoryException e) { // Recover: log and use cached order } } }
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.grep -rn 'throws.*SQLException' src/main/java | grep -v 'repos' | grep -v 'dao'Create an unchecked domain exception and wrap at the repository boundary.grep -rn 'extends Exception' src/main/java | xargs grep -L 'Throwable'Add a constructor that takes message and cause, calls super(message, cause).| Aspect | Checked Exception | Unchecked Exception |
|---|---|---|
| Extends | Exception (directly) | RuntimeException |
| Compiler enforcement | Must handle or declare with 'throws' | No compiler requirement |
| Typical cause | External/environmental failure (file, network, DB) | Programming error or contract violation |
| Real-world examples | IOException, SQLException, ParseException | NullPointerException, IllegalArgumentException, ArrayIndexOutOfBoundsException |
| Caller expectation | Caller can meaningfully recover | Caller should fix the code, not catch |
| Method signature impact | Appears in 'throws' clause — part of the public API | Invisible in signature — implicit contract |
| Where to catch | Wherever you can take meaningful recovery action | At system boundaries (global error handlers) |
| Custom exception pattern | Extend Exception; use for recoverable domain failures | Extend RuntimeException; use for API contract violations |
| Spring framework approach | Rare — Spring wraps most into unchecked | Preferred — DataAccessException hierarchy is all unchecked |
| Best practice in 2026 | Use sparingly, only when caller can take alternative action | Preferred for most application code; handle via global handlers |
| 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
Common mistakes to avoid
5 patternsSwallowing exceptions with empty catch blocks
logger.error() call; either rethrow the exception or wrap it in an unchecked exception before rethrowing. Never leave a catch block empty.Propagating checked exceptions across architectural boundaries
Losing the original cause when wrapping exceptions
Catching Exception or Throwable too broadly
Using checked exceptions for programming contract violations
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?
If you're building a repository layer that calls JDBC and throws SQLException, would you expose that checked exception in your service layer interface? Why or why not — and how would you handle it?
RuntimeException is a subclass of Exception, so why doesn't catching Exception also force you to catch RuntimeException — and does catching Exception actually catch unchecked exceptions at runtime?
When would you choose to create a custom checked exception vs a custom unchecked exception? Give a concrete example of each.
Frequently Asked Questions
Yes — and this is a common, recommended pattern. Catch the checked exception and rethrow it wrapped inside a class that extends RuntimeException, passing the original as the cause constructor argument. This is exactly what Spring does with SQLException → DataAccessException. The key is to always pass the original exception as the cause so the full stack trace is preserved in your logs.
Use checked exceptions when the failure is environmental and the direct caller can take a meaningful alternative action — for example, a payment gateway being unreachable. Use unchecked exceptions when the failure represents a programming contract violation — for example, passing a null order ID to a method that explicitly requires one. When in doubt, modern Java practice (and frameworks like Spring) leans toward unchecked to avoid polluting method signatures.
Yes — at runtime, catching Exception catches everything except Errors, because RuntimeException is a subclass of Exception. This is precisely why catching Exception broadly is dangerous: you'll accidentally swallow NullPointerExceptions and other programming bugs alongside the specific checked exception you intended to handle. Always catch the most specific exception type you can meaningfully act on.
Because Spring's designers recognised that at the point where infrastructure failures occur (database, network, file I/O), the immediate caller — usually a controller or service — cannot take meaningful recovery action. Forcing every caller to catch and handle SQLException would create massive boilerplate with no benefit. Using unchecked exceptions allows the framework to provide a global exception handler (@ControllerAdvice) that centralises error handling, keeping business logic clean.
'throws' is used in a method signature to declare that the method might throw a checked exception. It is mandatory for checked exceptions that are not caught inside the method. 'throw' is the actual statement that throws an exception instance. For checked exceptions, you must either catch them (try-catch) or declare them (throws). For unchecked exceptions, neither is required, though you may still use throws for documentation purposes (compiler ignores it).
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