paint-brush
How To Design Domain Model in Kotlinby@Pcc
5,223 reads
5,223 reads

How To Design Domain Model in Kotlin

by Luca PiccinelliJanuary 15th, 2021
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

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.

Coin Mentioned

Mention Thumbnail
featured image - How To Design Domain Model in Kotlin
Luca Piccinelli HackerNoon profile picture

“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("[email protected]"))
    .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("[email protected]"))
        .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!