Senior 5 min · March 05, 2026

Java Exceptions — Why Empty Catch Blocks Cause Duplicate Charges

Empty catch blocks caused duplicate charges when IOException was swallowed.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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.
Plain-English First

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.

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.

ExceptionHierarchyDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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));
    }
}
Output
Config file problem: /etc/app/config.properties (No such file or directory)
Parsed age: 42
Bad input caught at boundary: For input string: "forty-two"
Is RuntimeException? true
Is Exception? true
iIOException is RuntimeException? false
IOException is Exception? true
The One-Line Rule:
Extends Exception directly → checked (compiler enforces handling). Extends RuntimeException → unchecked (compiler stays silent). That's the entire distinction. Everything else is a consequence of this.
Production Insight
I've seen teams confuse unchecked vs checked when using Spring's DataAccessException hierarchy — it's all unchecked, but some developers still add throws clauses.
The real cost: when you mark a method as throws a checked exception that never actually occurs, you create unnecessary coupling. Every caller must handle it.
Rule: if callers can't productively respond to the exception, make it unchecked.
Key Takeaway
The hierarchy is the rule.
Everything else is design theory.
Checked = environmental, Unchecked = programmer error.
Choosing Exception Type at Design Time
IfFailure is environmental (file missing, network down, DB unavailable)
UseUse checked exception — the caller should plan for it.
IfFailure is a programming bug (null arg, invalid state, illegal index)
UseUse unchecked exception — fix the code, don't catch.
IfFailure can be handled by the immediate caller with alternate logic
UseUse checked exception to force handling.
IfFailure cannot be meaningfully handled by any layer (e.g., config error)
UseUse unchecked exception — propagate to global handler for logging.

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.

CustomExceptionDesign.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// ── 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());
        }
    }
}
Output
Payment failed (HTTP 504): Payment gateway timed out for customer: CUST-9912
Queuing payment for retry...
Order moved: CONFIRMED → SHIPPED
Bug caught: Illegal state transition: cannot move order from [SHIPPED] to [PENDING]
Pro Tip:
Always add a constructor that accepts a 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.
Production Insight
In one production incident, a team created a checked FileProcessingException but forgot to chain the cause. When an IOException occurred inside, the log showed only their custom message, but the original 'File not found' line number was lost.
Debugging took 2 hours because the wrapper exception pointed at the wrong line in the catch block.
Always, always chain the cause.
Key Takeaway
Custom exceptions are documentation.
A checked exception says 'plan for this'.
A runtime exception says 'your code has a bug'.
Custom Exception Design Decision Tree
IfWill callers need to catch this and take alternate action?
UseMake it checked. Extend Exception directly.
IfDoes this exception represent a bug in client code?
UseMake it unchecked. Extend RuntimeException.
IfAre you exposing internal implementation details?
UseWrap the infrastructure exception in an unchecked domain exception.

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.'

LayeredExceptionPattern.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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));
    }
}
Output
Controller response: 404: User 99 not found
Watch Out:
Never let 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.
Production Insight
Teams using Spring Data often forget that DataAccessException is unchecked. I've seen controllers with try-catch for DataAccessException that just log and return 500 — that's fine, but it's redundant because the global handler would do the same.
The real risk is the opposite: ignoring DataAccessException thinking it's checked. Spring won't force you to handle it, and if you don't, your transaction might roll back but the user sees a 200 with missing data.
Rule: always have a global exception handler for unhandled runtime exceptions.
Key Takeaway
Infrastructure exceptions stay in infrastructure.
Wrap them at the boundary.
Your business logic should never see SQLException.

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.

ExceptionMistakesAndFixes.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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());
        }
    }
}
Output
Top-level message: Could not load data from: /no/such/file.csv
Root cause: /no/such/file.csv (No such file or directory)
The Golden Rule:
If you catch it and can't fully handle it, you must either log-and-rethrow, or wrap-and-rethrow with the original as the cause. A catch block that just prints a message and lets execution continue is almost always a bug waiting to surface in the worst possible moment.
Production Insight
Empty catch blocks are the most common code smell in legacy systems. I've triaged incidents where a FileNotFoundException was swallowed during a config load, and the app ran with default settings for weeks — until a critical feature relied on a config that silently defaulted to null.
The fix was always: never catch unless you can do something about it. If you can't, rethrow (possibly wrapped).
The second most damaging mistake is catching Exception too broadly — it turns NullPointerException into a harmless log line while the system continues with corrupted state.
Key Takeaway
Empty catch blocks are ghost bugs.
Catch broadly and you mask programming errors.
Always rethrow or wrap with cause.

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.

