🚀 · Motivation

5 min read · Updated on by

Read on for a discussion about all the problems with exceptions, how they’re GOTO in disguise, break basic OOP principles, what we actually use them for, and a teaser for a better way of doing things.

Before we start, let me say that the following articles about Programming with Result are, in my view, among the most important ones in the entire set of materials — few, if any, other sections have the potential to more profoundly impact the way you design and write code. In effect, this is a continuation of the articles on strongly typed programming techniques in section on Sealed Hierarchies. If necessary, refresh your memory before reading on.

We previously demonstrated that modeling error states as types leads to safer, simpler and better maintainable code. However, in that section, we dealt mainly with “business” errors — invalid user input, for example — that are defined and happen in the business layer, as part of a business scenario. In other words, the places they occur are places that we actually wrote.

However, those are not the only errors we encounter in the wild. A large portion of errors occur outside our own code — in third party libraries such as Spring, or connectors to databases, or web servers, or even the JVM (StackOverflowErrorOutOfMemoryError etc.). In those situations, we do not have the option of preventing exceptions from being thrown. A natural question to ask is: in these situations, can we apply a similar type-centric approach to modeling error states? This is the question we’ll be answering in the following articles.

The status quo

Let’s take a specific example and look at how things “normally” work. Say you are using a third party library (e.g. Spring) and have a ProductRepository, which has a method retrieveById(id: Long) that retrieves the product associated with the given id. If the id is not found, it throws an ObjectNotFoundException exception.

Now, say you’re writing a ProductService, which should have a retrieve(id: Long) method that you want to implement.

Here’s a naive solution:


//sampleStart
fun retrieve(id: Long) = productRepository.retrieveById(id)
//sampleEnd

You will immediately say that this is bad design — the third-party ObjectNotFoundException exception should not be part of the service layer, it should be translated to a domain-specific exception.


//sampleStart
fun retrieve(id: Long) = try {
   productRepository.retrieveById(id)
} catch (e: ObjectNotFoundException) {
    throw ProductIdDoesntExist(e)
}
//sampleEnd

However, ask yourself this: is that the only exception that is can be thrown? What if the DB gets killed at the precise moment this code is executing? What if the network times out? Just browsing through Hibernate, this is the list of exceptions I found in a single method related to retrieval by idObjectNotFoundExceptionMappingExceptionTypeMismatchExceptionClassCastExceptionJDBCException. If you really wanted to do things by the book, you would have to catch all of these, map them to your domain exceptions and rethrow them. Otherwise, what's the point? The whole reason you created ProductIdDoesntExist is to separate layers, right?

So, if you’re doing things by the book, here’s what you’re actually doing:

  1. you’re spending inordinate amounts of time reading other people’s code and looking for all exceptions that get thrown
  2. you’re catching all those exceptions in every single service method and translating them to your domain exceptions, which you re-throw. And you do this every time you cross layers. This is horrible for many obvious reasons, but especially from a design perspective, you’re tightly coupling your service code with the internals of the library (since the vast majority of these are runtime exceptions) and have to worry about its entire implementation, not just its public interface. This undermines some of the most fundamental pillars of OOP — encapsulation and abstraction.
  3. you’re re-catching all of those exceptions in some other part of the code, where you usually do two things: log the exception and transform it to a value that gets sent to the client. This other place is usually not obviously connected to the place the exception was thrown (such as ControllerAdvice) - in other words, you're basically using GOTO.

But let’s be real, you’re not actually going to do that. Here’s what you’re actually going to do: you’re going to ignore most of the exceptions, just catch the “typical” ones and pretend the other’s don’t exist. God forbid we start thinking about things like ThreadDeathStackOverflowErrorOutOfMemoryError and similar. The end result is that different exceptions get handled in different places, in different ways, leading to different behaviors for the user - some errors get handled nicely, such as displaying an error banner on the front end, while others just completely screw the application up.

So, in effect, you’ve either a) spent lots of time and created lots of classes, so you can create spaghetti code using GOTO in disguise, making the project more complex and less maintainable, or b) you’ve spent a little less time and created lots of classes, so you can create spaghetti code using GOTO in disguise, making the project more complex and less maintainable and also inconsistent.

Why?

Here’s the important part: why are we doing this? What’s the actual, fundamental effect these things will have?

In 99% of the cases, there will be a place (a global try/catchControllerAdvice, an internal Spring/Tomcat mechanism, …) that will take this exception, possibly log it, and convert it to some string value that gets sent back to the client.

In the remaining 1% of the cases, there is either an intermediate step that enriches the information by chaining the exception with a higher level exception (which again gets logged and sent to the client), or, in a tiny minority of cases, we actually do something and try to recover from the error — restart a subsystem, try connecting again, etc.

But for some reason, we feel that we cannot use normal control flow to achieve this, and instead use crazy jumps over half the code base. To make matters worse, we feel that proper design principles demand that all exceptions be translated to versions specific to that layer, even though the practical benefit of doing so is almost always 0. And since we’re only human, in practice this leads to not all exceptions being translated, only some exceptions being logged, some exceptions being logged twice, etc. There’s a lot more that could be said about why exceptions are a pile of problems just waiting to happen.

And all this for…what? To return a value, or do if (failure) { tryRecover() }. So why jump through all those hoops? Why not just do that straight away?

Read on to find out how.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

The Kotlin Primer