Value-Based Classes and Domain Modelling in Kotlin
Not to be confused with value types of project Valhalla.
Value-based classes, as defined in the Java docs, are classes which conform to a set of rules:
- are final and immutable (though may contain references to mutable objects);
- have implementations of
equals
,hashCode
, andtoString
which are computed solely from the instance's state and not from its identity or the state of any other object or variable;- make no use of identity-sensitive operations such as reference equality (
==
) between instances, identity hash code of instances, or synchronization on an instances's intrinsic lock;- are considered equal solely based on
equals()
, not based on reference equality (==
);- do not have accessible constructors, but are instead instantiated through factory methods which make no committment as to the identity of returned instances;
- are freely substitutable when equal, meaning that interchanging any two instances
x
andy
that are equal according toequals()
in any computation or method invocation should produce no visible change in behavior.
Regarding the first rule: Immutable classes cannot contain references to mutable objects, because they’re immutable. The rest of the article assumes they don’t.
In Kotlin, that may look like this:
There are three advantages of this:
- You are certain that all instances of
Password
are “good passwords”, it becomes an invariant of your system. - Your domain model enforces its own rules, which is more convenient than having the “api consumer” enforce them.
- You have complete control over the instance your factory returns.
Pretty simple, huh? In fact, too simple.
What does returning null
here mean to the api consumer? That this’s a bad password? But why?
Returning null
in this case is barely helpful. What do we tell the app user? bad password? better luck next time? What if the api user wants to execute different actions on different failing scenarios? We need to provide more context!
Which takes us to a pretty controversial subject…
Exceptions vs ADTs
There are mainly two ways in which this problem is tackled:
- Exceptions:
This looks nice at first, but maintaining it, definitely ain’t!
All the try-catching aside, what happens when we decide we need to add seventy-five more constraints on our user’s passwords? We add the exceptions, update our documentations and .. start asking who has been instantiating our class?
Some people take this a step further and throw exceptions directly inside the constructors which is even more dangerous, but more on that later.
Overall, we can do better!
- Algebraic Data Types:
By using sum types (Either), sealed classes and exhaustive when, the compiler is now responsible for maintaining our code and will force us (unless explicitly told not to) to handle all the error scenarios wherever we instantiate this class.
Further more, your “constructor” is now an honset function, it returns exactly what its signature says it would, it doesn’t hijack your program’s execution, which is a huge plus for readability and maintainability.
It also allows us to do something like this, which we couldn’t do with exceptions:
Neat! Isn’t it?
Our code is now predictable and our invariants are established!
Except, they’re not…
This is valid Kotlin code:
The copy
method of Kotlin’s data classes breaks our invariants, it can bypass all of our rules and it can create “illegal” instances of our class.
Even worse for those throwing exceptions inside the constructor! Who would expect this innocent copy
method to throw an exception? No one, and you can’t document it for that matter!
It is a well-known design flaw in the language, with no apparent way to fix it, while keeping backward-compatibility.
So .. dead end? Was this all in vain? That would’ve been funny, but no!
This’s where the NoCopy compiler plugin comes into play, by simply annotating our data class with @NoCopy
:
The copy
method of this data class can no longer be referenced or called:
With that taken care of, our invariants are now truly established and our code is now truly safe.
Summary
By using a combination of value-based classes and ADTs, we managed to create robust data models that enforce their own rules and have the compiler help us maintaining them.
We also used the NoCopy compiler plugin to prevent the copy
method of the data classes from breaking our invariants.
Links
For ready implementations of Either
and other functional constructs,
Arrow-kt is considered the best choice for Kotlin developers.
Follow me on Twitter & Github, Connect on LinkedIn.
Thank you for reading this article through. Liked it? Clap your 👏 to say “thanks!” and help others find this article.