Read on to understand things you need to take into consideration when using types to make the states and shapes of entities explicit in your code.
In the previous article, we talked about the benefit of modeling different states and structures of core entities as explicit types. However, as is often the case, this technique cannot be applied blindly, and there are some things you need to consider before you decide to use it.
Combinatorics
One potential downside of this approach is that it can result in an exponential increase of classes, which can impact maintainability.
Let’s demonstrate this on an example. Consider a Customer
, where we track the name and income source of a customer. The customer is unpersisted by default (i.e. has no id
). At the start of the business workflow, we only require the name, but require the customer be “completed” at some (future) point. To complete a customer, information about the income source must be entered as well.
If the source of income is EMPLOYMENT
, the job the customer works must be specified as a separate enum. If the job is specified as OTHER
, the job name must be specified in an additional field as a string.
Similarly, if the source of income doesn’t fit in the existing enum values, we again use OTHER
+ specify using a string.
Here’s the data model that corresponds to what I just wrote:
//sampleStart enum class IncomeSource { EMPLOYMENT, INVESTMENTS, PROPERTIES, OTHER } enum class Employment { PROGRAMMER, DOCTOR, LAWYER, OTHER } /** * To create a customer, only a name is necessary. However, * to actually finish the workflow, the customer data must be * "completed" - additional information will need to be entered * in future steps. */ sealed interface Customer { // Name is always required val name: String // Income source is only required in 'completed' variants val incomeSource: IncomeSource? } sealed interface Persisted { val id: Long } // Employed customer sealed interface EmployedCustomer : Customer { override val incomeSource: IncomeSource get() = IncomeSource.EMPLOYMENT val employment: Employment? } sealed interface CompletedEmployedCustomer : EmployedCustomer { override val employment: Employment } sealed interface PersistedEmployedCustomer : EmployedCustomer, Persisted sealed interface PersistedCompletedEmployedCustomer : PersistedEmployedCustomer, CompletedEmployedCustomer { override val employment: Employment } // Employed customer with employment == other (i.e. employmentSpec is required) sealed interface CustomerWithOtherEmployment : EmployedCustomer { override val employment: Employment get() = Employment.OTHER val employmentSpec: String? } sealed interface CompletedCustomerWithOtherEmployment : CustomerWithOtherEmployment { override val employmentSpec: String } sealed interface PersistedCustomerWithOtherEmployment : PersistedEmployedCustomer, CustomerWithOtherEmployment sealed interface PersistedCompletedCustomerWithOtherEmployment : PersistedCustomerWithOtherEmployment, CompletedCustomerWithOtherEmployment // Customer with income source == other (i.e. incomeSourceSpec is required) sealed interface CustomerWithOtherIncomeSource : Customer { override val incomeSource get() = IncomeSource.OTHER val incomeSourceSpec: String? } sealed interface CompletedCustomerWithOtherIncomeSource : CustomerWithOtherIncomeSource { override val incomeSourceSpec: String } sealed interface PersistedCustomerWithOtherIncomeSource : CustomerWithOtherIncomeSource, Persisted sealed interface PersistedCompletedCustomerWithOtherIncomeSource : PersistedCustomerWithOtherIncomeSource, CompletedCustomerWithOtherIncomeSource //sampleEnd fun main() { val poem = """ In the coding constellation, Kotlin's the star, With extension properties, it travels far. From galaxies to cosmic wonders so bright, In the world of development, it's the light! """.trimIndent() println(poem) }
Fourteen interfaces to model a very simple business scenario — that’s a lot. It took me a while and multiple tries to map out the hierarchy in my head and write them all out, and I’m not embarrassed to say that I got lost a few times. To be completely honest, I’m not 100% sure I got them all right. And God forbid we added another “branch” in the data model — e.g. discriminated between customers imported from an external system vs those created by a user. Suddenly, we’d be looking at 28 interfaces.
Obviously, this doesn’t scale well, and we can’t apply this without careful consideration. So, when is it worth doing?
When is it worth doing?
Let’s go back and recap what the actual benefit of this approach are: it removes the need for runtime checks of business preconditions when implementing business scenarios.
So for this approach to modeling to be worth the effort, we need the hierarchy to actually appear in some business logic which contains preconditions that can be removed by modeling the hierarchy in this way. We need to take into consideration not only if these places exist, but how many there are, and most importantly, how many more do we expect to appear as the codebase evolves.
That last one should be the most important deciding factor. It also hints at where this style is most effective, and should most often be used — modeling core domain entities, around which the core business workflows revolve. By definition, these are the entities that appear most often in business code, and will keep appearing in new business code in the future.
Examples and counter-examples
So, if we’re building a service that allows PDFs to be signed electronically, a PdfDocument
entity is a prime candidate to model in this way, but only those parts which actually appear in core business code — e.g. differentiating between a SignedPdfDocument
and UnsignedPdfDocument
probably makes sense, but differentiating between, I dunno, a SinglePagePdfDocument
and MultiPagePdfDocument
probably doesn’t (unless, of course, you have a scenario where this distinction matters).
In contrast, if we’re building a fintech service which allows customers to invest their capital, it’s reasonable to assume that we’re probably going to need to upload a scanned PDF of the signed contract at some point in the workflow. This would also be modeled as a PdfDocument
entity, but there really is no sense in going crazy with the hierarchy, assuming that all we do is accept it, persist it to some storage and maybe send it to some third-party APIs. Maybe, maybe, it might be worth discriminating betweenPersisted
and Unpersisted
variants.
To return to the scenario with the Customer
above, you can imagine an online trading platform subject to standard KYC guidelines, where you are only able to withdraw your funds after you complete your profile. That is a very core workflow, and you need to strictly separate business logic applicable to “completed” customers from business logic applicable to “incomplete” customers. Everything is much safer if you strictly separate completed customers from the incomplete ones at the type level, and enlist the compilers help in ensuring that the two never get mixed up.
As a counter-example where the same approach wouldn’t make sense, imagine if the Customer
was just a DTO that gets built and sent to a third party API. There is no business logic involving it, it just get’s built, validated (i.e. all the fields that are required are there) and sent off to some system. We gain practically nothing by discriminating an EmployedCustomer
from the rest.
Sure, created objects are correct by design, and therefore there is no need for validation. It might seem that this allows us to save a few keystrokes, but don’t forget that:
- in the real world, there are many requirements that cannot be reflected by a static type hierarchy, so we’ll probably need to do some validation anyway
- these objects need to be built first!
And once you started building them, you would find out that you’re writing exactly the same code as you would have written in the validator:
//sampleStart /** * Without hierarchy */ val customer = Customer( ... ) // In validator if(customer.incomeSource == "EMPLOYMENT" && customer.employment == null) { // Return error } // ----------------- /** * With hierarchy */ val customer = if(domainObject.incomeSource == "EMPLOYMENT") { if(domainObject.employment != null) { CompletedEmployedCustomer(...) } else { // Return error } } //sampleEnd
It’s the same code, just in a different part of the application.
To recap, always take into consideration how much this will really help you. Are you working with a core entity that can appear with different structures/in different states, and many business scenarios only make sense for certain structures or certain states? Then by all means, do this! And don’t be daunted even if this approach results in a large number of classes, because honestly — how many classes does your project already have? The one I’m currently working on has hundreds, and I don’t care — it’s my IDE’s job to index them, and I only open up those that I’m interested in. In Kotlin, classes are (visually) cheap.