Domain Model Validation In Kotlin: Part 3
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:
fromaddress 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 (
toproperty) 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:
EmailRoutedoesn’t have any new dependencies
- 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
EmailRouteinstances (this was our main objective)
We may feel compelled to take this approach depending on the lifecycle of the implementations for
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
- (27, 29) we use them to validate our rules,
from !in allowedSendersuses the
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:
EmailRoutefactory 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
- there’s no clear distinction between the parameters that are needed to build an
EmailRotueand 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
multiplythat multiplies two integer numbers
- (3) we define a reference (
double) to a function that fixes the first param of multiply to
- (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
2is a constant and this makes sense, but if
2was a variable, how would we define our
doublefunction?! 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:
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
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
factoryWithContextfunction returns a function: it partially applies the parameters
- (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.
- clear separation of parameters with different lifecycle
- reusable context — we set the first two parameters once and then reuse the partially applied function
- 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
validatedis an extension function on a generic type
- (14, 15) we can see that
Tneeds to implement both
ReceiveEmailConsentsinterfaces, 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
ApplicationContextinstance as a receiver (using the scope function
with) 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
ApplicationContextlike 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 type
T, we’re selecting just the ones we actually need. The
ApplicationContextclass would also be part of our configuration and we don’t want our domain module to have dependencies on infrastructure modules.
- We have all the advantages of the partially applied function solution
- We retain parameter names and default values
- 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
ApplicationContextclass to be able to invoke
- we cannot use an explicit call to
EmailRoute.validated(..)and this reduces the readability of our code; we could rename the function to
validatedEmailRouteor use an import alias
- limitation: this works only if all the receiver upper bounds are interfaces, so if
ReceiveEmailConsentshad 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
contextconstruction to define the receiver types for or
- (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
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.