Domain modeling
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 communicating about the domain easier 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 value class
. And we have Arrow, which provides some interesting generic data types such as Either
and Ior
.
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 rely on title
instead of description
, and the compiler would not be able to help us. Let's 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
and 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? We use value class
, Kotlin's feature to disguise an already-existing type under a new name. Doing this causes no additional overhead since value class
is erased at runtime. At the moment of writing, every appearance of value class
requires a corresponding @JvmInline
annotation.
@JvmInline value class EventId(val value: Long)
@JvmInline value class Organizer(val value: String)
@JvmInline value class Title(val value: String)
@JvmInline value 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 we must 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 infinite possible values, but now we only have five possible ones.
So it's much easier to reason about AgeRestriction
than 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 infinite values, while AgeRestriction
modeled as an enum class
only has five 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 occur at an 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:
@JvmInline value class Url(val value: String)
@JvmInline value class City(val value: String)
@JvmInline value 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 it can be pretty problematic. 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 solves our previous issue, where we can instantiate an online Event
without an Url
, and 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 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 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 specific 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 look at how we can use Arrow's data types to clarify our code's domain problems further. Our program has some EventService
that can fetch an upcoming Event
based on an EventId
.
interface EventService {
suspend fun fetchUpcomingEvent(id: EventId): Event
}
What is completely missing from our EventService
is the different kinds of error scenarios we could encounter. It's only modeled through Throwable
in suspend
. So if we'd want to model the error domain explicitly, we could use any of the techniques we've already seen above.
Here, we model 2 different cases:
- An event is not found.
- 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 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 a sealed class
in Arrow Core, we can use the same technique we used above with when
to extract the Error
or Event
in a safe way.
In this article, we've seen how we can improve our domain by:
- Eliminating primitive types in our domain definition and using
value class
to prevent runtime overhead. - Using
enum class
andsealed class
to model disjunctions in our domain, such as certain data being available depending on the type ofEvent
. - Utilizing Arrow's
Either
to compose two different domains with an or relationship.