leanmind logo leanmind text logo

Blog

BDD

Behaviour-Driven Development is a technique for producing better product requirements.

Avoid try/catch hell without going full functional

By Eric Driussi

Recently I’ve been collaborating with a team in a decently large and complex Java codebase with more than a couple of years in production.

The codebase has gone through various business changes and has quite a lot of edge cases to consider.

Most of the code was done by people that are no longer in the company and.
Consequently, defensive programming is quite prevalent.

As you might expect, Exceptions are thrown everywhere, and the code is riddled with try/catch blocks where the catch blocks effectively act as if/else statements,
This, in turn, makes maintenance and feature development a slow and painful process.

The problem with Exceptions

I’ll concede that handling error cases with Exceptions (or similar constructs in other langs) doesn’t necessarily lead to a disastrous try/catch situation, but to be honest I feel it’s so easy to screw up that I almost expect it to happen.

The danger here is, to put it briefly, that you can easily end up with two semi-parallel execution flows in your mind while reading the code.
Let me explain.

While parsing the try block, all that logic depends not only on what you are actually reading, but also on each possible point of failure along the way.

Each line you read might stop the main execution flow and sending you who knows how many lines below (or files up the stack).

It’s like a less fancy (and less dangerous) GOTO statement, or a weird multi-branch if statement which cannot be refactored to Guard Clauses and whose corresponding else might be in a different file.

It requires you to reason about what a given function returns and what it might throw differently, which is strange since conceptually they are both ways to communicate an operation result up the stack.

A common “solution” to this problem (especially in Java teams) is something like “just let Spring’s @ExceptionHandler handle it”, which of course is (IMHO) significantly worse.

Not seeing the problem doesn’t mean it’s not there, it just means you are blind to it.

Getting out of the mess

A lot of questions were raised regarding how to improve the design, how to minimize these try/catch blocks or how to make the edge cases clearer.

Just like a codebase filled with null checks is (usually) begging for the use of Optional, one with lots of complex try/catch could usually be improved by adding the Either type into the mix.

However, these two are not (in my experience) equally easy to fit into a developer’s toolkit if they haven’t dabbled into functional paradigms before.
This is especially the case with Java, since Optionals are built in but Eithers are not, and Java devs are usually a lot more weary of dependencies than say, the average JavaScript dev.

Plus, Optionals are conceptually easy to grasp: either there is a thing or there is not. It’s close enough to a null (which we are all painfully familiar with), an easy step to take.

Eithers, on the other hand, require you to think rather differently about error management, even more so if you are not used to handling errors by returning them.
They also (usually) come with a bunch of clever sounding functions you can use
and concepts you need to grasp.
It’s not immediately clear what you should do with them, and they can feel esoteric at first.

A comfy compromise

Let’s see if we can build a custom solution, halfway between OOP and functional.

At a very basic level, we’ll need something that represents the result of an operation.
This piece of code should contain (wrap) both the success and the failure of the operation.

Since this won’t necessarily evolve into a fully fledged Either, we could call it Result.
This naming is closer to its intended use case and should (hopefully) deter any functional snob from politely informing us that it’s not technically a proper Either type.

It might look something like this:

public class Result<S, E> {
    private final S success;
    private final E error;
    
    private Result(S success, E error) {
        this.success = success;
        this.error = error;
    }
    
    public static <S, E> Result<S, E> success(S success) {
        return new Result<>(success, null);
    }
    
    public static <S, E> Result<S, E> error(E error) {
        return new Result<>(null, error);
    }
}

Very simple, very basic.

At some point, we will need to check whether the result is an error.
We will also need to unpack the result to maybe show it to the user, or include it in an API response, for example.

public boolean isSuccess() {
    return success != null;
}
    
public boolean isError() {
    return error != null;
}
    
public S getSuccess() {
    return success;
}
    
public E getError() {
    return error;
}

Depending on how the layers of our software are designed, we could have the need to map one error/success into another.
Our errors might be layer specific and our concept of success might mean a different thing in the persistence layer compared with the domain layer.

public class Result<S, E> {
    private final S success;
    private final E error;
    
    private Result(S success, E error) {
        this.success = success;
        this.error = error;
    }
    
    public static <S, E> Result<S, E> success(S success) {
        return new Result<>(success, null);
    }
    
    public static <S, E> Result<S, E> error(E error) {
        return new Result<>(null, error);
    }
    
    public boolean isSuccess() {
        return success != null;
    }
    
    public boolean isError() {
        return error != null;
    }
    
    public S getSuccess() {
        return success;
    }
    
    public E getError() {
        return error;
    }
    
    public <T> Result<T, E> mapSuccess(Function<S, T> fn) {
        return Result.success(fn.apply(success));
    }
    
    public <T> Result<S, T> mapError(Function<E, T> fn) {
        return Result.error(fn.apply(error));
    }
}

Here we have to bring in functions as parameters, but other than that, there is nothing fancy about this code. Boring code is good, Grug likes boring code.

You don’t have to force yourself (or others) to fully go the functional way to reap the benefits from using the bits you actually need.
Just add more capabilities to your Result type as you need them and, if you feel it’s getting unwieldy, ditch it completely in favor of a third party solution.

Add to this some sort of Error interface to ensure type safety and proper mapping, and you are well on your way to minimize thrown Exceptions.
Or don’t! You can start by just having strings on both sides and add fancier types as needed.

Errors will be raised

Of course, this will not prevent third party libs or even your lang’s standard lib from throwing Exceptions.

We can decide to not throw them within the code we write (which is already more
than half the battle), but a lang that provides these constructs will always require you to defend yourself from them.

If a piece of software is reasonably designed, this is relatively easy to deal with:
Wrap your I/O code in a try/catch, build a Result accordingly, and handle
that from that point on.

A simple example would be a piece persistence layer code that gets data from a DB.
Obviously the connection could fail, the data might not be found, you might expect one result and get more than one, etc.

Take the code that talks to the DB and wrap it in a try/catch.
Build your Result from the Exceptions you catch (or might catch), return it to the caller and forget about Exceptions from that point on.

This approach also allows for more flexibility when failure and success are not quite black and white.
Say for instance that your persistence code didn’t find the Data.
That’s just a not found for the persistence layer, but at the application layer the story might be different.
Depending on the use case, that might imply that invalid info was provided for the lookup or that the data was moved or that it should be created without user input.

Map the Result as needed to convey this information between the layers of your software.
You could add a fancier map function to freely map errors to successes or vice versa if needed.

When you reach the piece of code in charge of dealing with these errors and/or presenting them to the user, simply unpack the Result and paint the relevant information (or build the API response, or start your retry policy, you do you).

Conclusion

Exceptions are a pain to get right, and sometimes it’s easier to just avoid them as much as possible.
You don’t have to re-write the codebase or teach your team “the way of the function” to achieve this.

Keep it simple, add just the code you need as you need it.

Make the code work for your team, not the other way around.

Published on 2024/10/29 by

Do you want more? We invite you to subscribe to our newsletter to get the most relevan articles.

If you enjoy reading our blog, could you imagine how much fun it would be to work with us? let's do it!

We boost the professional growth of your development team