Application domain modeling
10/6/2022
Application domain modeling

Software projects are about telling computers what people want to do. Programmers are the ones who speak the computer language, but ordinary people aren't predestined to think and communicate the way a computer does. They are usually good at telling what they want to achieve and what is the purpose. Then it's the programmer's task to translate the purpose into computer language.

This arrangement leads to an unintended limitation. When a person needs to tell something to a computer, they need a programmer. When people need information about computer behavior, they need to ask a programmer. Programmers are a bottleneck in this process.

But what if we can design the project so the code is understandable even for ordinary people?

Enter the domain

A domain is a part of every project that does the important things. The business logic resides there together with industry rules and policies. The domain must be articulated unambiguously for everyone.

The domain shouldn't be too technical. There should not be transactions, exceptions, wrappers, or gateways. Ordinary people do not understand these terms but need to understand the domain. Although programmers understand these technicalities, they are ambiguous, and two programmers may imagine a different mechanism under these terms.

The domain should be full of real-world objects. We should see purchase orders, money amounts, other people, or actual goods. These objects can be virtual, like card transactions, but need to be recognizable to everyone.

Kotlin exhorts for readable code. This is one of its main characteristics. And we can use it to model our domain.

The real-world objects are usually modeled as data class with read-only properties. Any modification needs to be done by a generated copy() function that prevents unintended state updates or inconsistencies.

Some objects or data items like Social Security Numbers are so simple that one may tend to represent them as a primitive type like String or Int. This is good for program efficiency, but a person may unintentionally swap it with some other information of the same primitive type and introduce a severe error. To overcome this, we may introduce a data class to wrap the information and give it a unique type. This will work, but its overhead may consume an unacceptable amount of resources when used at a larger scale. Kotlin value class is a great fit for such use cases. It assigns a unique type to a simple data item while still keeping its memory footprint small.

Domain actions

Domain objects are important, but they are useless unless we can take action with them. A purchase order is to be placed or canceled. A parcel with a new pair of shoes is to be shipped or claimed. And we need to model these actions as well.

The first thing is the name. Action in programming is quite an overused, and generic term and it does not work well for us. Some teams name them Interactor others use the term UseCase, or Actor. We will use the term UseCase for the rest of this post.

A use case is defined as a single function with explicit input and output. Use cases have no state and can be called multiple times. Let's try to define them in Kotlin.

It would be nice if a UseCase may have a single input and single output so we can define a base type for all use cases with inputs and outputs defined as generics. But in the real world there are actions that may have more inputs, so this is not feasible, is it?

class FindDriverUseCase {
    fun execute(position: Coordinates, destination: Coordinates)
}

This approach will work technically, but it does not tell the whole story for people unfamiliar to the context.

Introducing a specific type for use case input will actually help to understand the context and works as an extension point for further feature evolution.

class FindDriverUseCase {
    fun execute(input: PotentialRide)
}

Great, after using this approach for a while, you'll realize that some use cases have their input and output optional. That's fine, Kotlin has built-in nullability in the type system, doesn't it?

class FindStoresUseCase {
    fun execute(input: Country?): List<Store>?
}

The use case finds all our company stores. We may want to get a list of all of them or to filter them by a given Country.

The definition of input as null on the use-site is not very understandable in this case. Also, it is hard to guess what null as returned result actually means. A little bit more modeling will fix this:

sealed interface For {
    object AllCountries: For
    data class SingleCountry(val country: Country): For
}
sealed interface StoresResult {
    object NotAvailable: StoresResult
    object OutOfSync: StoresResult
    data class Available(val stores: List<Store>): StoresResult
}
class FindStoresUseCase {
    fun execute(input: For): StoresResult
}

It seems to be a good idea to deny the value null as use case input or output at all. Let's introduce a base type and force the generics to be based on Any that effectively forbids null:

interface UseCase<in Input: Any, out Output: Any> {
    fun execute(input: Input): Output
}

Actions namespace

Quite often, we have a few use cases working with similar objects:

