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:

  • 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 (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:

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 and receiveEmailConsents as parameters
  • (27, 29) we use them to validate our rules, from !in allowedSenders uses the contains 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 to 2
  • (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 if 2 was a variable, how would we define our double 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 parameters allowedSenders and receiveEmailConsents
  • (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 type T
  • (14, 15) we can see that T needs to implement both AllowedSenders and ReceiveEmailConsents 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 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 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 type T, we’re selecting just the ones we actually need. The ApplicationContext 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 invoke validated(..)
  • we cannot use an explicit call to EmailRoute.validated(..)and this reduces the readability of our code; we could rename the function to validatedEmailRoute or use an import alias
  • limitation: this works only if all the receiver upper bounds are interfaces, so if AllowedSenders or ReceiveEmailConsents 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 or validated 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.

➫ Domain Model Validation In Kotlin: Part 4

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store