SpringExceptionPattern.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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());
    // }
}
Output
No fixed output — depends on random condition. On database failure, controller returns 500 with error message; on missing user, returns 500 unless @ExceptionHandler is added.
Framework Design Insight:
Spring could have made DataAccessException checked. But the team understood that forcing every repository client to catch it would create massive boilerplate for no benefit — 99% of callers can't do anything useful with a database connection failure. That's why they chose unchecked.
Production Insight
Teams new to Spring often add 'throws DataAccessException' to their service methods because they're used to checked exceptions. That works but is pointless — the throws clause is meaningless for runtime exceptions.
Worse, some teams catch DataAccessException in every controller method and log it, thinking they're being thorough. They're just duplicating the global exception handler.
Rule: if you're using a framework that uses unchecked exceptions, trust the global handler. Add controller-specific handling only when you need custom HTTP response codes.
Key Takeaway
Modern frameworks prefer unchecked for a reason.
Most callers can't fix infrastructure failures.
Reserve checked exceptions for truly recoverable, caller-actionable failures.
● Production incidentPOST-MORTEMseverity: high

The Silent Payment Failure: When a Checked Exception Was Swallowed

Symptom
Customers reported duplicate charges. The payment logs showed no errors — just successful transactions with no corresponding orders.
Assumption
The team assumed the payment gateway's response was always valid; the IOException catch block was meant for 'rare network glitches' and was left empty.
Root cause
A checked IOException from the HTTP client was caught with an empty catch block. The exception never reached any monitoring, and the code continued as if the payment succeeded. The second charge came from an automatic retry because the first response was never captured.
Fix
Replace the empty catch block with a logging statement and rethrow a custom unchecked PaymentException with the original cause. Add a global exception handler to catch all PaymentExceptions and roll back the transaction. Also add a health check on the gateway connection before charging.
Key lesson
  • 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.
Production debug guideSymptom -> Action guide to fix the most common exception handling defects4 entries
Symptom · 01
Application logs show 'Exception caught' but no stack trace
Fix
Check for empty catch blocks or catch (Exception e) { e.getMessage() } without logging. Add a logger.error("message", e) with the exception as second argument.
Symptom · 02
Service method signature declares throws SQLException, leaking database details
Fix
Catch SQLException at the repository boundary, wrap in an unchecked domain exception (e.g., DataAccessException) with the original cause, and remove SQLException from the service signature.
Symptom · 03
NullPointerException is caught broadly and logged as 'something went wrong'
Fix
Don't catch NullPointerException at all — fix the code that caused it. Use Optional or explicit null checks. Only catch at the very top of the request pipeline for a generic 500 response.
Symptom · 04
Custom exception is thrown but the original cause is lost in logs
Fix
Verify custom exception constructors always take Throwable cause and pass it to super(message, cause). grep for 'new MyException("message", null)' or single-argument constructors.
★ Exception Handling Quick Debug Cheat SheetRun these commands and checks when an exception handling bug surfaces in production
Checked exception silently ignored
Immediate action
Check catch blocks for empty or non-logging code. Use grep on the catch clause to find 'catch.*{}$' or 'catch.* $'.
Commands
grep -rn 'catch (' src/main/java | grep -v 'logger' | grep -v 'e)'
Add logging: e.printStackTrace() or logger.error("error", e) — never empty.
Fix now
Replace empty catch with: logger.error("Failed to ...", e); throw new RuntimeException(e);
Leaking implementation exception (e.g., SQLException) in service layer+
Immediate action
Check method signatures for throws clauses that include JDBC or IO exceptions. Search for 'throws SQLException' or 'throws IOException' in service interfaces.
Commands
grep -rn 'throws.*SQLException' src/main/java | grep -v 'repos' | grep -v 'dao'
Create an unchecked domain exception and wrap at the repository boundary.
Fix now
catch (SQLException e) { throw new DataAccessException("DB error", e); }
Exception cause lost — logs show only wrapper exception+
Immediate action
Check custom exception constructors: do they accept and chain Throwable cause? View stack trace depth in log aggregation tool.
Commands
grep -rn 'extends Exception' src/main/java | xargs grep -L 'Throwable'
Add a constructor that takes message and cause, calls super(message, cause).
Fix now
public MyException(String message, Throwable cause) { super(message, cause); }
Checked vs Unchecked Exceptions at a Glance
AspectChecked ExceptionUnchecked Exception
ExtendsException (directly)RuntimeException
Compiler enforcementMust handle or declare with 'throws'No compiler requirement
Typical causeExternal/environmental failure (file, network, DB)Programming error or contract violation
Real-world examplesIOException, SQLException, ParseExceptionNullPointerException, IllegalArgumentException, ArrayIndexOutOfBoundsException
Caller expectationCaller can meaningfully recoverCaller should fix the code, not catch
Method signature impactAppears in 'throws' clause — part of the public APIInvisible in signature — implicit contract
Where to catchWherever you can take meaningful recovery actionAt system boundaries (global error handlers)
Custom exception patternExtend Exception; use for recoverable domain failuresExtend RuntimeException; use for API contract violations
Spring framework approachRare — Spring wraps most into uncheckedPreferred — DataAccessException hierarchy is all unchecked
Best practice in 2026Use sparingly, only when caller can take alternative actionPreferred for most application code; handle via global handlers

