Read on to find out how to use sealed hierarchies to shift away from exceptions thereby preventing runtime errors, enforcing assumptions, recovering the ability to reason locally and achieving linear code flow.
Take a look at the following code, which is basically a template for every business rule ever written:
//sampleStart interface CalculationResult fun calculateValue(): CalculationResult = TODO() fun valid(result: CalculationResult): Boolean = TODO() fun format(value: CalculationResult): String = TODO() fun sendResponse(response: String): Unit = TODO() fun execute() { val value: CalculationResult = calculateValue() if(!valid(value)) { throw Exception("Invalid value calculated!") } val response: String = format(value) sendResponse(response) } //sampleEnd fun main() { val poem = """ Kotlin, the architect of code's realm, With extension properties, it takes the helm. From realms to kingdoms, a structure so grand, In the world of languages, it commands! """.trimIndent() println(poem) }
At first glance, there really doesn’t seem to be anything wrong with the way the code is written. However, on closer inspection, we might realize that there are in fact some potential problems.
For one, the format
function may implicitly rely on the fact that value
is valid. This could cause problems in two different scenarios:
- 10 years from now, this requirement is long forgotten, and
format
gets called with a value that was not validated first - a new business requirement arises which requires different “levels” of validation. Again,
format
might expect those parts to be there from the beginning, and gets called with input that was validated in a different way than it expects.
A specific example of the latter case might be CalculationResult = ClientData
, where we're validating that we have all the client's data. When we're first creating a client, we might only require a name and an e-mail, but once we’re at the point where we’re e.g. signing a contract, we also need an address and bank account. So we modify the valid
function to only check the appropriate parts of ClientData
based on the context and think we're done. However, we don't realize that format
expects all the values to be there, and get a runtime error — the worst kind of error.
Another problem is that we don’t know which “outputs” can actually be generated by this code. If everything goes well, we can see clearly what happens, but what if the result is not valid? An exception gets thrown, and that gets handled in one of the callers, i.e. someplace else. It’s not clear where that place is, how we get there from here (there are usually multiple callers) and what happens there. Things get even more complicated when using things like ControllerAdvice
, ExceptionHandler
and similar constructs (which is often the case in the real world). To be sure of what happens what an exception gets thrown, one must backtrack through all possible execution paths from the point the exception gets thrown, which is not a feasible approach. In other words, exceptions break local reasoning.
In total, this means that there are (at least) two different places where responses get produced, which means (at least) two different places we need to be aware of, manage, maintain and keep in sync when making changes. Worst of all, we need to do all this manually — the compiler will not let us know if we change one, but forget to change the other.
It turns out that these problems have a solution. The core idea is to represent everything (including error states) as data types:
interface CalculationResult fun calculateValue(): CalculationResult = TODO() fun sendResponse(response: String): Unit = TODO() //sampleStart sealed class ValidationResult data class Valid(val result: CalculationResult) : ValidationResult() data class Invalid(val message: String) : ValidationResult() fun validate(value: CalculationResult): ValidationResult = TODO() fun format(result: ValidationResult): String = when(result) { is Valid -> TODO() is Invalid -> TODO() } fun execute() { val value: CalculationResult = calculateValue() val validationResult: ValidationResult = validate(value) val response: String = format(validationResult) sendResponse(response) } //sampleEnd fun main() { val poem = """ When you're in the voyage of code's sea, Kotlin's syntax is the captain so free. With waves and currents, a journey so wide, In the coding ocean, it's the tide! """.trimIndent() println(poem) }
Even in code as simple as this, this approach leads to a markedly better results.
The key benefits are:
- Illegal states (in fact all states) are explicit, and represented by types.
- Function signatures communicate and enforce assumptions (
format
requires its input to be validated first, and the type denotes the manner in which the validation is performed — you can differentiate between e.g.PartialValidationResult
andFullValidationResult
) - The data flow is completely linear. There are no branches, jumps, no catch blocks, no special situations, it’s just
calculateValue -> validate -> format -> sendResponse
- The ability to reason locally is recovered. Formatting of all data is done in one place, for all scenarios. Again, no special situations, no alternative ways a response can get sent, no
ExceptionHandler
s, noControllerAdvice
etc. - Since we use sealed classes, whenever we add a new state (e.g. a
PartialValidationResult
), we are immediately told which parts of the code we need to adapt. We’ve completely removed a whole category of errors. Again.
For more on this, I highly recommend reading this great article. In fact, you should go ahead and read the entire series, it will make you a better person.