Domain Model Design Enhancement with Rich Types and Static Analysis in Kotlin
Introduction
Following an object oriented paradigm, when facing a new domain modelling need, usually the approach is to encapsulate elements having some kind of relationship together into a type.
package com.myapp.domain
data class Ad(
val id: Int,
val price: Int,
val address: String,
val description: String
)
An Ad
data type with a flatten bunch of attributes with primitive types.
val anAd = Ad(
12345,
350000,
"Main avenue, 23",
"Beautiful environment full of nature"
)
// Ad(id=12345, price=350000, address="Main avenue, 23", description="Beautiful environment full of nature")
The problem
What can be the problem with this approach? As the class attributes are primitives, it is not guaranteed the correctness. There can be errores regarding its values and order.
Types used have little meaning since there are many attributes that share the same type, this is unsafe. Maybe they have meaning for us when reading their names, but they all have the same meaning for the compiler.
val anAd = Ad(
350000,
12345,
"Beautiful environment full of nature",
"Main avenue, 23"
)
// Ad(id=350000, price=12345, address="Beautiful environment full of nature", description="Main avenue, 23")
The compiler in this scenario is not able to help us, not giving us any error, as the code is "correct" in terms it does not violates any build rule, but definitely the behaviour is not what we expect.
val anAd = Ad(
-12345,
-350000,
"Main avenue, 23",
"Beautiful environment full of nature"
)
// Ad(id=-12345, price=-350000, address="Main avenue, 23", description="Beautiful environment full of nature")
In this case, negative values for both id and price may have no sense, but they are valid values for Int
primitive types, so the compiler can't help us neither this time.
Using rich data types
Another approach to let the compiler help us to reduce not valid instances in our domain is to use rich types to encapsulate the primitive ones.
The goal of domain modelling is to describe the business model as accurately as possible. If we can support that domain modelling with type-safety and let the compiler help us, we will reduce bugs.
package com.myapp.domain
@JvmInline value class AdId(val value: Int)
@JvmInline value class Price(val value: Int)
@JvmInline value class Address(val value: String)
@JvmInline value class Description(val value: String)
data class Ad(
val id: AdId,
val price: Price,
val address: Address,
val description: Description
)
Following previous example, with this new approach based on rich data types instead of primitives, the compiler fails checking data integrity based on attribute data types.
val anAd = Ad(
Price(350000), // Build error
AdId(12345), // Build error
Description("Beautiful environment full of nature"), // Build error
Address("Main avenue, 23"), // Build error
)
The compiler can help us checking type integrity, but we can go deeper and have checks about data values. A negative price or id has no sense, so we can protect against that at compile time to let the compiler go for the rescue again.
Static analysis with Arrow Analysis
Arrow Analysis is an open source Gradle plugin lead by Alejandro Serrano Mena part of the Arrow project which introduces checks in the compilation pipeline, warning about common mistakes and also giving pre-conditions as promises that the caller of a function should obey, at build time.
package com.myapp.domain
import arrow.analysis.pre
@JvmInline value class AdId(val value: Int) {
init {
pre(value > 0) { "ad id must be positive" }
}
}
@JvmInline value class Price(val value: Int) {
init {
pre(value > 0) { "price must be positive" }
}
}
@JvmInline value class Address(val value: String)
@JvmInline value class Description(val value: String)
data class Ad(
val id: AdId,
val price: Price,
val address: Address,
val description: Description
)
val anAd = Ad(
AdId(-12345), // Build error
Price(-350000), // Build error
Address("Main avenue, 23"),
Description("Beautiful environment full of nature"),
)
Building the code, the compiler works for us warning about those pre-condition fails.
//Errors at compilation time
e: pre-condition `ad id must be positive` is not satisfied in `AdId(-12345)`
-> unsatisfiable constraint: `-12345 > 0`
-> `-12345` bound to param `value` in `com.myapp.domain.AdId.<init>`
-> in branch: 12345 != null
e: pre-condition `price must be positive` is not satisfied in `Price(-350000)`
-> unsatisfiable constraint: `-350000 > 0`
-> `-350000` bound to param `value` in `com.myapp.domain.Price.<init>`
-> in branch: 350000 != null
A solid domain modelling with right tools can help us building better and robust software, reducing errors and bad behaviours.