Domain Driven Design in Kotlin - Entities lifecycle management
As devs, we often focus on the project’s tech aspects, such as frameworks, libraries, and their versions. Unfortunately, this moves us away from, most likely, the main thing we should focus on — solving customer problems.
Unless we are experts in some specific technical concepts and were summoned to solve issues related to tooling, our main concern is bringing the business value. To do so, we should try to focus on the domain that our software is or will be, the customer’s needs, and avoid mixing those with internals unrelated to the client’s problem. By internals, I mean things like persistence of our data, transportation, notification, etc.
That is where the Domain Driven Design kicks in by separating technical aspects of our software from business logic that should be represented in code as simple and clean as possible. The tactical part of DDD comes with building blocks based on which we can build domain in the code and loosely couple it with other more technical parts of code. This separation brings values such as better observability and testability. It can also help us better react to business changes and perform technical upgrades such as moving from one library to another.
Take a look at the graph from well known DDD bible by Eric Evans. In the middle, we can see Entities and Value objects which both should contain data and business logic that manipulates that data. We need to remember that code-wise, tactical domain-driven design stands in opposition to well-known anemic models in which data and behavior are separated.
Entities
The first place we should take a look at whenever we think about storing data is the entity. The main characteristic of this building block is its identity. Entities should represent things in our domain for which we want to be able to clearly distinguish one from the other by defining some attribute (or attributes) and treating it as an identifier. For some of them, it will be a natural key, meaningful to the domain, like an insurance number in American medical systems. In other cases, the identifier can be autogenerated by the system itself e.g. order number in e-commerce. One way or the other, such a key should give us the possibility to tell that two entities with all other attributes of the same value should be treated as two different things.
The other characteristic of the entity is the fact that it usually has its lifecycle. Dealing with multiple states might be tricky, especially when it comes to validating which changes are allowed and which are not. Let’s look at the following code:
class Order(
status: OrderStatus
) {
var status = status
private set
val id: UUID = UUID.randomUUID()
enum class OrderStatus {
received, accepted, rejected
}
// order cannot be rejected if was accepted
fun reject() {
if (status == OrderStatus.accepted) {
throw IllegalStateException("Cannot reject already accepted order!!!")
}
status = OrderStatus.rejected
}
// only received order can be accepted
fun accept() {
if (status != OrderStatus.received) {
throw IllegalStateException("Cannot accept order not in received status")
}
status = OrderStatus.accepted
}
}
The logic inside reject()
and accept()
functions do not look bad, however, if we would add a few more statuses it can become quite cumbersome. Also, throwing exceptions after someone tried to use the API incorrectly is a post-factum action. Take a look at OrderTest
.
internal class OrderTest {
@Test
fun `can accept received order`() {
// given
val receivedOrder = Order(Order.OrderStatus.received)
// when
receivedOrder.accept()
// then
assertEquals(receivedOrder.status, Order.OrderStatus.accepted)
}
@Test
fun `can reject received order`() {
// given
val receivedOrder = Order(Order.OrderStatus.received)
// when
receivedOrder.reject()
// then
assertEquals(receivedOrder.status, Order.OrderStatus.rejected)
}
@Test
fun `accepted order cannot be rejected`() {
// given
val acceptedOrder = Order(Order.OrderStatus.accepted)
// when + then
assertThrows<IllegalStateException> {
acceptedOrder.reject()
}
}
@ParameterizedTest
@EnumSource(
value = Order.OrderStatus::class,
names = [ "received" ],
mode = EnumSource.Mode.EXCLUDE
)
fun `orders other than received cannot be accepted`(status: Order.OrderStatus) {
// given
val receivedOrder = Order(Order.OrderStatus.received)
// when
receivedOrder.accept()
// then
assertEquals(receivedOrder.status, Order.OrderStatus.accepted)
}
}
Not so many test cases, right? What if we would add 5 more statuses? We will end up adding more and more tests checking that other statuses should not be transitioned to the “accepted” state as well as not rejected (if that’s the business logic)
Let’s look at another approach. Instead of using a single Order
, we could use Kotlin sealed classes to build subtypes of Order
a type that cannot be changed to an improper type.
sealed class Order(val id: UUID = UUID.randomUUID()) {
}
class ReceivedOrder : Order() {
fun accept() = AcceptedOrder(this.id)
fun reject() = RejectedOrder(this.id)
}
class RejectedOrder(id: UUID) : Order(id)
class AcceptedOrder(id: UUID) : Order(id)
As you can see, ReceivedOrder
is the only one that can be accepted or rejected. Also, the types which we will receive after performing these actions are explicitly set — there is no way to get rejected orders after acceptance and vice versa. That simplified a lot, didn’t it? Let’s check the tests.
internal class OrderTest {
@Test
fun `can accept received order`() {
// given
val receivedOrder = ReceivedOrder()
// when
val result = receivedOrder.accept()
// then
assertEquals(result::class.java, AcceptedOrder::class.java)
}
@Test
fun `can reject received order`() {
// given
val receivedOrder = ReceivedOrder()
// when
val result = receivedOrder.reject()
// then
assertEquals(result::class.java, RejectedOrder::class.java)
}
// We got covered on compilation time
// @Test
fun `orders other than received cannot be accepted`() {
}
// We got covered on compilation time
// @Test
fun `accepted order cannot be rejected`() {
}
}
Wait, what happened? We need only two tests to verify if accepting and rejecting can be performed on received orders. What about the others? They are not needed, since there is no option to perform other operations. Our API became simpler and less error-prone. If someone would like to accept RejectedOrder
he won’t simply find such a method. This way of representing the lifecycle of the Entity helps maintain the correct state and makes our domain code more readable, even for less technical people.
Kotlin language can be really helpful to express business logic in separation from technical aspects of our applications which is one of the core aspects of Domain Driven Design. Sealed classes sound like a perfect choice for Entities that have multiple states and different behavior depending on that state. Please bear in mind, however, that not every domain or subdomain will need such a mechanism. There are still places and contexts in which introducing building blocks from tactical DDD will be overkill and won’t solve any real problem.