Tuesday, March 29, 2022

Kotlin: Functional Domain Modeling #1

Kotlin: Functional Domain Modeling #1

In this blog we start investigating how to use the Kotlin type system to accurately capture the domain model in code. We will see that types can act as documentation; documentation that does not get out of sync with the design because the latter is represented in the code itself.



Modeling Simple Values

As developers we have a tendency to focus on technical issues. However, the domain experts for whom we are developing an application think in terms of domain concepts such an order id, a product code, a customer name or a customer address. If we are to have a successful project it is important that we, as developers, fully embrace the domain experts requirements. To do that we must use the same vocabulary. So, instead of thinking in terms of Int and String, we think in terms of order id and address, even where the order id is an Int and the address is a String.

As our development proceeds it is important that, for example, a product code and a name are not mixed up. Just because they are both represented by Strings, say, they are not interchangeable. To make clear these types are distinct we employ a wrapper type that wraps the primitive representation.

In the following the classes CustomerId and OrderId are wrapper classes around a primitive Int. Creating simple types like this ensures that they cannot be accidentally mixed. The final comment line shows we cannot compare them.

data class CustomerId(val id: Int)
data class OrderId(val id: Int)


val customerId = CustomerId(123)
val orderId = OrderId(456)

// compilation error: operator == cannot be applied
// val isSame = (customerId == orderId)

Equally, if we have the function processCustomer that expects a CustomerId as input, then calling that function with an OrderId is another error.

val customerId = CustomerId(123)
val orderId = OrderId(456)
fun processCustomer(customerId: CustomerId): Unit = TODO()

processCustomer(customerId)

// compilation error: required: CustomerId; found: OrderId
// processCustomer(orderId)

Sometimes it is necessary for business logic to create a wrapper around some type. However, it introduces runtime overhead due to additional heap allocations. Moreover, if the wrapped type is primitive, the performance hit is terrible, because primitive types are usually heavily optimized by the runtime, while their wrappers don't get any special treatment.

To solve such issues, Kotlin introduces a special kind of class called the inline classTo declare an inline class, use the value modifier before the name of the class. To declare an inline class for the JVM backend, use the value modifier along with the @JvmInline annotation before the class declaration. An inline class must have a single property initialized in the primary constructor. At runtime, instances of the inline class will be represented using this single property. This is the main feature of inline classes, which inspired the name inline: data of the class is inlined into its usages (similar to how the content of inlined functions is inlined to call sites). All this is shown in the next example:

@JvmInline
value class LastName(val lastName: String)

// No actual instantiation of class LastName happens
// At runtime surname contains just a String
val surname = LastName("Barclay")

assertEquals("Barclay", surname.lastName)

If a class has two or more simple properties used directly in the class declaration then it can lead to problems as shown in the following example:

data class Name(val lastName: String, val firstName: String)

val me = Name("BARCLAY", "Ken")
val doppelganger = Name("Ken", "Barclay")

assertEquals(true, me.firstName == doppelganger.lastName)

We simply cannot avoid inadvertently mixing the two properties. First, we introduce wrapper classes as shown earlier. Observe how the final comment line would produce a compilation error when we mix the first and last names.

data class LastName(val lastName: String)
data class FirstName(val firstName: String)
data class Name(val lastName: LastName, val firstName: FirstName)

val me = Name(LastName("BARCLAY"), FirstName("Ken"))

assertEquals("Ken", me.firstName.firstName)

// compilation error: type mismatch
// val doppelganger = Name(FirstName("Ken"), LastName("BARCLAY"))

Then we remove the overhead of using simple types without loss of type-safety:

@JvmInline
value class LastName(val lastName: String)
@JvmInline
value class FirstName(val firstName: String)

data class Name(val lastName: LastName, val firstName: FirstName)

// No actual instantiation of class LastName happens
// At runtime surname contains just a String
val surname = LastName("BARCLAY")

val me = Name(surname, FirstName("Ken"))

assertEquals("Ken", me.firstName.firstName)

Almost always simple types are constrained in some way, such as having to be in a certain range or match a certain pattern. It is very unusual to have an unbound Int or String in a real-world domain. In the next blog we will discuss how to enforce these constraints.



The code for the Dogs library can be found at:

https://github.com/KenBarclay/TBA
https://github.com/KenBarclay/TBA


No comments: