Functional Domain Modeling in Kotlin

Functional Domain Modeling in Kotlin

At 47 Degrees, we care a lot about domain modeling to describe our domain as precisely as possible.

The goal of functional domain modeling is to describe your business domain as accurately as possible to achieve more type-safety, maximize the use of the compiler with our domain and, thus, prevent bugs and reduce unit testing. Additionally, it makes it easier to communicate about the domain, since the domain is the touchpoint with the real world.

Kotlin is a good fit for functional domain modeling. It offers us data class, sealed class, enum class, and inline class. And we have Arrow, which offers us some interesting generic data types such as Either, Validated, Ior, etc.

In some codebases, you can find the following primitive type based implementation of an Event:

data class Event(
  val id: Long
  val title: String,
  val organizer: String,
  val description: String,
  val date: LocalDate
)

The types used here have little or no meaning since title, organizer, and description all share the same type.

This makes our code prone to subtle bugs where we might be relying on title instead of description, and the compiler would not be able to help us out.

Let’s take a look at an example where things go wrong without the compiler being able to help us.

Event(
  0L,
  "Simon Vergauwen",
  "In this blogpost we dive into functional DDD...",
  "Functional Domain Modeling",
  LocalDate.now()
)

Here, we have mixed up organizer, description, but the compiler is happy and constructs the Event object. There are more cases where you can fall into this trap; for example, when destructuring.

So how do we prevent this from happening, or how can we improve our domain model to be better typed? Let’s use a still experimental, but very exciting, upcoming feature of Kotlin: inline class. Doing this causes no additional overhead, since inline class is erased at runtime. (You can replace this with data class if you don’t want to rely on @Experimental in your codebase yet.)

inline class EventId(val value: Long)
inline class Organizer(val value: String)
inline class Title(val value: String)
inline class Description(val value: String)

data class Event(
  val id: EventId
  val title: Title,
  val organizer: Organizer,
  val description: Description,
  val date: LocalDate
)

If we go back to our previous example, the compiler now fails to compile since we pass Organizer where Title is expected, Description where Organizer is expected, and so on.

Event(
  EventId(0L),
  Organizer("Simon Vergauwen"),
  Description("In this blogpost we dive into functional DDD..."),
  Title("Functional Domain Modeling"),
  LocalDate.now()
)

In functional programming, this type of data composition is also known as a product type or a record, which models an AND relationship. So we can say that an Event exists out of an EventId AND a Title AND an Organizer AND a Description AND a LocalDate, which tells us much more than an Event that exists out of a Long AND a String AND a String AND a String AND a LocalDate.

Let’s say that we need to evolve our Event model to keep track of any age restrictions. We could model this with String again, but that would only worsen our original problem. So let’s say we follow the MPAA film ratings, which is an enumeration of 5 different cases.

Since we’re clearly talking about a fixed set of cases, or enumeration, we use an enum class.

enum class AgeRestriction(val description: String) {
  General("All ages admitted. Nothing that would offend parents for viewing by children."),
  PG("Some material may not be suitable for children. Parents urged to give \"parental guidance\""),
  PG13("Some material may be inappropriate for children under 13. Parents are urged to be cautious."),
  Restricted("Under 17 requires accompanying parent or adult guardian. Contains some adult material."),
  NC17("No One 17 and Under Admitted. Clearly adult.")
}

Using an enum class is much more powerful than String for reasons beyond the problems we already explained above. A String has an infinite number of possible values, while now we only have 5 different possible values. So it’s much easier to reason about AgeRestriction than it would be to reason and work with String.

In functional programming, this type of data composition is also known as a sum type, which models an OR relationship. So we can say that an AgeRestriction is either General OR PG OR PG13 OR Restricted OR NC17. This tells us much more than if it was just a String. A String would have an infinite number of values, while AgeRestriction modeled as an enum class only has 5 different values. So using sum types can drastically reduce the complexity of our types.

With online events on the rise, we have a different kind of event that doesn’t take place at a Address, but rather at a certain Url. So, depending on what kind of Event it is, the data inside will be slightly different. Naively we could implement this as follows:

inline class Url(val value: String)

inline class City(val value: String)
inline class Street(val value: String)
data class Address(val city: City, val street: Street)

data class Event(
  val id: EventId
  val title: Title,
  val organizer: Organizer,
  val description: Description,
  val date: LocalDate,
  val ageRestriction: AgeRestriction,
  val isOnline: Boolean,
  val url: Url?,
  val address: Address?
)

This is a common encoding, but can be quite problematic. Depending on if isOnline is true, url will be non-null and vice-versa for address. However, after checking isOnline, both url and address is still null, so we’ll end up with code like this.

