Domain Model Validation In Kotlin: Part 3

Validations within a context

  • 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

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:

  • simplicity
  • EmailRoute doesn’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 EmailRoute instances (this was our main objective)

Option 2: Send the context as function parameters

The source code for this solution is in the params branch.

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

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

Option 5: Use context receivers

We cover this solution in the context-receiver branch.

  • (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
  • (7–8) we define a context and run multiple tests reusing it
  • (52–53, 67–68) use a different context to simulate validation failures

--

--

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