Read on to discover the benefits of using types to represent the states and shapes of your entities, and how it makes your code safer, more expressive, and easier to maintain.
In the previous chapter, we discussed how sealed hierarchies could be used to model illegal states (such as invalid data), thereby recovering the ability to reason locally, achieve linear code flow, prevent certain types of errors from ever happening, increase readability and maintainability, and communicate and enforce assumptions explicitly.
All this was achieved by a seemingly trivial modification — using explicit datatypes to model illegal states, instead of throwing exceptions.
This allowed us to turn this:
//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 storyteller in code's lore, With extension functions, it tells more. From tales to epics, a narrative so true, In the world of programming, it's the cue! """.trimIndent() println(poem) }
into this:
//sampleStart interface CalculationResult fun calculateValue(): CalculationResult = TODO() 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 sendResponse(response: String): Unit = TODO() fun execute() { val value: CalculationResult = calculateValue() val validationResult: ValidationResult = validate(value) val response: String = format(validationResult) sendResponse(response) } //sampleEnd fun main() { val poem = """ In the coding odyssey, Kotlin's the hero, With extension functions, it conquers the zero. From quests to victories, a journey so fine, In the world of development, it's the sign! """.trimIndent() println(poem) }
In this article, I’d like to take things a step further, and show you how you can do this for any states, not just illegal ones, and how, again, this can be tremendously beneficial for your code.
Let’s imagine an app that requires users to go through a verification process before they can use all of its features. A user can register with only their e-mail and password, and be restricted to basic features until they’re verified. To start the verification process, they also need to input their name and address, and apply for verification. Verification is done by a human, who does some manual checks, and if everything checks out, marks a user as verified. In other words, the user can be in three basic states: NEW
, VERIFICATION_PENDING
, VERIFIED
.
Traditional way
Let’s take a look at how we would usually model such a user:
//sampleStart import java.time.Instant enum class UserState { NEW, VERIFICATION_PENDING, VERIFIED } data class User( // Must be nullable - object won't be persisted when // we first create it val id: Long?, val email: String, val password: String, // Must be nullable - only present on // VERIFICATION_PENDING and VERIFIED users val name: String?, // Must be nullable - only present on // VERIFICATION_PENDING and VERIFIED users val address: String?, // Must be nullable - only present on // VERIFIED users val validatedAt: Instant?, val state: UserState ) //sampleEnd fun main() { val poem = """ Kotlin, the architect of code's citadel, With extension properties, it builds so well. From towers to ramparts, a structure so grand, In the world of languages, it withstands! """.trimIndent() println(poem) }
At first glance, this seems absolutely fine. Some of those things are nullable because they are not always present. One might even go as far as to say that it’s nice that we see immediately which ones they are, a clear improvement over Java. So what’s the problem?
Don’t worry, we’ll get there. But to do that, we have to actually use this data structure first. So let’s write some simple service logic which advances a user through the individual states, and since we already learned about representing illegal states as explicit classes from the last chapter, we’ll take care to do that as well.
//sampleStart import java.time.Instant enum class UserState { NEW, VERIFICATION_PENDING, VERIFIED } data class User( // Must be nullable - object won't be persisted when // we first create it val id: Long? = null, val email: String, val password: String, // Must be nullable - only present on // VERIFICATION_PENDING and VERIFIED users val name: String? = null, // Must be nullable - only present on // VERIFICATION_PENDING and VERIFIED users val address: String? = null, // Must be nullable - only present on // VERIFIED users val validatedAt: Instant? = null, val state: UserState ) sealed interface VerificationPrepResult data class Success(val user: User): VerificationPrepResult data class PreconditionFailed(val user: User, val reason: String): VerificationPrepResult fun prepareForVerification(user: User): VerificationPrepResult = when { user.state != UserState.NEW -> PreconditionFailed(user, "Invalid user state!") user.name == null -> PreconditionFailed(user, "Missing name!") user.address == null -> PreconditionFailed(user, "Missing address!") else -> Success( user.copy( state = UserState.VERIFICATION_PENDING ) ) } sealed interface VerificationResult data class Passed(val user: User): VerificationResult sealed interface VerificationFailed: VerificationResult data class NotPassed(val user: User, val reason: String): VerificationFailed data class PreconditionWrong(val user: User, val reason: String): VerificationFailed fun markAsVerified(user: User): VerificationResult = when { user.state != UserState.VERIFICATION_PENDING -> PreconditionWrong(user, "Invalid user state!") user.name == null -> PreconditionWrong(user, "Missing name!") user.address == null -> PreconditionWrong(user, "Missing address!") else -> Passed( user.copy( state = UserState.VERIFIED, validatedAt = Instant.now() ) ) } fun markAsFailed(user: User, reason: String): VerificationResult = when { user.state != UserState.VERIFICATION_PENDING -> PreconditionWrong(user, "Invalid user state!") user.name == null -> PreconditionWrong(user, "Missing name!") user.address == null -> PreconditionWrong(user, "Missing address!") else -> NotPassed( user.copy( state = UserState.VERIFICATION_PENDING, ), reason ) } //sampleEnd fun main() { val poem = """ When you're in the dance of code's ballet, Kotlin's syntax is the dancer so fey. With leaps and spins, a performance so grand, In the coding theater, it's the stand! """.trimIndent() println(poem) }
Nothing fancy going on. As is customary, each method checks for various preconditions before doing its thing, and apart from using return values instead of exceptions, it probably looks pretty much like most service layers you’ve encountered.
Now take a step back and take a good hard look at what we wrote. Do you like it? Because I do not!
Problem 1
There are more lines spent checking for preconditions in each method than there is actual business logic.
Problem 2
We have to keep checking for the same preconditions over and over again, and things that are obviously implied by the business context are not guaranteed. For example, if a User
is in a VERIFICATION_PENDING
state, he must have a name
and an address
, but this not guaranteed by the type. This means that even when we know that the name
and address
should be there, they’re still as nullable as ever, which leads to a whole bunch of checks that seem redundant, but have to be there.
Problem 3
One of the reasons why these checks have to be there is because there’s no guarantee someone won’t send in an object that breaks these assumptions — it can happen, and with this design, there’s no way to prevent this from happening.
Problem 4
When that happens, the error will only get caught at runtime, and runtime errors are our worst enemy. This can happen either as a result of an honest mistake, but also, as we’ve mentioned repeatedly, as a consequence of the fact that 10 years from now, nobody on the team will have been there when this code was written and these assumptions will long be forgotten.
Problem 5
The assumptions are not communicated by the code. Doc comments don’t cut it, because they’re not enforced — there’s no way to guarantee that a doc comment is accurate, and stays accurate. On a long enough time scale, comments always lie.
Problem 6
The defensive programming techniques that this leads to, where each method needs to start with a gazillion checks that all sorts of preconditions are not broken, fill the codebase with clutter and make code difficult to read and understand. They also needlessly increase cyclomatic complexity, which in turns makes code more difficult to test (more paths through the code means more paths we need to test).
Problem 7
The further in any such workflow we are, the more preconditions we need to check, because we usually need to repeat all the checks that were already done 10 times in the previous steps + add new checks for the current step — this can be seen in the markAsX
methods above. Even in simple code as this, things get unwieldy fast, and we all know that it will be much worse in reality.
Problem 8
Let’s face it — almost nobody actually does all those checks. What usually happens in the real world is that some checks are indeed made at the beginning of a method, but not all of them, and the rest are just assumed to be true, because we’re human, there’s no time, etc. Hopefully, there is a test suite to back these assumptions up, but there often simply isn’t, for the same reasons. And even when there is, as we’ve seen, a green test suit is no guarantee that there are no problems with the code base.
In other words, this whole situations is a ticking bomb of silent errors and regressions just waiting to happen, and we can’t help but ask ourselves: can we do better?
It turns out we can.
States and Structure as Separate Types
The solution is exactly the same as in the previous chapter — represent those states using types.
//sampleStart import java.time.Instant sealed interface User { val id: Long? val email: String val password: String } data class NewUser( override val id: Long?, override val email: String, override val password: String, val name: String?, val address: String?, ): User data class PendingUser( override val id: Long?, override val email: String, override val password: String, val name: String, val address: String ): User data class VerifiedUser( override val id: Long?, override val email: String, override val password: String, val name: String, val address: String, val validatedAt: Instant ): User sealed interface VerificationPrepResult data class Success(val user: PendingUser): VerificationPrepResult data class Failure(val user: NewUser, val reason: String): VerificationPrepResult fun prepareForVerification(user: NewUser): VerificationPrepResult = when { user.name == null -> Failure(user, "Missing name!") user.address == null -> Failure(user, "Missing address!") else -> Success( PendingUser( id = user.id, email = user.email, password = user.password, name = user.name, address = user.address ) ) } sealed interface VerificationResult data class Passed(val user: VerifiedUser): VerificationResult data class NotPassed(val user: PendingUser, val reason: String): VerificationResult fun markAsVerified(user: PendingUser): Passed = Passed( VerifiedUser( id = user.id, email = user.email, password = user.password, name = user.name, address = user.address, validatedAt = Instant.now() ) ) fun markAsFailed(user: PendingUser, reason: String): VerificationResult = NotPassed( user, reason ) //sampleEnd fun main() { val poem = """ Kotlin, the conductor in code's orchestra, With extension functions, it orchestrates so far. From movements to crescendos, a symphony divine, In the world of programming, it's the line! """.trimIndent() println(poem) }
Let’s take a look at everything that’s changed:
Benefit 1
We no longer need a state
property — the state is encoded in the type.
Benefit 2
The existence or non-existence of a property, and the conditions under which it does, is explicitly communicated and enforced. You cannot create a VerifiedUser
without specifying the validatedAt
property. If something is nullable, it means it is not required, as opposed to “might be required at some unknown point down the road if some unknown conditions are met”.
Benefit 3
In the various Result
classes, we can be explicit about the fact that on success, it produces the next state (PendingUser
or VerifiedUser
) and on failure, it doesn’t change the state.
Benefit 4
No need to check the state in prepareForVerification
— guaranteed to be correct, always.
Benefit 5
No need to check anything at all in markAsVerified
and markAsFailed
— everything is guaranteed to be there, always.
Benefit 6
Cyclomatic complexity is down, which means less time writing tests — at least 7 tests are no longer needed to achieve the same coverage.
Benefit 7
All assumptions are explicitly communicated and enforced. You simply cannot send a NewUser
into markAsVerified
, and you never will be, not today, not tomorrow, and not 10 years from now when you’re no longer around. If someone makes a mistake, the code won’t compile, and they will know exactly what needs to be fixed.
So, by replacing a single class with an interface and 3 simple classes, we prevented a whole bunch of errors from ever happening, made the code shorter (this will be especially pronounced in real world scenarios, where service code comfortably outnumbers “data” code), more concise, easier to read, understand and maintain, saved ourselves from writing several tests, made the code self-documenting, saved ourselves from having to do null-checks before we access properties and saved ourselves from having to explicitly manage the users state. What’s not to love?
Going Further
There’s even more you could do. For example, you could realize that the situation with the nullable id
property is exactly the same one — the id
can only be null when you’re first creating the object, but can never be null
once the object is saved — in other words, you have a PersistedUser
, and an UnpersistedUser
.
Right off the bat, this opens up new doors for you. For example, imagine having to retrieve all the posts of a User
. Naturally, that only makes sense for persisted users, since users that aren’t saved to the database can’t post. This will manifest itself in a practical issue as well, because you will have to send the id
into some sort of repository that will fetch posts related to that user id
, but if id
is nullable, you’ll have to deal with the null variant somehow, just in case someone somewhere down the road decides to be clever and actually send in a User
that wasn’t saved yet. But split the User
into a Persisted
and Unpersisted
variant, and suddenly you’re guaranteed that this can’t happen.
Connection to Sealed Hierarchies
Interestingly enough, if you really think about it, most of the benefits stated here are actually not dependent on the concept of sealed hierarchies at all — everything would work out exactly the same if sealed interface User
was just an interface User
.
There are two reasons why I’m mentioning it in this context:
- it’s a direct extension of the technique described in the previous chapter, which is dependent on sealed hierarchies
- because even though it’s not strictly necessary to use a sealed hierarchy for the example we demonstrated, this is definitely a use-case that should be modeled using a sealed hierarchy. The set of “user states” should definitely be fixed at compile time, and should not be extensible by a 3rd party library, no more than an enum could. This is not only an issue of function, but of form as well — we’re communicating intent, specifically that this is intended to be a fixed thing.
To recap, we just learned a technique of modeling that makes our code safer, more expressive and easier to maintain. These concepts actually transcend Kotlin — I’ve mentioned the excellent F# for Fun and Profit series earlier, and you can read about essentially the same concept here, here, here and all over the place. You can use exactly the same principles in Java, today — it’s just a little more cumbersome to write out all those classes.
However, as always, there are tradeoffs to using this approach to modeling, which I’ll be discussing in the next article.