To Throw or Not To Throw
I often found teams convincing themselves that they don’t do flow control using exceptions because they don’t do constructs like a ResultException
as described in Ward Cunningham’s wiki.
Things may not be that bad in most of these teams.
But I’ve seen exceptions being used in ways that are very close to flowing control, especially when the presence of said exceptions are used to trigger complex business scenarios.
You might have also seen that this indeed an oft-used method, so what is so bad about doing using exceptions this way?
1. Exceptions can increase cognitive-complexity
Rampant use of exceptions in your code-base can make it difficult to read and understand. Remember that every catch
increases cognitive complexity.
My personal preference when it comes to exceptions was always this: exceptions should be used only when something exceptional happens. In other words, throw an exception only when you need to make a fast exit from something bad that has already occurred.
For instance, if the business function being executed is one to capture a payment, and if some dependency required to fulfill that function is unavailable (like the external payment gateway is down) — throw an exception.
Another solution would be to avoid exceptions altogether, and choose another mechanism like a Result
object or an Either monad
However, I’ve not seen every team eager to adopt a new concept like a Result
object. Also, consider that it’s not very easy to migrate existing code.
So this got me thinking. Is there a set of guidelines that we could follow that make the code better readable while still using exceptions?
As such, I tried to prepare a list.
Basic Guidelines
1. Handling Exceptions from external libraries
Handle exceptions thrown from third-party libraries close to where they might be thrown. Do not handle that exception further downstream.
If we let an exception thrown by a third party propagate to all layers of your code, it increases the coupling to that library.
This can also raise subtle, uncaught errors when you switch the library to another one if it is ever required.
2. Respect architectural boundaries
This is fairly similar to the problem above, but I mention it as a separate point because it is something that I have seen so often.
For example, avoid handling a database-specific exception thrown from the database layer in the presentation layer.
3. Handling your own exceptions
If you throw an exception in your code yourself, handle it just once.
This should be done only in one location — hopefully, a dedicated ExceptionHandler
class, if you come from the Spring world.
The reason for this advice is due to the very nature of exceptions, that they can function as non-localized goto
statements.
As such, I would like to downgrade the problems it can raise, by having a single place to look at where an exception is handled. For a future maintainer, this is easier than having to go through the complete execution chain to understand what happens.
This paradigm might raise some interesting problems to solve too.
We often need to catch exceptions that we have thrown ourselves, maybe update a metric or two, or a database entity, and re-throw the same or (even) a different exception so that some code downstream can handle it.
There are ways to do the above things that might involve not using exceptions in the first place, or even revising code architecture so that you satisfy the paradigm that you handle your thrown exceptions just once.
That often is not easy, but it will pay off in the future when the next person tries to read and decipher what is happening.
4. Document API methods
If you are writing a method that throws an unchecked exception, please document it. Simple JavaDoc
comments will do the trick. A method that does not document the exception(s) that it throws lies about its contract.
Help the future maintainers of your code. If they do not have this documentation, it could be that have no idea that it throws
an exception in the non-happy path case.
What usually follows is that they either
- wrap all the code calling this method with
try
andcatch
blocks, making the code unnecessarily verbose (or) - forget to catch the exception, and might end propagating the exception to a downstream layer.
Both, bad places to be in.
Conclusion
These were fairly basic points that came to my mind while working on a recent project and thinking about ways to write more maintainable and easily readable code.
References
- What is cognitive complexity — https://www.sonarsource.com/docs/CognitiveComplexity.pdf
- Monads in Scala — https://www.freecodecamp.org/news/a-survival-guide-to-the-either-monad-in-scala-7293a680006/
- Kotlin Result Object — https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/