Domain Model Validation In Kotlin: Part 4
Writing an (almost) functional app in an (almost) functional way
In the previous article of this series, we tackled the most complex types of validations by exploring different ways we can use context in our rules. We have come a long way, but we only used our code isolated in unit tests. We would like to see how our code would fit in an application.
One of the simplest ways of doing this is to create a CLI (command-line interface) that interacts with our code. The CLI will ask for the user to input the values of the properties needed to address an email: from
, to
, cc
and bcc
. Then it prints the execution result with a mock message instead of sending an email.
Of the variants explored in the previous article, we will go forward with examples from the context receivers solution. Still, you can find branches with the other options: params, partially applied functions, and generic receiver.
Here is a quick recap of the model class that we want to validate and instantiate from CLI:
Dealing with exceptions
Writing a JVM application that doesn’t have to deal with exceptions is a chimaera, but the next best thing is isolating the code that deals with exceptional behaviour.
To achieve this, we will introduce a new data type: Either
. The type signature is very similar to Validated
:
Ignoring the functions that we can apply over them, it seems to be just a renaming: Validated
to Either
, Invalid
to Left
, Valid
to Right
. When choosing between the two data types, the rule of thumb is that we’re working with Either
when we fail fast and go with Validated
when we need to accumulate validation errors. But consider the naming, too: if it’s a validation Validated
might declare the intent better, even when we have a single rule!
In the following code samples, we will return Either
when there is a potential exception and Validated
when we apply validation rules.
We will start with an example of reading the property file that contains the values for two of our validation rules: allowedSenders
and receiveEmailConsents
. A file like this one:
We are now ready to write the code for reading this file, but instead of letting potential exceptions through, we will return an Either
that explicitly states that our function returns either an error or a pair of values. The return type is a mouthful, but we’ll see that it’s easier to deal with it than to type it — that’s one of the reasons why, a lot of times, we let the compiler infer it.
- (1) we define this function as an extension function over any non-nullable type to have access to
this
(in the context ofthis::class
) and get a classpath resource - (5)
use
is an extension function overAutoClosable
that executes a block of code and then closes the resource - (6)
apply
is a scope function that invokes a function with the invoking instance as the receiver, equivalent toval properties = Properties(); properties.load(inputStream)
- (8, 11)
asList
is an extension function we created for convenience to read a comma-separated string as a list of strings (we have to love extension functions):
- (13) we return the
Pair
of valid properties from theuse
block - (14) if everything goes well (no exception thrown), we map the pair to
Right
- (16) if there’s an exception, we return a
Left;
we also need to extend a bit our error hierarchy:
The is even a convenience function to convert exception throwing code to Either
without having to explicitly map it: Either.catch
. We use this to further refactor our readProperties
function:
We will use this technique of wrapping potential exception throwing code in
Either.catch
and converting it toEither
data type to keep the rest of our code base exception free.
We learned a lot from just a few lines of code, and we’re just getting started.
Composing Validated and Either
Let’s recall from the previous article that we need to implement these two interfaces required to validate our model:
Arrow provides us with an elegant way of dealing with both Either
and Validated
data types: either
computation blocks. Inside either
blocks, we have thebind
extension function on Either
and Validated
, and bind
short-circuits the either
block if things go wrong (bind
is applied over aLeft
or an Invalid
). Once we see it in action, in just a moment, this will become obvious.
We will read and validate both string lists from the properties file.
- (1) our function is suspended to be able to use
either
blocks; there is the alternative of usingeither.eager
if we’re not in a suspended context - (3) we start an
either
block, this enables us to use thebind
function onEither
orValidated
- (4 —5)
readProperties
has anApplicationError
on the left, but to be able to compose easily withValidated<ApplicationErrors, T>
(accumulated errors) we will map the single error to a list of errors usingmapLeft
- (5)
bind
short-circuits oureither
computation block; ifreadProperties
returns:
–Right
then the pair of expected values is destructured intoallowedSenderProperties
andreceiveEmailConsentProperties
–Left
then the computation ends (hence the short circuit), and the block evaluates toLeft<ApplicationErrors>
– the same applies toValidated
, mappingValid
toRight
andInvalid
toLeft
- (7— 12) we use our validate function to get valid
allowedSenders
andconsents
(9) then map it to aPair
, then destructure intovalidatedAllowedSender
andvalidatedConsents
variables - (12) we use
bind
again, but this time on aValidated
- (14–15) we can provide simple implementations to
AllowedSenders
andReceiveEmailConsents
, backed by the lists we just read from our property file
We can already identify two reusable patterns from this implementation: composing single errors with multiple errors (5) — this will be easier if we consider a single error as a list of errors with a single element — and in-place parallel validations (7–12). Let’s create some functions to help us reuse this: leftNel
, and validated
returning tuples.
Using these functions we can simplify our code:
- (5) we replaced
mapLeft { it.nel() }
withleftNel()
- (7–10) our new
validate
overload maps our valid values to a pair, and we can destructure it; this is what we meant by in-place parallel validation
We are now able to use our appConfig
function to read the externalized configuration. We also have our domain ready from the previous articles.
Let’s create a CLI that will allow us to read our input and test our validations,
using the same techniques of isolating exceptions with try-catch-to-either (12–17) and either
computation blocks (3):
- (2) the return type of
readInput
looks intimidating, but we can see in ourmain
function below that we can deal with it nicely - (4) we wrap all the input strings into a
Tuple4
: we take some shortcuts in our proof of concept; a new data class would make the code more readable - (12) the potential exception-throwing code is isolated in the
readString
function
Putting everything together
Our main
wraps everything together: reads the configuration, sets the context for validations, reads the input and maps it to an EmailRoute
. We are already familiar with all these techniques from this and the previous articles.
- (1) we are not able to use type inference for the generic types of
either
because main returnsUnit
, thus we specify them explicitly - (4–5) set the validation context — we use the context receivers solution from the previous article
- (6) destructure Tuple4 into the corresponding variables
- (7) create a validated
EmailRoute
and thenbind
the validated value to theemailRoute
variable - (9) we confirm that
emailRoute
is valid by printing it - (12) our
either
block ends here, and we apply thegetOrHandle
function to provide a way of dealing with errors: we print them
In the GitHub source code, I added a loop that executes the program repeatedly to make experimenting easier. Play around to test different failing validation rules, invalid property files, or invalid inputs.
Here is a potential output of our application:
We finally reached the end of our journey:
- In the first part, we used inline classes to validate our simple data types in a type-safe way
- We continued in the second part by validating multiple properties and lists of values and accumulating the errors
- In the third part, we explored multiple ways of dealing with a validation context without using by relying solely on the compiler (without dependency injection frameworks)
- And in this final part, we integrated our model into a demo CLI application that deals with exceptions and validation failures in a unitary and elegant way.
I hope that, at least partially, I managed to reason and explain the techniques applied in the example solution, not only list them as a recipe. Some ideas are opinionated, especially the naming, and I’m open to suggestions and debates. Thus, feel free to comment, and we can start a conversation.
There are excellent resources on the topic. We used them extensively when creating the solution that inspired these articles. Even though I think our use case is not uncommon, we had to put together pieces collected from different places, starting from Arrow documentation, 47degree blog, and Arrow source code. Here are some of my top recommendations: