How To Design Domain Model in Kotlin

Written by Pcc | Published 2021/01/15
Tech Story Tags: kotlin | ddd | software-design | functional-programming | functional-design | konad | validation | hackernoon-top-story | web-monetization

TLDR How To Design Domain Model in Kotlin is written in Valiktor by Pcc Luca Piccinelli. It is possible to turn the code into an unequivocal expression of the domain. A typical, persistence oriented, modeling could look like the following: A contact has a name, a surname, and an email. The name can have a middle initial. The email must be verified. You can send password recovery only to verified emails. Maintaining high cohesion also favors the reusability. Using primitive types in the domain model is a code smell.via the TL;DR App

“If it compiles, it works”. With Valiktor and Konad

In Domain Driven Design there is the concept of ubiquitous language. Being trivial, this is usually related to the names you give to the entities in the domain model. 
It is possible to take it a step further. We can turn the code into an unequivocal expression of the domain.
For example, here it follows how an American domain expert may describe a “contact” and some business rules.
A contact has a name, a surname, and an email. The name can have a middle initial. The email must be verified. You can send password recovery only to verified emails.
A typical, persistence oriented, modeling could look like the following:
data class ContactInfo(
    val firstname: String,
    val middleInitial: String,
    val lastname: String,
    val email: String,
    val emailIsVerified: Boolean)
Does this code express any of the requirements? Yes, it enumerates all the data. Anyway, is there any constraint on strings? Are all the data mandatory, or some may be missing?
We can do better, and declare these informations in the model.

Declarative Domain model

First, recall the basics.

Cohesion

Some of the data are related, then it is a good idea to group them.
data class PersonalName(
  val firstname: String, 
  val middleInitial: String, 
  val lastname: String)

data class Email(
  val value: String, 
  val isVerified: Boolean)

data class ContactInfo(
  val name: PersonalName, 
  val email: Email)
This model expresses which information is related. Maintaining high cohesion also favors the reusability.

Declare the constraints

The string attributes of
PersonalName
should not be empty. This is not clear in the code. Let’s clarify it.
data class PersonalName(
    val firstname: NotEmptyString,
    val middleInitial: NotEmptyString?, // Here it is clear that it is optional
    val lastname: NotEmptyString)
Notice that
middleInitial
is nullable. This expresses at compile-time that the middle initial is optional.
I wrapped strings in
NotEmptyStrings
that ensures compliance with the constraints.
inline class NotEmptyString
    @Deprecated(level = DeprecationLevel.ERROR, message = "use companion method 'of'")
    constructor(val value: String){
        @Suppress("DEPRECATION_ERROR")
        companion object{
            fun of(value: String): NotEmptyString = validate(NotEmptyString(value)){
                validate(NotEmptyString::value).isNotBlank()
            }
        }
    }
Here I used an inline class, and I declared a compile-time error to prevent the use of the constructor. This class can be instantiated only using the factory method
of
, hence there is no chance to skip the validation. I used Valiktor to implement it.
Inline classes exist in Kotlin with the purpose to enhance the expressiveness of primitive types. Using primitive types in the domain model is a code smell, known as primitive obsession

Make it compile-time safe

The signature of
NotEmptyString.of
states that if you input a string, you will always get back a
NotEmptyString
fun of(value: String): NotEmptyString
This is a lie. If the validation fails, then Valiktor will throw a
ConstraintViolationException
. The possibility that it throws is not declared in the signature then, you can get an unexpected run-time error. Let’s fix this issue with Konad
import io.konad.Result
inline class NotEmptyString
    @Deprecated(level = DeprecationLevel.ERROR, message = "use companion method 'of'")
    constructor(val value: String){
        companion object{
            @Suppress("DEPRECATION_ERROR")
            fun of(value: String): Result<NotEmptyString> = valikate {
                validate(NotEmptyString(value)){
                    validate(NotEmptyString::value).isNotBlank()
                }
            }
        }
    }
internal inline fun <reified T> valikate(valikateFn: () -> T): Result<T> = try{
    valikateFn().ok()
}catch (ex: ConstraintViolationException){
    ex.constraintViolations
        .mapToMessage()
        .joinToString("\n") { "\t\"${it.value}\" of ${T::class.simpleName}.${it.property}: ${it.message}" }
        .error()
}
Here I used the helper function
valikate
that catches the exception, format its messages with Valiktor API, and wrap everything in
Result
.
The builders
ok()
and
error()
are extension methods from Konad, that build a
Result.Ok
and a
Result.Errors
, respectively.
Look at the new signature:
fun of(value: String): Result<NotEmptyString>
The possibility to fail is now clear. In Konad, Result is a sealed class that can be
Result.Ok
or
Result.Errors
. Using it, the function is free from side-effects.

Compose a
PersonalName

Now we can create some
Result<NotEmptyString>
that may result in an error or the desired value. From those, we need to obtain a
PersonalName
. If you are used to “monad like” structures like Java Optional or Kotlin nullables, then you could expect that I’m going to write an infinite list of 
.flatMap.flatMap.map
… or
let.let.let
….
Well, I’m not. This is where Konad shines. We are going to create a
PersonalName
with the composition API of Konad:
data class PersonalName(
    val firstname: NotEmptyString,
    val middleInitial: NotEmptyString?,
    val lastname: NotEmptyString){
    companion object {
        fun of(firstname: String, lastname: String, middleInitial: String? = null): Result<PersonalName> =
            ::PersonalName.curry()
                .on(NotEmptyString.of(firstname))
                .on(middleInitial?.run { NotEmptyString.of(middleInitial) } ?: null.ok())
                .on(NotEmptyString.of(lastname))
                .result
    }
}
// usage example
when(val nameResult = PersonalName.of("Foo", "", "")){
    is Result.Ok -> nameResult.value.toString()
    is Result.Errors -> nameResult.description("\n")
}.run(::println)
Konad accumulates all the errors. The
println
in the example, is going to print the list of all the errors, separated by a new line with
name.description("\n")
.

Remove flags

An email can be verified or not. In the initial model, there is the boolean flag
isVerified
that keeps this information. 
data class Email(
   val value: String, 
   val isVerified: Boolean)
The flag is not type-safe. It requires to be checked every time that we need a verified Email. For example:
fun sendPasswordRecovery(email: Email) { if(email.verified) sendTo(email.value) }
This can’t be checked at compile-time. My preferred approach is to have a type for the
Unverified
status and one for the
Verified
.
sealed class Email(open val value: String){
    data class Verified(private val email: Unverified): Email(email.value)
    data class Unverified(override val value: String): Email(value)
}
The
sendPasswordRecovery
function can change as follows:
fun sendPasswordRecovery(email: Email.Verified) { sendTo(email.value) }
The
if
has gone, and there is no chance that anyone forgets to check the
isVerified
status. Also, you don’t need to read the implementation to understand that only verified emails can achieve a password recovery.

Validate Email

We need to constrain the construction of an email, as already did for
NotEmptyString
.
To build a
Verified
Email, you need an
Unverified
one. It follows that it suffices to implement the validation only for
Unverified
emails.
sealed class Email(val value: String){
    data class Verified(private val email: Unverified): Email(email.value)
    class Unverified private constructor(value: String): Email(value){
        companion object{
            fun of(value: String): Result<Email.Unverified> = valikate {
                validate(Email.Unverified(value)){
                    validate(Email.Unverified::value).isEmail()
                }
            }
        }
    }
}
Notice that
Unverified
became a normal class, instead of being a data class. This is because there is no way to make private the
copy
method of a data class.

Finally, the declarative model

In the end, this is how the model looks:
data class ContactInfo(
    val name: PersonalName,
    val email: Email)

data class PersonalName(
    val firstname: NotEmptyString,
    val middleInitial: NotEmptyString?,
    val lastname: NotEmptyString){    
    ... // construction code here
}

sealed class Email(val value: String){
    data class Verified(private val email: Unverified): Email(email.value)
    class Unverified private constructor(value: String): Email(value){
       ... // construction code here
    }
}
You can understand all the requirements just by reading it. A contact is composed of a name and an email. A name is composed of three strings that must not be empty. One of those strings may be missing. An email can be unverified or verified.
let’s have a look also to some example services:
class PasswordResetService(){
    fun send(email: Email.Verified): Unit = TODO("send reset")
}

class EmailVerificationService(){
    fun verify(unverifiedEmail: Email.Unverified): Email.Verified? = 
        if(incredibleConditions())Email.Verified(unverifiedEmail) else null
    private fun incredibleConditions() = true
}
Finally, how to build a
ContactInfo
. Again, with the composition API of Konad:
val contact: Result<ContactInfo> = ::ContactInfo.curry()
    .on(PersonalName.of("Foo", "Bar", "J."))
    .on(Email.Unverified.of("foo.bar@gmail.com"))
    .result

Conclusion

With the proposed approach, you don’t need to look at any method implementation to understand:
  • the structure of the data;
  • all the constraints;
  • the flow of the processes that involve those data.
Everything is in the signatures.
It is not trivial to design classes that ensure compile-time safety, without side-effects. I proposed some implementation examples using:
  • Valiktor to implement readable data checks, with few lines of code.
  • Konad to improve compile-time safety. 
The use of monads, highers the code complexity. With Konad you can benefit from their compile-time safety, with a minimum impact on the code complexity. Konad composition API is easy to use and doesn’t require any knowledge of functional concepts.

Code and material

Here it follows the complete code example. You can find it at the following repo: https://github.com/lucapiccinelli/typesafe-domain-model.
fun main(args: Array<String>) {
    val contactResult: Result<ContactInfo> = ::ContactInfo.curry()
        .on(PersonalName.of("Foo", "Bar", "J."))
        .on(Email.Unverified.of("foo.bar@gmail.com"))
        .result
    contactResult
        .map { contact ->
            when(contact.email){
                is Email.Unverified -> EmailVerificationService.verify(contact.email)
                is Email.Verified -> contact.email
            }
        }
        .map { verifiedMail: Email.Verified? -> verifiedMail
            ?.run { PasswordResetService.send(verifiedMail) }
            ?: println("Email was not verified") }
        .ifError { errors ->  println(errors.description("\n")) }
}

object PasswordResetService{
    fun send(email: Email.Verified): Unit = println("send reset to ${email.value}")
}

object EmailVerificationService{
    fun verify(unverifiedEmail: Email.Unverified): Email.Verified? = if(incredibleConditions())Email.Verified(unverifiedEmail) else null
    private fun incredibleConditions() = true
}

data class ContactInfo(
    val name: PersonalName,
    val email: Email)

data class PersonalName(
    val firstname: NotEmptyString,
    val middleInitial: NotEmptyString?,
    val lastname: NotEmptyString){
    companion object {
        fun of(firstname: String, lastname: String, middleInitial: String? = null): 
Result<PersonalName> =
            ::PersonalName.curry()
                .on(NotEmptyString.of(firstname))
                .on(middleInitial?.run { NotEmptyString.of(middleInitial) } ?: null.ok())
                .on(NotEmptyString.of(lastname))
                .result
    }
}

sealed class Email(val value: String){
    data class Verified(private val email: Unverified): Email(email.value)
    class Unverified private constructor(value: String): Email(value){
        companion object{
            fun of(value: String): Result<Email.Unverified> = valikate {
                validate(Email.Unverified(value)){
                    validate(Email.Unverified::value).isEmail()
                }
            }
        }
    }
}

inline class NotEmptyString
@Deprecated(level = DeprecationLevel.ERROR, message = "use companion method 'of'")
constructor(val value: String){
    companion object{
        @Suppress("DEPRECATION_ERROR")
        fun of(value: String): Result<NotEmptyString> = valikate {
            validate(NotEmptyString(value)){
                validate(NotEmptyString::value).isNotBlank()
            }
        }
    }
}

internal inline fun <reified T> valikate(valikateFn: () -> T): Result<T> = try{
    valikateFn().ok()
}catch (ex: ConstraintViolationException){
    ex.constraintViolations
        .mapToMessage()
        .joinToString("\n") { "\t\"${it.value}\" of ${T::class.simpleName}.${it.property}: ${it.message}" }
        .error()
}

Acknowledgments

This article is based on concepts brought to the scene by Scott Wlaschin with its great talk (and book) “Domain modeling made functional”.
Thank you for reading!

Written by Pcc | I am a programmer. I love programming. Any language, any paradigm
Published by HackerNoon on 2021/01/15