Domain Model Validation In Kotlin: Part 2
In the first part, we used inline classes to set a type-safe foundation for our model. It was an important step, but we’re a long way from considering our whole domain validated: we just validated email addresses.
Next, we will validate multiple properties, accumulate the errors, apply individual element validations to lists of elements, and create validation rules that depend on numerous properties.
It’s time to go back to our EmailRoute class.
Validating multiple properties and accumulating errors
Let’s start with only one validation rule: each Email property (from, to, cc and bcc) must be a valid email address. Since we can only build valid instances of Email, our work here seems to be done.
Yet, as it turns out, creating an instance is a bit tedious. In a real-world example, the values for the properties will probably come from deserialization (e.g. JSON) and have the type String. For now, let’s ignore cc and bcc, focusing on the single value properties. Let’s also recall the type signature for the Validated hierarchy and find some clues on how we can handle these types:
A simple way to test that both properties are valid would be to use a when expression, taking advantage of smart casting. Take a moment to analyze this code:
- (6) both from and to are valid so that we can construct an EmailRoute; from and to are Valid<Email>
- (7)(8) only one of the values is invalid, we return the invalid one directly; Invalid<E> has a single type param, and it can substitute both Validated<ValidationErrors, Email> and Validated<ValidationErrors, EmailRoute>
- (9) both emails are invalid, therefore we have to concatenate the errors
How scalable is this if we add more validated properties? For n properties, the number of branches would be 2ⁿ.
Fortunately, the Validated class has the zip function that will help us automatically group invalid errors and thus lets us focus on how to handle validations passing. Let’s see this in action, and then we will take a closer look at the parameters we used.
Let’s take a step back and remember the zip function from Kotlin lists. It takes the first element from the first list, the first element from the second list, creates a pair of those two and invokes a function with those pairs. Then it moves to the second pair of elements, and so on until there are no more elements in at least one of the collections.
Ignoring the first parameter, from.zip(…, to) works in a similar way, the difference being that, unlike a list, from and to can have at most one (valid!) element. The parameters of our lambda (validFrom, validTo) will always be Valid instances. If any of the emails is not valid, zip won’t invoke our lambda, and it will return an Invalid.
Now let’s go to the first parameter: Semigroup.nonEmptyList(). Sounds scary? A semigroup is one of the most basic structures: it only has one (binary) operation to combine two elements. We can create a semigroup of lists because we have an operation (plus) that we can use to combine two lists. Thus by providing Semigoup.nonEmptyList() as the first parameter, we’re just specifying that, for invalid instances, we want to concatenate non-empty lists.
Since we’re always dealing with lists of errors, let’s go even a step further and hide these details behind our own functions. We won’t need to worry about zipping and semigroups anymore:
Now we can refactor our EmailRoute validation and make it reusable through a companion factory function to apply it:
Validating lists of elements
And now we get to what seems to be the most challenging part: validating email lists. But having overloaded our validate function to at least four parameters, the solution is trivial:
traverseValidated returns an Invalid that accumulates all the errors if there is at least one invalid element. If (and only if) all elements are valid, it returns a Valid that contains a list of valid values (in our case Valid<List<Email>>).
Validation rules that depend on multiple properties
We just received a new feature request: we cannot have both cc and bcc empty (hypothetical, educational purposes). It seems pretty straightforward to integrate it into our validation: write the condition before creating the EmailRoute instance and return an Invalid if it fails (please try it!).
But looking closer (at the return type of f — last param of validate and zip), we notice that we are in a function that can only return the value that will be wrapped by a Valid. What to do?! Maybe we could post-process our Validated instance before returning it? It turns out that we can:
We will again apply our pattern: try to understand the code, extract it in some reusable function with a familiar name (andThen), and forget about FP.
fold takes two parameters:
- the first one is a function to map the errors — we rewrap the errors in an Invalid (I used function reference ValidationErrors::invalid because two lambda parameters don’t look very natural)
- the second one is a function to map valid values — and here we’re adding extra conditions transforming if needed our valid value into an invalid one
Update: since I first wrote this code (I procrastinated on writing these articles),
andThen was added to Arrow core as an extension function to
Validated. There’s no more need to write our own extension. This is yet another example of how powerful extension functions are: we just import arrow.core.andThen, the rest of the code remains unchanged.
Now we can have a look at our full implementation:
So far, for simplicity, we have focused on the code. In real life however, we also need to test it. Let’s see what unit tests for EmailRoute would look like, using the Kotest testing framework:
I think we got to a sound stage with our code, and maybe it’s a bit easier to understand, thanks to our helper functions.
In the following article, we will tackle another kind of validation: validations that depend on an external context. We will see why that is a challenge and how context receivers help us create an elegant solution.