Modeling is a critical task in Software Development: good models reduce the risk of bugs, increase readability and improve maintainability. However, we can often see developers focusing on “How can I code this?”, and they are done with their task when they find a way to code it. This inherently reduces the domain modeling to the abuse of primitives and shallow design where any inconsistent state is allowed.
To better explain, let’s consider the hypothetical requirements:
- Your team is developing a feature to order items in e-commerce.
- Your job is to design the models of this feature.
The simplest way to do that might look like the code below.
fun findOrderById(id: String): Order
data class Order(
val id: String,
val accountId: String,
val items: List<OrderItem>,
// ...billing address, shipping address, status, and other properties.
)
data class OrderItem(
val id: String,
val name: String,
val quantity: Int,
val price: BigDecimal,
)
At first glance, these models will look good, but let’s have a closer look.
- Primitive Obsession: almost all types are String, and the compiler is incapable of verifying their integrity.
- Absence of Invariants: it is unrealistic to define a quantity as a number between
-2147483648
and2147483647
. What is a quantity-1
? - Error Prone: what happens if you create an
Order
with an emptyaccountId
? What will happen if you callfindOrderById
with anOrderItem.id
by mistake?
Good models get out of developers’ way by making invalid state impossible. More than that, it leverages the compiler to not allow human’s mistake (e.g., an OrderItemId
should not be used as an OrderId
).
Let’s redesign the previous model.
fun findOrderById(id: OrderId): Order
data class Order(
val id: OrderId,
val accountId: OrderAccountId,
val items: List<OrderItem>,
)
@JvmInline
value class OrderId(val value: String) {
init {
runCatching { UUID.fromString(value) }
.onFailure { e -> error("OrderId should be a valid UUID but found: $value.") }
}
}
@JvmInline
value class OrderAccountId(val value: String) {
init {
runCatching { UUID.fromString(value) }
.onFailure { e -> error("AccountId should be a valid UUID but found: $value.") }
}
}
data class OrderItem(
val id: OrderItemId,
val name: OrderItemName,
val quantity: OrderItemQuantity,
val price: OrderItemPrice,
)
@JvmInline
value class OrderItemId(val value: String) {
init {
runCatching { UUID.fromString(value) }
.onFailure { e -> error("OrderItemId should be a valid UUID but found: $value.") }
}
}
@JvmInline
value class OrderItemName(val value: String) {
init {
require(value.isNotBlank()) { "OrderItemName should not be blank." }
require(value.length < 200) { "OrderItemName length should be smaller than 200 but found: ${value.length} with content: $value."}
}
}
@JvmInline
value class OrderItemQuantity(val value: Int) {
init {
require(value >= 0) { "OrderItemQuantity should not be negative but found: $value." }
require(value < 99) { "OrderItemQuantity should not be higher than 99 but found: $value." }
}
}
@JvmInline
value class OrderItemPrice(val value: BigDecimal) {
init {
require(value > BigDecimal.ZERO) { "OrderItemPrice should not be negative but found: $value." }
}
}
The new API makes it impossible to create an invalid instance of an Order
. It ensures all invariants of an Order
are respected during the application’s lifetime and reduces corner cases by providing a type-safe API: the function findOrderById
knows that you are calling it with an OrderId
, it will be a valid UUID
.
F.A.Q.
- What about the number of classes?
Good models often will involve more classes and specific validations - but keep in mind those are already in our code. Remember all those checks to verify if the parameter of findOrderById
is valid? Or that IllegalArgumentException
when you expected a quantity but received a negative number? Now they are explicitly defined in our domain and can be easily found.
- What about the number of lines of code?
It is true that now you need to cover more ground, but it is also true that you reduced the number of corner cases. Before, your tests had to cover if the findOrderById
parameter is valid. Now, the compiler will ensure the parameter is always correct - if the code compiles, you know you are receiving a correct UUID
. In the end, you are making safer code and reducing the number of tests you will need.
- Should I have a class for each property?
Probably not. Modeling requires a lot of analysis. You must identify the essential concepts of your domain that must be assertive and ensure those are deep. For that, Domain-Driven Design might help you.
Credits
Special credits for Secure By Design (Chapter 2: Shallow Modeling) and A Philosophy of Software Design (Chapter 4: Modules should be deep) where I learnt about Deep and Shallow modeling, both exceptional books.
ℹ️ To stay up to date with my writing, follow me on Twitter or Mastodon. If you have any questions or I missed something, feel free to reach out to me! ℹ️