Java has a reputation problem. Mention “exceptions” in a room full of developers and someone will inevitably say something like “good concept, terrible implementation” followed by a suggestion that we should all be using errors-as-values instead.
After working with Java for a while, I’ve realized something interesing: Java’s exception philosophy is actually really good. The part that’s broken is how we use it.
Why I Prefer Exceptions Over Errors-as-Values
Since I started working with Java, I’ve found myself consistently preferring
exceptions for error handling instead of return types like the new
std::expected in C++ or Rust’s Result enum.
One big reason is separation of concerns. When a method succeeds, it simply returns a value.
When it fails, normal execution stops and control jumps to a catch block.
There’s no half-success state where you have to remember to inspect a return value every single time.
This also massively reduces boilerplate. Compare these two mental models:
- Open a file, check return value, log something, read the file, check return value, log something again
Code
var reader = openFile(file); if (reader == null) { LOGGER.error("Ooops..."); return; } var bytes = reader.readBytes(); if (bytes == null) { LOGGER.error("Ooops..."); return; } - Assume everything works inside a
tryblock and handle all failures in one placeCode
try { var bytes = openFile(file).readBytes(); } catch (IOException _) { LOGGER.error("Ooops..."); }
The second approach is not just shorter — it’s easier to reason about. You describe the happy path once, and the unhappy path once. No need for a mixture of both or some functional nesting.
Checked vs. Unchecked: A Feature, Not a Bug
Java’s split between checked and unchecked exceptions is often used as “proof” that Java got exceptions wrong. I strongly disagree.
Checked exceptions are incredibly useful when used correctly. If a method declares a checked exception, the compiler guarantees that you can’t accidentally ignore it. So as long as the method author made the right call, you literally cannot forget to handle the error.
The challenge, however, is that many developers don’t internalize this distinction. To see how this concept can work well in practice, let’s take a quick look at Rust.
A Rust Perspective
Rust encourages developers to distinguish between recoverable and irrecoverable errors.
Recoverable errors are returned as Result<T, E>, which the caller is expected to handle.
Irrecoverable errors, on the other hand, trigger a panic!(),
which unwinds the stack and signals a bug or an unrecoverable situation.
In practice, Rust developers automatically think about whether an error can be handled or if it should crash the program. Java’s checked vs unchecked exceptions should serve the same purpose: checked exceptions for recoverable errors the caller can handle, unchecked exceptions for unrecoverable ones.
Java even has an advantage here: recoverable errors are handled via exceptions rather than return values, which keeps the happy path clean and separates error handling from normal logic.
The problem is that, unlike Rust, many Java APIs and developers fail to use this distinction properly — which is why exception handling often feels messy in the wild.
This misuse isn’t theoretical — it shows up in real code all the time, even in libraries you’d expect to get it right.
Where Things Get Messy
What I see far too often are methods that throw unchecked exceptions for things that are very much expected to fail. A classic example: parsing with Google GSON.
If parsing a JSON config fails, that’s not some exotic, programmer-error-only scenario. That’s a real, expected failure mode. Throwing an unchecked exception here is basically saying:
“Yes, parsing failed, but I don’t think you should handle this. Let your application crash instead.”
On the flip side, we also get APIs that throw checked exceptions for things that are extremely unlikely to fail if the developer uses them correctly. This leads to everyone’s favorite Java pattern:
try {
doSomething();
} catch (Exception e) {
throw new RuntimeException(e);
}
This is not only boilerplate — it actively makes stack traces worse. We add an extra layer of indirection, gain no new information, and still don’t actually recover from anything.
This, my friends, is not a language flaw but a misjudgement the developer made.
How It Can Be Done Right
The frustrating boilerplate we just saw is not inevitable.
Some frameworks show that checked exceptions can be handled gracefully,
so the caller never has to write catch + throw new RuntimeException.
A great example is Spring’s JdbcTemplate.
When interacting with a database, JDBC throws an SQLException
— a checked exception that would normally force you to catch it everywhere.
Spring wraps these SQLExceptions once at the API boundary into
an unchecked DataAccessException.
This means you can write clean, straight-line code like:
List<User>; users = jdbcTemplate.query(
"SELECT * FROM users",
(rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name"))
);
No try-catch, no RuntimeException wrapping, and the stack trace remains clean.
If something truly goes wrong, the exception still contains
all the original information — but the caller isn’t burdened with boilerplate.
The thing with method design
Using unchecked exceptions for irrecoverable error and checked exceptions for recoverable ones definitely is a good idea but in practice, the difference between these two scenarios often depends on the caller’s context and cannot be reliably predicted when writing the method. Let’s again have a look at a real-world example:
JavaFX, a popular framework for writing modern desktop applications, allows programmers to store nodes and layout hierarchies inside an XML-based .fxml file format which can be parsed to a Java object at runtime using the FXMLLoader class.
Purposely, this class supports loading fxml data from a URL, allowing the caller to dynamically load a document tree from the web, local files, etc. If accessing the URL fails, a checked IOException is thrown.
Even though this makes sense when actually loading files from the filesystem, in most use cases the fxml file will be bundled in the application JAR. In these cases, the caller can always assume that the file will be accessible which leads to most developers writing the cassic catch (Exception e) { throw new RuntimeException(e); }.
Start writing unchecked wrapper methods
So what can you do to provide an API that can handle both cases - depending on the caller’s context? The answer is actually something, we’ve already seen with Java Swing: A simple unchecked wrapper.
When writing your code, work with checked exceptions and expose this method to the caller. Then, add a wrapper method that internally uses a try-catch and throws an unchecked exception to support for cases where a caller can be sure that the action won’t fail (for example because of guard clauses on the caller’s side).
With our FXMLLoader example, the implementation would look something like this:
// Checked base method for cases where the
// location might not point to an
// existing resource
public <T> T load() throws IOException {
[...]
}
// Unchecked wrapper method for cases where the
// caller can be sure that the location points
// to an existing resource
public <T> T loadUnchecked() throws RuntimeException {
try {
return load();
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
Even tough this effectively just moves the try-catch from the caller to the API itself, this approach has two major advantages:
- We do not burden the caller with boilerplate. This simple refactor already improves the readability and flow of the caller’s source code.
- We provide a simple way to indicate the intention of the code. By explicitly calling the
loadUncheckedmethod, the caller shows that it expects the execution to work without any problems, improving the way a reader understands how the logic is supposed to work.
Of course, this design pattern is not applicable in every scenario: Sometimes the code is just pretty much needed to throw checked exceptions and in these cases, I do not think that the described approach makes much sense. Still, there definitely are cases in which a bit more flexibility in method design choises can make a huge difference and you should at least think about whether some unchecked wrapping is able to help you out.
So… Is Java’s Exception System Bad?
In my opinion: no. Java has a solid, well-thought-out exception system. It gives API designers the tools to clearly communicate what can fail, how it can fail, and who is responsible for handling it.
The problem is that those tools are often used poorly — sometimes even by very large, popular frameworks.
If Java exceptions feel painful, it’s usually not because the language got it wrong. It’s because someone, somewhere, chose the wrong kind of exception and made it everyone else’s problem.
So if your stack traces are messy, don’t blame Java. Blame whoever wrote a library that makes unreasonable assumptions about your use case instead of actually supporting you and your code. And maybe… check yourself before you wreck yourself.