fun printLocation(event: Event): Unit =
  if(event.isOnline) {
    event.url?.value?.let(::println)
  } else {
    event.address?.let(::println)
  }

But, even worse, we can also easily break the intended contract like in the example below.

Event(
	Id(0L),
	Title("Functional Domain Modeling"),
	Organizer("47 Degrees"),
	Description("Building software with functional DDD..."),
	LocalDate.now(),
	AgeRestriction.General,
	true,
	null,
	null
)

The compiler is happy with the below definition, even though our intended contract said that, if it’s isOnline, then url would be non-null. We can prevent this issue by introducing a sealed class to combine Event.Online and Event.AtAddress in a typed way.

sealed class Event {
  abstract val id: EventId
  abstract val title: Title
  abstract val organizer: Organizer
  abstract val description: Description
  abstract val ageRestriction: AgeRestriction
  abstract val date: LocalDate

  data class Online(
    override val id: EventId,
    override val title: Title,
    override val organizer: Organizer,
    override val description: Description,
    override val ageRestriction: AgeRestriction,
    override val date: LocalDate,
    val url: Url
  ) : Event()

  data class AtAddress(
    override val id: EventId,
    override val title: Title,
    override val organizer: Organizer,
    override val description: Description,
    override val ageRestriction: AgeRestriction,
    override val date: LocalDate,
    val address: Address
  ) : Event()
}

This not only solves the issue we had before where we can instantiate an online Event without an Url, but it also offers a much nicer way of working with the data. Instead of if(event.isOnline), we can now use an exhaustive when to pattern match on Event, and due to Kotlin’s smart casting, we can safely access url in the case that it’s Event.Online.

fun printLocation(event: Event): Unit =
  when(event) {
  	is Online -> println(event.url.value)
  	is AtAddress -> println("${event.address.city}: ${event.address.street}")
  }

This type of data composition is also known as a sum type, which models an OR relationship, but sealed class offers us more powerful capabilities than enum class. A sealed class allows our sum or cases to exist out of object, data class, or even another sealed class. An enum class cannot extend another class, so it cannot be a case of a sealed class. Here, our sealed class exists out of 2 cases, an Online OR AtAddress Event, where Online and AtAddress are a product types of several other types. A rule of thumb in Kotlin is to use an enum class when the cases don’t contain any data or, in other words, if all cases can be modeled as object.

As we’ve already seen in the examples above, modeling your domain precisely has many benefits. It can eliminate certain bugs, such as instantiating data incorrectly. It makes our code/model easier to reason about by eliminating invalid values, and it can improve code relying on our models.

Let’s take a look at how we can use Arrow’s data types to further clear up domain problems in our code. In our program, we have some EventService that can fetch an upcoming Event based on a EventId.

interface EventService {
  suspend fun fetchUpcomingEvent(id: EventId): Event
}

What is completely missing from our EventService is the different kind of error scenarios we could encounter. It’s only modeled through Throwable in suspend. So if we’d want to explicitly model the error domain, we could use any of the techniques we’ve already seen above.

Here, we model 2 different cases:

  1. An event is not found.
  2. An event is no longer upcoming, but has already happened.
sealed class Error {
  data class EventNotFound(val id: EventId): Error()
  data class EventPassed(val event: Event): Error()
}

We can compose these two separate domains, Error and Event, using Either from Arrow Core. This allows us to model an OR relationship, meaning that fetchUpcomingEvent either returns an Error or an Event, but never both. So Either is a generic sum type, which allows us to generically compose two separate domains with each other in an OR relationship.

So, if we update our EventService:

interface EventService {
  suspend fun fetchUpcomingEvent(id: EventId): Either<Error, Event>
}

Since Either is defined as sealed class in Arrow Core, we can use the same technique as we used above with when to extract the Error or Event in a safe way. Arrow Core is a module by itself, so you can fetch it like so:

depdendencies {
  def arrowVersion = "0.11.0"

  implementation "io.arrow-kt:arrow-core:$arrowVersion"
}

In this post, we’ve seen how we can improve our domain by:

  • Eliminating primitive types in our domain definition, and using inline class to prevent runtime overhead.
  • Using enum class and sealed class to model disjunctions in our domain, such as certain data being available depending on the type of Event.
  • Utilizing Arrow’s Either to compose two different domains with an OR relationship.

In future blog posts, we’ll discuss how we can improve our domain models even more by using other types of Arrow, and using other techniques such as type refinement to constrain our models even further.

If you are interested in learning how to write pure functional programs using Kotlin, keep watching for the next available Kotlin training course offered by the 47 Degrees Academy.

Ensure the success of your project

47 Degrees can work with you to help manage the risks of technology evolution, develop a team of top-tier engaged developers, improve productivity, lower maintenance cost, increase hardware utilization, and improve product quality; all while using the best technologies.