🚀 · Modeling Illegal States

4 min read · Updated on by

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 ControllerAdviceExceptionHandler 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 and FullValidationResult)
  • 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 ExceptionHandlers, no ControllerAdvice 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.

Leave a Comment

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

The Kotlin Primer