Domain Model Validation In Kotlin: Part 1
Inline classes: more type-safety for less runtime overhead
Validations, surprisingly, proved to be one of the most challenging tasks when modelling our domain. The majority of my team’s projects are in Java, and for most of our colleagues, the project where we used this approach was an introduction to both Kotlin and Functional Programming (FP). I am writing this series of articles to curate and explain what I found helpful from Arrow, a functional programming library for Kotlin, for our project. These articles could serve as documentation for my colleagues, while you can adapt and reuse the following examples for your use cases.
I know it’s an overused example, but it so happens that our model has a lot to do with emails (true story). Part of our platform’s responsibility is handling email message notifications, so the following examples are as close as it gets to real-life code. The actual rules for validating the email were simplified because we used an external library, and I wanted to keep the dependencies for this sample code reduced to a minimum.
The primary goal of our validations is to make it impossible to build invalid instances of our domain model classes. As a result, we don’t have to worry about the validity of our domain later: it is trustworthy. And maybe we can also trick the compiler into doing some of the heavy liftings.
To get the most out of the examples, I recommend becoming familiar with Kotlin.
I previously mentioned Arrow, but you don’t need any previous knowledge of it. I will explain from scratch everything we use from Arrow.
If you want to code along, here is the Gradle (build.gradle.kts) file that I used:
Let’s start by defining our model: a class that groups the information regarding the sender and destination of an email. We will call it EmailRoute, having the properties: from, to, cc, and bcc.
A simple approach would be to use a data class:
Not so fast! We don’t have any validations yet. But there is another, more subtle problem with our code: there is no straightforward way of knowing that from and to should be email addresses or that cc and bcc should be lists of emails. We could rename to fromEmail, toEmail, etc., but that only becomes a naming convention. This is a variant of the primitive obsession code smell. We are using naming conventions over type safety. Let’s do better!
We created an Email class that clearly states where we expect emails. In our case, it will only be a wrapper over a String, but there could be other complex implementations (e.g. parsing email into parts). Using inline classes for wrapper types like this one, we can take full advantage of compile-time type checking without the runtime overhead added by a full-blown class.
And now comes the second advantage of having created this class: we can add validations to it.
Wait, where is the promised FP way?! Don’t worry; this was just an example of a Kotlin idiomatic way of validating input. Here’s why we want to refactor to a functional approach:
- To get rid of the side effects (here: the exception IllegalArgumentException thrown by require); we want to know what to expect from a function just by looking at its signature, and we can only find out about this exception by looking at the implementation or by having it documented (or at runtime, ouch!).
- If we had multiple validation errors (e.g. there are more fields to validate), only the first error would be thrown, and accumulating errors would not be straightforward.
To avoid some of those problems, we will create a companion object factory function (aka static factory method in Java) and use it (exclusively!) to create Email instances. Instead of using the actual constructor, we will use the valueOf function:
By returning Email? (the nullable type) we are clearly stating that we are not always able to create an Email and will return null if such is the case. And by making the constructor private, we ensure that only our factory function can create new Email instances.
The advantages are that it’s a simple solution and that we can do this using only the standard library. But, we have a drawback: why did the validation fail?! So this solution could work if there is only one well-known validation rule.
We can be explicit about the failure reason(s) using the Validated type. Our valueOf function now returns a Validated<ValidationErrors, Email> instance, having:
- the invalid variant (Invalid<ValidationErrors>) if the email is not valid; we will create Invalid instances using the invalidNel() extension function;
- the valid variant (Valid<Email>) if the email is valid; we will use the valid() extension function to create Valid instances;
Since we later plan to accumulate multiple errors, we created a convenient type alias for a list of errors (ValidationErrors). NonEmptyList (also referred to as Nel, e.g. invalidNel) is a list type from Arrow that always has at least one element. Returning an invalid instance with an empty list of errors wouldn’t make sense, would it?
There are multiple ways to use our validation results, each with a different purpose. Here are examples of some of the usual ways:
If the name mapLeft looks strange, think about the Validated type’s generic signature: Validate<E(rror), A(ctual)>. The type of error is the left generic and the type of valid result the right one. Another mnemonic could be: “left is wrong, right is right”.
If we have mapLeft, where is mapRight? The Validated type is “right biased”, meaning it’s optimised to process and compose right(valid) results. Most of the function names that don’t have a Left/Invalid/Error suffix could be read as having the Right suffix, but’s it’s omitted for brevity. So, in the previous example, you can read tap as tapRight/tapValid, and map as mapRight/mapValid.
To confirm that our Email class behaves and see how we can deal with Validated in our tests, let’s write some unit tests. For this, we will use Kotest, a Kotlin testing framework that provides assertions extensions for working with Arrow data types.
First, let’s add the testing dependencies to our Gradle file:
Now we are ready to write the tests. I won’t get into details, but no matter what testing framework you are using, keep in mind that you need to test both the type (Valid/Invalid) and the content (Email).
Our little (both as LOC and footprint) Email class has already achieved a lot: it’s impossible to create invalid instances, the compiler is used to type-check our code, and we don’t have side effects when errors get in our way. Neat!
Before moving forward, I want to mention some other related patterns (but I won’t use them to keep the code as less confusing as possible):
- Smart constructor that looks like the actual constructor, using the operator fun invoke instead of a custom function name. This can be astonishing because a constructor, by specification (!), should evaluate to an instance (non-null) of the homonymous class.
- Overloading the invoke operator to extract the value in the inline class, this way, we can refactor email.value to email():
- Create ValidationErrors with lazy messages, using again the invoke operator:
In the rest of the series, we will see how to:
- validate multiple fields and accumulate the errors
- validate each element of a list in an elegant way
- apply validations that depend on multiple fields
- create (extension) functions to improve readability
- apply validations that need an external context (and here we’ll see the power of the context receivers feature, added in Kotlin 1.6.20)