Sometimes called "functional domain modelling", there are a bunch of articles and videos titled like that but I haven't found a Kotlin edition, so took the chance and hope it's useful :)
Let's state our goal clearly again: make impossible states impossible.
What does that mean? In a statically typed language like Kotlin, it means we want to leverage the compiler to ensure that we can only have valid models so that it's impossible to have incorrect cases. In other words:
- Use the type system to describe what is possible or not. If something is not possible, it should not compile.
- Another way to look at it is: use types as if they were tests that you won't need to write. Think about
nullable
types in Kotlin, for example... if it's not marked with?
you know it's notnull
(unless someone cheated along the way... but you can't control who uses reflection either).
Let's start with an apparently normal example of what could be a user profile in some platform:
const val ACCOUNT_TYPE_BASIC = 0
const val ACCOUNT_TYPE_PREMIUM = 1
const val ACCOUNT_TYPE_PRIVILEGE = 2
data class UserProfile(
val name: String,
val age: Long,
val accountType: Int,
val email: String?,
val phoneNumber: String?,
val address: String?
)
What problems can you spot?
-
name
: there could be a number within the string. What about the surname? Special characters? What if it's empty? -
age
: can anybody live so long to fill aLong
? Can it be 0? Negative? -
accountType
: what if someone puts a number that's not in our constants list? -
email
: an email needs a specific format, but we could write anything in there and it'd compile. -
phoneNumber
: same... -
address
: same again...
How could we use the type system to make these cases impossible?
Let's start with name
.
Name
We'd normally have some sort of function that validates the input before putting it into the model, but a String
doesn't reflect any of those validations. What can we do? How about a type that represents a validated name? Let's explore that.
data class UserName(val name: String) {
companion object {
fun createUsername(name: String): UserName {
// perform some validations on name
if (name.isEmpty()) throw IllegalArgumentException()
return name
}
}
}
So with a specific type we can represent a validated model. But still, there are problems in the proposed solution...
- Anyone can just create a
UserName
, bypassingcreateUserName
. - It blows in runtime if there's any problem
For the first case, an attempt could be to make the constructor private:
data class UserName private constructor(val name: String)
But then we get a warning telling us that "private data class constructor is exposed via the generated 'copy' method". Bummer... it's a reported bug and it doesn't seem to be a priority for JetBrains. At this point for simplicity I think we could just ignore the warning, rely on convention and call it a day...
Now, if it still doesn't feel right, we could hack it using some interface magic:
interface IUserName {
val name: String
companion object {
fun create(name: String): IUserName {
// perform some validations on name
if (name.isEmpty()) throw IllegalArgumentException()
return UserName(name)
}
}
}
private data class UserName(override val name: String) : IUserName
More verbose than desirable... but it works. Now, there's still the problem of the IllegalArgumentException
. Ideally we want to handle all our cases and make it impossible to blow up. We could use something like Either, or if we don't want to add Arrow we can just use Java's Optional.
fun create(name: String): Optional<IUserName> {
// perform some validations on name
if (name.isEmpty()) return Optional.empty()
return Optional.of(UserName(name))
}
Or since this is Kotlin, you could just make it nullable:
fun create(name: String): IUserName? {
// perform some validations on name
if (name.isEmpty()) return null
return UserName(name)
}
For the rest of this post I'll just use the non-interface version for simplicity, but hopefully the point has come across.
age
Pretty much the same as for name
, we can create a validated type for it.
accountType
For this one we can just use a common construct available in many languages: Enum
.
enum class AccountType {
ACCOUNT_TYPE_BASIC,
ACCOUNT_TYPE_PREMIUM,
ACCOUNT_TYPE_PRIVILEGE
}
email, phoneNumber, address
At this point it should be obvious that these fields share the same problem, so we can create validated types for each of them.
But is that the only problem? Is it possible to have a user that we can't contact in any way? According to the model, this is perfectly valid:
val profile = UserProfile(
...,
email = null,
phoneNumber = null,
address = null
)
We probably want to be able to contact the user right? So maybe we can generalize all of them as a ContactInfo
. Is there any way to express that a ContactInfo
can be "an email, a phone number, or an address"? How would you do that with GraphQL? Hmm... union types? In Kotlin we can represent these with sealed classes.
// for simplicity, assume that we have factory methods for those data classes and the constructors are private...
sealed class ContactInfo
data class Email(val email: String) : ContactInfo
data class PhoneNumber(val number: String) : ContactInfo
data class Address(val address: String) : ContactInfo
In GraphQL syntax this would be: union ContactInfo = Email | PhoneNumber | Address
.
So everything's validated... is that enough?
Our UserProfile
might look like this now:
data class UserProfile(
val name: UserName,
val age: Age,
val accountType: AccountType,
val contactInfo: List<ContactInfo>
)
Is that OK? Can contactInfo
be empty? We did say 'no' before, didn't we? We could create a special NonEmptyList
type (or use Arrow):
data class UserProfile(
val name: UserName,
val age: Age,
val accountType: AccountType,
val contactInfo: NonEmptyList<ContactInfo>
)
Now? Hmm... are duplicate ContactInfo
s allowed? π€ What's a data structure that can contain only unique elements?
data class UserProfile(
val name: UserName,
val age: Age,
val accountType: AccountType,
val contactInfo: NonEmptySet<ContactInfo>
)
And that's it! (NonEmptySet
is non-standard Java/Kotlin, but should be easy to create)
Conclusion
Making impossible states impossible is about using our data types to represent valid things in our domain, which will ultimately lead to more robust software with less bugs. It's often called "functional domain modelling", probably not because it has anything to do with functional programming per se, but most likely because in the statically-typed functional world we strive for "total functions", which are those that consider all possible inputs and outputs.
Just asking yourself the question of "does my model allow any illegal state?" will get you a long way!
Some resources you can check:
- Design for errors - An introduction to Domain Modeling with a bit of Arrow by Ivan Morgillo
- Scott Wlaschin - Talk Session: Domain Modeling Made Functional
- Making Impossible States Impossible" by Richard Feldman
Bonus
When Kotlin finally gets inline classes, we'll be able to have zero-cost abstractions.
// this needs a data class to wrap a String
data class Name(val name: String)
// inline classes are basically the underlying primitive,
// verified by the compiler
inline class Name(val name: String)
Top comments (7)
That is a clever usage of interfaces!
I recently had to deal with a similar case while refactoring some legacy code.
My approach to create valid entities was achieved via factory functions. In a nutshell you can have a
which tricks the dev to think that is using a constructor but at the same time keeps everything valid and readable.
If you are interested I wrote a blog post here.
Interesting blog post, thanks! I like that increasingly more people are aware of this problem :)
As for the suggestion, I'd advise against doing it that way because it goes against the conventions:
null
.My 2 cents.
Both arguments are valid. Especially the second one didn't even crossed my mind. Thank you!
Big fan of such (Functional) domain modelling.
What's your take on the Null vs Option vs Either?
From my perspective:
My interest is in when to choose Null or Option. I'm facing the dilemma in Typescript; if I don't need the resolution of Either, shall I use Option or just keep it simple with Null...
That is ignoring for a moment that we also have undefined ;-)
In languages without good type support, null can be a real pain, but in Typescript and I suppose Kotlin, things are better. So I guess it just is a matter of null can make it more difficult to compose, yet easier to consume as you only need to assert, not unwrap like Option/Either.
I've had the exact same dilemma and for the moment my loosely-held conclusion is that
Option
is unnecessary in Kotlin. Perhaps if you use Arrow it provides syntax sugar with monad comprehensions but I wouldn't say that's a good enough reason to use it over built-in nullables.The only case where I think using
Option
orOptional
has an advantage is when your code needs to be used from Java and you want to keep null-safety. Would this apply to Typescript and Javascript?Great article. However, I will say if you want first party support for unions itβs likely better to use a language that has unions like TypeScript.
I'd say that multiple reasons need to be evaluated before choosing one language or another :)