Java Exceptions: Stop Writing Bad Try-Catch Blocks

Well, I have to admit, that pull request last Tuesday had me seriously considering a swim in the ocean. It wasn’t the logic—the business logic was actually quite sound. But the error handling? Yikes. A massive try-catch block wrapping fifty lines of code, ending with a generic catch (Exception e) { e.printStackTrace(); }. Oof.

But we need to talk about this. Java exceptions have been around since the beginning, yet in 2026, I still see developers treating them like annoying mosquitoes to be swatted away rather than critical signals about system health. If you’re building backend services—whether you’re connecting to object storage, a PostgreSQL 16 database, or just parsing JSON—how you handle failure defines how maintainable your code is.

Checked vs. Unchecked: The War is Over

For years, there was a debate. Should we use checked exceptions (forcing the caller to handle it) or unchecked exceptions (runtime failures)? And I’m going to be blunt: Unchecked exceptions won.

Modern Java frameworks like Spring Boot and libraries like Hibernate have largely moved to runtime exceptions. Why? Because forcing a developer to declare throws IOException up a chain of ten methods just creates noise. It makes refactoring a nightmare. As stated in the Spring Framework documentation, “Checked exceptions are often a nuisance in application development, where you rarely want to propagate them to the user, but rather log them and wrap them in a RuntimeException.”

My rule of thumb is simple. If the caller can reasonably recover from the error (like a “File Not Found” where they can ask the user for a new path), use a checked exception. But if the caller can’t do anything about it (like “Database Connection Failed” or “S3 Service Unavailable”), throw a runtime exception. Let it bubble up to a global handler.

Creating Meaningful Custom Exceptions

Don’t just throw RuntimeException. That’s lazy. It tells me nothing about why the code failed. Was it a validation error? A timeout? A permission issue?

I usually define a base exception class for my domain. Here is a pattern I’ve been using in my recent projects running on Java 21.

package com.example.storage.exception;

import java.time.Instant;

// A base unchecked exception for our storage layer
public class StorageException extends RuntimeException {
    
    private final String errorCode;
    private final Instant timestamp;

    public StorageException(String message, String errorCode) {
        super(message);
        this.errorCode = errorCode;
        this.timestamp = Instant.now();
    }

    public StorageException(String message, String errorCode, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
        this.timestamp = Instant.now();
    }

    public String getErrorCode() {
        return errorCode;
    }
}

This gives me a timestamp and an error code I can trace in the logs. It’s practical. It’s clean. As mentioned in the Java Tutorials on creating custom exceptions, “Defining your own exception types can make your API clearer and more usable.”

The “Catch and Wrap” Pattern

This is where most people mess up. They catch an exception and then… nothing. Or they log it and return null. Returning null on error is the billion-dollar mistake.

Instead, catch the low-level exception (like an IOException or SQLException) and wrap it in your domain exception. This preserves the stack trace (crucial for debugging) but abstracts the implementation details away from your business logic.

The Nightmare of Exceptions in Streams

If you use Java Streams (and you should), you’ve run into this wall. You try to call a method that throws a checked exception inside a .map(), and the compiler screams at you. The Lambda structure doesn’t allow checked exceptions.

I used to write ugly try-catch blocks inside my lambdas. It looked terrible. Now, I use a generic wrapper approach. It’s a bit of generic gymnastics, but you write it once and use it everywhere.

@FunctionalInterface
public interface ThrowingFunction<T, R, E extends Exception> {
    R apply(T t) throws E;

    // Static helper to wrap checked exceptions into RuntimeException
    static <T, R> java.util.function.Function<T, R> unchecked(ThrowingFunction<T, R, Exception> f) {
        return t -> {
            try {
                return f.apply(t);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        };
    }
}

Now, look at how clean the usage becomes. Suppose we have a list of file paths and we want to read them all. Files.readAllBytes() throws an IOException (checked).

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;

public class FileProcessor {
    
    public List<byte[]> loadAllFiles(List<String> paths) {
        return paths.stream()
            .map(Path::of)
            // Look how clean this is - no try-catch block visible here
            .map(ThrowingFunction.unchecked(Files::readAllBytes))
            .collect(Collectors.toList());
    }
}

Don’t Log and Rethrow

If there is one thing you take away from this, let it be this: Pick one. Log the error, or rethrow the error. Never do both.

When you do this:

catch (Exception e) {
    logger.error("Something went wrong", e);
    throw e;
}

You create duplicate logs. If the exception bubbles up through five layers and everyone logs-and-rethrows, your log file gets hammered with the same stack trace five times. It makes debugging a mess because you can’t tell if the error happened five times or just once. Let the exception bubble up to the entry point (like your Controller or a global Error Handler) and log it once there.

Final Thoughts

Exception handling isn’t just about preventing crashes. It’s about communication. You are communicating to the future maintainer (which is often you, six months from now, wondering why you wrote this garbage) what went wrong and how to fix it.

Stop catching generic Exceptions. Stop swallowing errors. And for the love of code, stop printing stack traces to System.out in production. Follow the patterns in this article, and you’ll be writing maintainable, debuggable Java code in no time. For more tips on modern Java development, check out my articles on security best practices and functional Java programming.