SubmitPurchaseOrderUseCase, CancelPurchaseOrderUseCase, …

These class types may quickly pollute the project's namespace and make it hard to navigate.

We should try to group them somehow under a shared namespace. Kotlin doesn't have support for namespaces (yet). We can supersede it by grouping the use cases under a single sealed interface for this purpose. You can use sealed class or object as an alternative, but the memory footprint would be slightly bigger.

sealed interface PurchaseOrderUseCase {
    class Submit { … }
    class Cancel { … }
}

Kotlin call site

Now let's look at the call site:

val purchaseOrder = PurchaseOrder(...)
val submitPurchaseOrderUseCase = PurchaseOrderUseCase.Submit()
val result = submitPurchaseOrderUseCase.execute(purchaseOrder)

We can polish it with a little trick called Invoke operator. When a Kotlin function named invoke() is marked as operator, its invocation may be replaced by the use of () operator.

When applied to our base type

interface UseCase<in Input: Any, out Output: Any> {
    operator fun invoke(input: Input): Output
}

the call site can be simplified to

val purchaseOrder = PurchaseOrder(...)
val submitPurchaseOrder = PurchaseOrderUseCase.Submit()
val result = submitPurchaseOrder(purchaseOrder)

Notice the use case instance name change, where the UseCase suffix has been omitted, and its invocation now looks like a standard function call. If it is used in an obvious context, we may shorten the use case instance variable name even more and call it simply submit(purchaseOrder).

Swift call site

Until now, we were only calling our use cases from Kotlin code. On Kotlin Multiplatform projects, it is common to share the domain logic between all targeted platforms. Let's check how a use case may be used in another language. We will take the example of a mobile application implemented both for Android in Kotlin and iOS in Swift.

First, let's try to call it the same way we do in Kotlin.

val submitPurchaseOrder = PurchaseOrderUseCase.Submit()
val result: SubmitResult = submitPurchaseOrder(purchaseOrder)

This code has two major problems. The invocation operator is unavailable, and the result is not of type SubmitResult. Let's start with the latter one.

Swift generics

Generics in swift are available only for class, struct and enum. A very similar thing for interface is called associated types, but its characteristic is incompatible with Kotlin generics.

This is why Kotlin Multiplatform has Swift generics support only for classes. To fix this in our project we need to define our base type as an abstract class instead of an interface. This is actually not an issue as its implementations are classes anyway:

abstract class UseCase<in Input: Any, out Output: Any> {
    abstract operator fun invoke(input: Input): Output
}

Swift function invocation

Now let's fix the former problem with use case invocation. Swift language doesn't have an invoke operator like Kotlin. Instead, it has methods with special names, and one of them is callAsFunction() that can be called with the use of () symbol.

We may introduce this function to our use case base type but that may be confusing for use from Kotlin common code or Android code.

To make it available for iOS code only, we will provide different implementations of use case base type for Android and iOS using Kotlin's expect/actual feature. Its implementation just delegates the call to our standard invoke() function.

// Kotlin Common
expect abstract class UseCase<in Input : Any, out Output : Any> constructor() {
    abstract operator fun invoke(input: Input): Output
}

// Kotlin Android
actual abstract class UseCase<in Input : Any, out Output : Any> {
    actual abstract operator fun invoke(input: Input): Output
}

// Kotlin iOS
actual abstract class UseCase<in Input : Any, out Output : Any> {
    actual abstract operator fun invoke(input: Input): Output
    fun callAsFunction(input: Input): Output = invoke(input)
}

Living documentation

The domain is a part of every project that actually does the important things. When modeled properly, it suits so-called living documentation. It is understandable for any reader; anytime a logic is changed, the documentation also changes.

Anytime you find a piece of domain code that is not understandable, let's take a moment and polish it. Your future will be grateful to you.


Pavel Švéda

Twitter: @xsveda


Tags

#domain; #architecture; #kotlin; #swift; #multiplatform

Author

Pavel Švéda

Versions

Kotlin 1.7.20
Swift 5.7