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.
First, recall the basics.
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.
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
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 Konadimport 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.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")
.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.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
became a normal class, instead of being a data class. This is because there is no way to make private the Unverified
method of a data class.copy
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
With the proposed approach, you don’t need to look at any method implementation to understand:
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:
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.
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()
}
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!