Key takeaways

1
The entire distinction is in the inheritance tree
extends Exception directly = checked (compiler enforces it); extends RuntimeException = unchecked (compiler ignores it). No other magic.
2
Checked exceptions are a compiler-enforced contract saying 'this realistic external failure will happen
you must have a plan.' Unchecked exceptions say 'your code has a bug — fix it, don't catch it.'
3
Wrap checked infrastructure exceptions (SQLException, IOException) into unchecked domain exceptions at your layer boundaries
this keeps business logic clean and prevents implementation details from leaking through your API signatures.
4
Always pass the original exception as the 'cause' when wrapping
losing the cause makes production debugging brutally difficult because the root stack trace is discarded forever.
5
Modern frameworks like Spring prefer unchecked exceptions for most cases because callers can't meaningfully recover from infrastructure failures at the point of call.

Common mistakes to avoid

5 patterns
×

Swallowing exceptions with empty catch blocks

Symptom
The program appears to run but produces wrong results silently, with no log entry and no stack trace to debug from
Fix
At minimum add a 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

Symptom
Your service method ends up with 'throws SQLException', coupling your business logic to your database driver and leaking infrastructure details through every layer
Fix
Catch the checked infrastructure exception at the repository/DAO layer, wrap it in an unchecked domain exception (passing the original as the cause), and let that propagate instead.
×

Losing the original cause when wrapping exceptions

Symptom
You throw 'new MyException(message, null)' instead of passing the caught exception as the cause — your production logs show the wrapper message but the original stack trace pointing to the real failure line is gone forever
Fix
Always pass the caught exception as the second argument: 'new MyException(message, originalException)'.
×

Catching Exception or Throwable too broadly

Symptom
A catch(Exception e) block silently swallows NullPointerException from your own bugs alongside the IOException you intended to catch, masking programming errors
Fix
Catch the most specific exception type you can act on. Use multi-catch for unrelated exception types: catch (IOException | SQLException e).
×

Using checked exceptions for programming contract violations

Symptom
A method that validates input throws a checked InvalidInputException, forcing every caller to handle what is fundamentally a bug in the calling code
Fix
Use IllegalArgumentException or a custom unchecked exception for input validation failures. Reserve checked exceptions for truly external, recoverable failures.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Can you explain the difference between checked and unchecked exceptions ...
Q02SENIOR
If you're building a repository layer that calls JDBC and throws SQLExce...
Q03SENIOR
RuntimeException is a subclass of Exception, so why doesn't catching Exc...
Q04SENIOR
When would you choose to create a custom checked exception vs a custom u...
Q01 of 04JUNIOR

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?

ANSWER
Checked exceptions extend Exception directly and the compiler enforces handling or declaration. Unchecked exceptions extend RuntimeException — the compiler stays silent. Both exist to keep the signal-to-noise ratio sane. If every possible error (including null pointer dereferences) required a try-catch, code would be unreadable. Checked exceptions are for realistic external failures that a caller can plan for. Unchecked exceptions are for programming mistakes that should be fixed, not caught. The language designers deliberately split them so that callers aren't forced to handle issues they can't meaningfully recover from.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can you convert a checked exception to an unchecked exception in Java?
02
Should I use checked or unchecked exceptions for my custom business exceptions?
03
Does catching Exception catch both checked and unchecked exceptions?
04
Why do most Spring framework exceptions extend RuntimeException?
05
What is the difference between throws and throw in relation to checked exceptions?
🔥

That's Exception Handling. Mark it forged?

5 min read · try the examples if you haven't

Previous
throws and throw in Java
5 / 6 · Exception Handling
Next
Multi-catch and Finally Block