Using Kotlin’s Delegation to Add Superpowers to a Data Class
If you’ve been writing code in Kotlin for any amount of time you’ve almost certainly run into delegation. One of the most common usages of the delegate pattern is by lazy, which enforces that a value not be calculated until it’s requested, and this value isn’t re-calculated upon subsequent requests.
Another common usage of the delegate pattern is when it’s used to implement an interface
by
some injected parameter, like CoroutineScope by injectedScope
. This allows us to call public members on the injected parameter without having to continuously refer to it directly, similar to when we use with(something)
.
Enhancing Fixed-Size State Containers
Until recently, my experience with delegation was limited to the two examples above. However, I have since come across an elegant way to give fixed-size data containers the powers of a Collection
by making use of delegation.
Fixed-size data containers are a requirement I’ve come across quite often in my career. Often these are a group or list of entities that are known at compile time but need massaging or the fulfillment of various fields by an API response or other runtime input. One example of such a container is a suite of features that the user is eligible to use. The potential features are known at compile time, but whether the user is eligible for the feature and all the characteristics of each feature is not known until the app is run.
In Kotlin, we generally represent this type of data using one of two things: either a Data Class or Set<T>
.
Data Class
A data class is a good starting point when designing a container such as this one.
data class FeatureSuite(
val featureOne: Feature.TypeA.FeatureOne,
val featureTwo: Feature.TypeA.FeatureTwo,
val featureThree: Feature.TypeB.FeatureThree,
...
)
One major benefit of having a data class
representation of these features is that we can guarantee every feature is accounted for when the suite is constructed. That's because each feature is a required parameter of the constructor of our class. In addition, we have non-nullable access to each feature as public properties of the suite, so if we need to check if a given feature is enabled, all we must do is suite.featureOne.isEnabled()
. As an aside, using a data class
over a normal class
ensures that every parameter must be a val
or var
which helps enforce the container characteristics of this domain entity.
However, there is a downside to the data class
. If we want to get the list of features that are of type TypeA
, we have no way other than a "hard-coded" public method that creates and returns a list of all of the known TypeA
features. If we are to add a new TypeA
feature to the suite, there is a good chance we will forget to add it to this list, and it also means we are tracking the same data in two separate places which adds complexity. Unit tests to catch this would be brittle and best-effort, which is not something we aspire to when writing tests.
Set<Feature>
The second approach we can take is using a Set<Feature>
as a container for our features. This might look something like:
typealias FeatureSuite = Set<Feature>
We could then create a FeatureSuite
by doing:
val featureSuite = setOf(
Feature.TypeA.FeatureOne(...),
Feature.TypeA.FeatureTwo(...),
Feature.TypeB.FeatureThree(...),
...
)
By using a Set
, we are now able to do things like filterIsInstance
to get all features of type TypeA
, without having to worry about missing one if we add a new feature.
However, we’ve lost the benefits of a data class
! We no longer can guarantee that each feature is passed in when creating the suite, and we don't have null-safe access to each feature because there's no guarantee the feature exists within the Set
! That lack of null-safety will propagate to all usages of the suite, and could cause a RuntimeException
if a feature that wasn't added to the suite is requested.
So, how do we get the benefits of the data class
and the Set
, all in one entity? You guessed it, delegation!
Delegation, Your New Superpower!
We should define our FeatureSuite
as follows:
data class FeatureSuite(
val featureOne: Feature.TypeA.FeatureOne,
val featureTwo: Feature.TypeA.FeatureTwo,
val featureThree: Feature.TypeB.FeatureThree,
...
) : Set<Feature> by setOf(
featureOne,
featureTwo,
featureThree,
...
)
Note that we now have the benefits of both the data class
and the Set
by using delegation to a Set
consisting of all the feature parameters.
- Guarantee that all features are passed into the suite via the constructor
- Null-safe access to features via
featureSuite.someFeature
- Ability to
filterIsInstance
and receive all features of a given type without worrying about missing one
We can begin to add additional functionality to make call sites cleaner as well:
val typeAFeatures: List<Feature.TypeA> by lazy {
filterIsInsance(Feature.TypeA::class.java)
}val enabledFeatures: List<Feature> by lazy {
filter { it.isEnabled() }
}
Testing
We should enforce a couple things via unit tests to mitigate the risk of any future developer error:
- All features should be mapped to the underlying
Set
(need to make sure we don't leave one out!). This is the most important test. - The features passed to the suite are in fact the exact same (object equality) features present in the underlying
Set
The first test is simple. Because Set
by nature doesn't allow duplicates, we just need to make sure that the created Set
has the same size as the number of parameters of the suite itself.
// Notes: `primaryConstructor` is made available through `kotlin-reflect` and `FakeFeatureSuite` is just a convenience method to create a (real) test object that can be re-used in other tests.@Test
fun `All features mapped to Set`() { val featuresRequiredInConstructor =
FeatureSuite::class.primaryConstructor!!.parameters.size val featuresAddedToSet = FakeFeatureSuite().size assertThat(featuresAddedToSet)
.isEqualTo(featuresRequiredInConstructor)}
The second test is less bulletproof, but still worthwhile:
@Test
fun `Object equality of features`() { val featureSuite = FakeFeatureSuite() val featureOneFromSet = featureSuite.single {
it is Feature.TypeA.FeatureOne
} assertThat(featureSuite.featureOne)
.isSameAs(featureOneFromSet)}
The risk with this test is that a new feature will be added to the suite, but not added to this test. One way we can attempt to guard against this is by checking that the number of assertions made is always equal to the number of features within the suite.
val assertions = listOf<AbstractAssert<*,*>>(
// Wrap our assertions in a List assertThat(featureSuite.featureOne)
.isSameAs(featureOneFromSet)
)val expectedAssertionCount =
FeatureSuite::class.primaryConstructor!!.parameters.sizeassertThat(assertions.size)
.`as`( "Ensure all constructor params checked for object equality").isEqualTo(expectedAssertionCount)
Now, if a new feature is added but not added to our assertions, this test will fail. The only way we can mess it up now is by accidentally duplicating assertions in the list rather than adding a unique assertion for a new feature.
Conclusion
By combining the benefits of a normal data class
with those of a Set
, we are able to surface all the desired functionality for our suite of features in a safe way that avoids RuntimeException
s. As always, if you have any suggestions on how I might improve on this implementation, definitely shoot them my way!
DISCLOSURE STATEMENT: © 2020 Capital One. Opinions are those of the individual author. Unless noted otherwise in this post, Capital One is not affiliated with, nor endorsed by, any of the companies mentioned. All trademarks and other intellectual property used or displayed are property of their respective owners.
Originally published at https://brandontrautmann.com on May 27, 2020.