← Back to Blog

Checked Exceptions Were a Mistake and Spring Proved It

Checked exceptions force callers to acknowledge errors, not handle them. Spring's unchecked hierarchy and the @Transactional rollback default show the cost.

The stack trace pointed at a network call. That call was failing silently. No error in the logs. The method returned null. For three weeks, the feature did nothing for every user who triggered that code path. An engineer found the bug by adding a log line. Inside the catch block was one line: // TODO: handle this properly.

The developer who wrote it was not careless. They got a compiler error. The method they were calling declared throws IOException. Java required them to either declare the exception in their own signature or catch it. They chose to catch it. They were in the middle of something else. They left a comment. They moved on. The compiler's requirement was satisfied. The handling was not.

What Checked Exceptions Were Supposed to Do

The original intent was sound. If a method can fail in a meaningful, recoverable way, force every caller to acknowledge it. A file read can fail because the file does not exist. Opening a network connection can fail because the host is unreachable. The compiler would force callers to confront these failures, not skip past them.

The flaw is that acknowledge and handle are not the same thing. A catch block's existence is verifiable. Whether the catch block does anything useful is not. The developer's understanding of the failure mode, or whether they had a recovery strategy, is outside what the compiler can see. Syntax is all it can verify. So checked exceptions shifted the burden from thinking about error handling to satisfying the compiler. Different problems, different solutions.

The Three Ways They Fail

Empty or token catch blocks. The most common pattern. Compiler requires a catch, so the developer writes one. Nothing happens inside it. The exception is swallowed, and the method continues: a wrong return value, or a side effect that was never intended. This is worse than not catching the exception at all, because at least an uncaught exception would produce a visible failure.

Throws propagation. A data access method declares throws SQLException. Its caller, a service, must now either catch it or declare throws SQLException in its own signature. The service has no idea what to do with a SQL exception. It passes it upward. The controller above it passes it further. Six layers of code now couple to the implementation detail that somewhere below them, there is a database. The whole point of layered architecture is that the controller should not know or care that data comes from a database. Checked exceptions leak that knowledge upward through the call stack.

Wrapping without context. The developer wraps the checked exception in a RuntimeException. If they write throw new RuntimeException(e.getMessage()), the original exception and its stack trace are gone. The log shows a RuntimeException with a message string. The original cause, the class, and the full trace have been discarded. If they write throw new RuntimeException(e), the original exception survives as a cause, but a wrapper was added that did not need to exist. Neither is better than throwing an unchecked exception to begin with.

Spring's Answer

Spring's exception hierarchy is entirely unchecked. DataAccessException, HttpMessageNotReadableException, BeanCreationException, TransactionSystemException: all extend RuntimeException. None require a catch block.

JDBC's SQLException is checked. Spring's JdbcTemplate and JPA integration layer catch every SQLException at the boundary, classify it, and rethrow it as the appropriate DataAccessException subclass. By the time an exception reaches application code, it is unchecked. Application code never has to catch a SQLException it cannot handle.

This was not accidental. The Spring documentation explicitly states that forcing programmers to catch exceptions they cannot handle is bad API design. Josh Bloch's Effective Java makes the same point: use checked exceptions only for conditions from which the caller can reasonably recover. If the caller cannot recover, use an unchecked exception. Spring's framework is built on the conclusion that most exceptions at the framework level are not recoverable by the caller. They represent bugs, misconfigurations, or infrastructure failures. The caller should let them propagate to a boundary that can log them and return an appropriate error response. Checked exceptions make this harder, not easier.

The @Transactional Trap

@Transactional rolls back the current transaction when a RuntimeException is thrown. By default, it does not roll back on checked exceptions. This is documented. It surprises almost everyone who encounters it.

A service method annotated with @Transactional saves a record, calls an external service, and the external call throws a checked IOException. Spring's proxy intercepts the exception. It's checked, not a RuntimeException, so the proxy commits the transaction anyway. The record was saved. External call failed. Data is now inconsistent.

@Transactional
public void processOrder(Order order) throws IOException {
    orderRepository.save(order);
    externalService.notify(order); // throws IOException
    // transaction commits even though notify() failed
}

The fix is to throw an unchecked exception at the boundary where the checked exception is caught:

@Transactional
public void processOrder(Order order) {
    orderRepository.save(order);
    try {
        externalService.notify(order);
    } catch (IOException e) {
        throw new OrderNotificationException("Failed to notify for order " + order.getId(), e);
    }
}

OrderNotificationException extends RuntimeException. The transaction rolls back. Original IOException is preserved as the cause, and the log shows both. This is the pattern Spring expects and the pattern that matches how the rest of the framework behaves.

What to Do Instead

Define a small hierarchy of unchecked exceptions for your domain. A base ApplicationException extends RuntimeException. Specific subclasses for specific failure modes: UserNotFoundException, PaymentFailedException, ExternalServiceException. Each carries enough context to be useful in a log.

Catch exceptions only at boundaries: the HTTP controller layer, the Kafka consumer, the batch job entry point. At those boundaries you have enough context to decide whether to retry, return an error response, or log and move on. Everywhere else, let exceptions propagate unchecked. Do not catch them in the middle of the stack where you have no recovery strategy.

When translating from a checked exception at the bottom of the stack, wrap it in your domain exception and preserve the original as the cause. One log line at the boundary shows both the domain context and the original stack trace. That is all you need to debug it.

What to Check Right Now

Search your codebase for empty catch blocks and catch blocks that call e.getMessage() without passing the exception object itself to the logger. Every one of those is a discarded stack trace.

Next, hunt for throws IOException, throws SQLException, or throws Exception in method signatures that are not the actual source of the exception. If a service method declares throws IOException but does not itself open any IO, it is propagating a checked exception it does not own. That exception should have been wrapped at the layer that opened the IO.

Then look for @Transactional methods that declare checked exceptions in their throws clause. Those transactions are not rolling back on failure. The data inconsistency is happening silently every time that exception fires.

The checked exception debate has been settled in practice. Spring settled it. Kotlin removed checked exceptions from the language entirely. The compilers that came after Java mostly agreed. The code that still fights this battle is code written to satisfy the compiler, not to handle errors.

Share
X LinkedIn HN
UI

Umur Inan

Principal Software Engineer

Backend engineer focused on JVM systems, distributed architecture, and the failure modes that only show up in production. I write about what I learn building and breaking things at scale.

👁 0 5 min read

Comments (0)