Domain Model Validation In Kotlin: Part 3
Validations within a context
In the first part, we used inline classes to set a type-safe foundation for our model, and in the second part, we validated multiple properties and accumulated the errors. We covered most of the use cases with these two steps, but we’re about to receive new specifications that don’t fit our current technical solution.
Our stakeholders are asking us to implement two new validation rules:
from
address must be in a list of allowed email addresses — e.g., we don’t want to impersonate our CEO when sending an email- we must check that our receiver (
to
property) has explicitly accepted to receive our emails
Both our new rules need information outside the boundaries of our EmailRoute
class. They are dependent on an external context.
To check these rules, we will create two interfaces that will allow us to decouple our validation from the implementation. In our examples, we will read the list of allowed senders and the emails with consents from property files (other data sources would work just as well).
We overload the contains
operator to be able to write the rules in a natural way, e.g. from in allowedSenders
There are many ways to implement this in our code; let’s try a few of them and note their advantages and disadvantages. Then we should be prepared to choose the option that fits best our project.
You can find the source code for a working project on GitHub. For the following examples, we will focus on the class EmailRoute
and its tests, EmailRouteSpec
. Where applicable, there’s a particular branch for that solution, and the main branch has the new context receiver solution that I plan on maintaining as this language feature evolves.
Option 1: Apply these new rules before or after creating EmailRoute
In other words, we apply these rules outside our factory function. Implementing this should be straightforward; thus, I didn’t write the code. Let’s think about the pros and cons:
Advantages:
- simplicity
EmailRoute
doesn’t have any new dependencies
Disadvantages:
- we could apply the most costly validations first (checking against a database could be slow)
- validations are scattered all around our codebase
- we lose the guarantee that it’s impossible to create invalid
EmailRoute
instances (this was our main objective)
We may feel compelled to take this approach depending on the lifecycle of the implementations for AllowedSenders
and ReceiveEmailConsents
(e.g. they could come from a dependency injection framework), and we don’t want to add that kind of dependency to our domain, but I think the subsequent solutions are better.
Option 2: Send the context as function parameters
The source code for this solution is in the params branch.
Obviously, besides our business parameters, we also need to receive the functions that enable us to apply the new rules. Even though the implementation is straightforward, let’s look at it for later comparison with the other options.
- (9, 10) we send
allowedSenders
andreceiveEmailConsents
as parameters - (27, 29) we use them to validate our rules,
from !in allowedSenders
uses thecontains
operator
And we also write tests that help us check our new rules and see what the code would look like at the call site:
Let’s take a moment and think about this implementation:
Advantages:
- simplicity
EmailRoute
factory function contains all the validations (this is debatable, but when the number of rules is manageable, I will opt for this approach)- it’s impossible to build invalid instances
Disadvantages:
- there’s no clear distinction between the parameters that are needed to build an
EmailRotue
and the ones required to validate it - the context will probably be the same for the lifespan of our application, but we still need to specify it for every
EmailRoute.validated(..)
call, as we can also notice in our tests
Option 3: Partial function application
If the title seems alien, let’s start with a simple example to understand what partial application means.
- (1) we define the function
multiply
that multiplies two integer numbers - (3) we define a reference (
double
) to a function that fixes the first param of multiply to2
- (5) when we invoke
double
, the second parameter (21
) becomes available and multiply is evaluated
You may ask yourself why complicate our code and return a function instead of directly calling multiply, e.g.:
In this case
2
is a constant and this makes sense, but if2
was a variable, how would we define ourdouble
function?! Returning a partially applied function instead of just applying that function gives us a lot of flexibility.
We have two groups of parameters to our EmailRoute.validate
factory function: allowedSenders
and receiveEmailConsents
, which are available right after the application startup and the rest of the parameters could be different for each invocation. Thus we create a partially applied function that applies the first two parameters and returns a function for us to call with the from
, to
, cc
, and bcc
.
The complete solution for this approach is available in the partially-applied branch.
- (1) our function type is a bit verbose, so we create a type-alias to be able to reuse it in a more compact way
- (11–13) the
factoryWithContext
function returns a function: it partially applies the parametersallowedSenders
andreceiveEmailConsents
- (14) we can see here the parameters that we still need to provide to apply our factory function
Let’s also have a look at the call site in our tests:
It’s interesting to notice how createValidatedEmailRoute
(8) is reused in multiple tests. And how we redefine our context (30, 44) to simulate failures.
After looking at it from two different angles, we can evaluate our solution.
Advantages:
- clear separation of parameters with different lifecycle
- reusable context — we set the first two parameters once and then reuse the partially applied function
Disadvantages:
- the complexity is higher, and it might be harder to follow if we are not familiar with partially applied functions (but we are now)
- no more named parameters or default parameter values; we can partially overcome this using multiple overloads, but it’s verbose and limited
Option 4: Use the context as receiver
Covered in the receiver branch.
We have another way of sending an implicit param to a function: param as a receiver. We will do this by defining an extension function over the context, but one problem arises: we have two parameters, and we can have a single receiver. A way of performing this would be to use a generic type with multiple upper bounds (this concept is also called intersection type or sum type).
Let’s see it in action. For this to work, we also need to change a bit our interfaces definitions, to disambiguate between the function names of the two, renaming contains
to isSenderAllowed
and consentsReceivingEmails
, respectively.
- (8)
validated
is an extension function on a generic typeT
- (14, 15) we can see that
T
needs to implement bothAllowedSenders
andReceiveEmailConsents
interfaces, this is why we cannot have the same function signature in both interfaces - (27, 29) we call the functions directly from the interfaces because our receiver implements them
Now let’s see how we can use our factory, again by writing some tests:
- (1) we need a class that implements both interfaces, and it’s a perfect use case for delegation
- (12) we reuse the same
ApplicationContext
instance as a receiver (using the scope functionwith
) for multiple tests; this is a way we could share the context in our production code, too - (55, 68) we create specific contexts to simulate our possible failures
We may ask ourselves why not use an
ApplicationContext
like class as a receiver and instead, we use a generic type with multiple upper bounds. It’s because an actual application context would contain many more dependencies, and with our generic typeT
, we’re selecting just the ones we actually need. TheApplicationContext
class would also be part of our configuration and we don’t want our domain module to have dependencies on infrastructure modules.
Advantages:
- We have all the advantages of the partially applied function solution
- We retain parameter names and default values
Disadvantages:
- generic types can be harder to follow
- we had to give up on overloading contains operator, which was an excellent fit for our use case
- we have to implement the
ApplicationContext
class to be able to invokevalidated(..)
- we cannot use an explicit call to
EmailRoute.validated(..)
and this reduces the readability of our code; we could rename the function tovalidatedEmailRoute
or use an import alias - limitation: this works only if all the receiver upper bounds are interfaces, so if
AllowedSenders
orReceiveEmailConsents
had been classes, we would have needed to create interfaces for them
After all the critique, it might seem strange, but this is my preferred solution in the current stable Kotlin version (1.6.10). The extra verbosity is a fair compromise over the clear separation between the context and the rest of the parameters.
Option 5: Use context receivers
We cover this solution in the context-receiver branch.
Context receivers is a new feature in Kotlin that will be available for preview in version 1.6.20. Using context receivers, we can send multiple receivers to our factory function without the extra hassle of using a generic type:
- (8) we use the new
context
construction to define the receiver types for orvalidated
function - (26, 28) we can use labels to discriminate between receivers if the function signatures collide
To invoke our function, we need to have both instances of AllowedSenders
and ReceiveEmailConsents
in our scope. For now, we use multiple with
calls. It’s a bit ugly, but it will be improved, keep an eye on KEEP-259 for updates.
- (7–8) we define a context and run multiple tests reusing it
- (52–53, 67–68) use a different context to simulate validation failures
This solution has all the advantages of Option 4 but none of the enumerated disadvantages. I look forward to this feature coming into a final release of Kotlin, as this is only one of its many use cases.
You might have realized by now that we did dependency injection without using any framework, just Kotlin standard library. And we could use the same techniques more broadly in our application. We saw how functions enable us to write tests without using mocks simply by providing test specific implementations. With extension function on generic types and context receivers, we dived into some powerful Kotlin features. They might seem scary at first, but after a bit of practice, we will start to love them.
With this, we finished all the validation types covered in our series. I hope it’s a relevant slice of real-life use cases. But we’re still missing something essential: integrating this in an application; we only created examples isolated in tests.
In the following article, we will integrate all the validations covered so far into an actual application by creating a CLI to interact with our program. If you don’t want any spoilers, avoid looking at the cli package in the GitHub project. But if you do, know that we will try to cover and explain all the code from there.