Using Composition in Kotlin
Learn how composition makes your Kotlin code more extensible and easy to maintain. By Prashant Barahi.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Using Composition in Kotlin
30 mins
- Getting Started
- Inheritance
- Liskov Substitution Principle
- Implementation Inheritance Antipatterns
- Single Implementation Inheritance
- Tight Coupling
- Exposing Superclass APIs Unnecessarily
- Exploding Numbers of Subclasses
- Composition
- Refactoring Using Composition
- Refactoring the UserMediator Class
- From Composition to Aggregation
- Refactoring the Pizza Class
- Handling the Exposure Problem
- Using Inheritance
- Delegation Pattern in Kotlin
- Where to Go From Here?
Refactoring Using Composition
With composition, you use small parts to compose a complex object. In this section, you’ll see how you can use this composition-based approach to avoid or mitigate the design issues introduced by implementation inheritance.
Refactoring the UserMediator Class
Because you can’t extend more than one parent class, the simplest fix to the broken UserMediator
would be to remove the open
keyword from UserApiService
and UserCacheService
, and instead have them as private instance fields of UserMediator
, as shown in the snippet below:
class UserMediator {
private val cacheService: UserCacheService = UserCacheService()
private val apiService: UserApiService = UserApiService()
/**
* Search for [User] with [username] on cache first. If not found,
* make API calls to fetch the [User] and persist it in server.
*
* @throws UserNotFoundException if it is not in the "server".
*/
fun fetchUser(username: String): User {
return cacheService.findUserById(username)
?: apiService.fetchUserByUsername(username)?.also { cacheService.saveUser(it) }
?: throw UserNotFoundException(username)
}
}
Notice how the classes that UserMediator
extended are converted to private instance fields of that class.
Moreover, you can make this class easier to test by accepting these instance fields as arguments to the constructor and outsourcing the creation of these fields to the client. This is called dependency injection.
From Composition to Aggregation
Remove these two fields and create a constructor for UserMediator
, taking these two instance variables as arguments:
class UserMediator(
private val cacheService: UserCacheService,
private val apiService: UserApiService
) {
// methods...
}
And in main()
of the UserDemo.kt, use the following code to initialize mediator
:
val mediator = UserMediator(
cacheService = UserCacheServiceImpl(),
apiService = UserApiServiceImpl()
)
UserMediator
now depends on the user of the class to provide its dependencies. And during testing, you can pass in test stubs that fit your test situation — making testing a lot easier.
UserMediator
(the one with default constructor) exhibits composition relationship with its contained instances since their lifecycle is bound to the container’s lifecycle. In contrast, the second revision of UserMediator
(the one with constructor arguments) exhibits aggregation relationship since it expects client to supply the dependencies. So the contained instances can exists even after the object of UserMediator
is destroyed.
Put your caret on the UserMediator
class definition and press Control-Enter. Then, select Create test. This creates a file — UserMediatorTest.kt — inside the test directory. Open it and paste the following snippet:
internal class UserMediatorTest {
private lateinit var mockApi: UserApiService
private lateinit var realCache: UserCacheService
@BeforeEach
fun setup() {
// 1
realCache = UserCacheServiceImpl()
// 2
mockApi = object : UserApiService {
private val db = mutableListOf<User>()
init {
db.add(User("testuser1", "Test User"))
}
override fun fetchUserByUsername(username: String): User? {
return db.find { username == it.username }
}
}
}
@Test
fun `Given username when fetchUser then should return user from cache and save it in cache`() {
// 3
val mediator = UserMediator(realCache, mockApi)
val inputUsername = "testuser1"
val user = mediator.fetchUser(inputUsername)
assertNotNull(user)
assertTrue { user.username == inputUsername }
// Check if saved in cache
assertNotNull(realCache.findUserById(inputUsername))
}
}
Here’s a breakdown of the code above:
- Initialize
realCache
as an instance ofUserCacheServiceImpl
. Because this class only uses in-memory data structure, you don’t have to mock it. - But
UserApiServiceImpl
performs a “network” call, and you don’t want the result of test cases to depend on the server’s response or availability. So it’s better to mock or stub it. Here, you’ve replaced it with an implementation that instead uses in-memory data structure, so you determine its result and can change it to match your test scenario. - Because
UserMediator
takes instances ofUserCacheService
andUserApiService
as arguments, you can pass in the above variables.
In the next section, you’ll refactor the exploded subclasses using a composition-based approach.
Refactoring the Pizza Class
Previously, you saw how multilevel inheritance can cause the number of subclasses to explode in number. You can avoid this problem by not modeling the relationship in the form of multilevel inheritance and instead establishing a “has-a” relation between Pizza
and the dimensions.
Open the Pizza.kt file inside the explosionofsubclassesdemo package and replace the content with the following snippet:
import java.math.RoundingMode
// 1
sealed class PizzaType {
data class Cheese(val cheeseName: String) : PizzaType()
data class Veggie(val vegetables: List<String>) : PizzaType()
}
enum class Size(val value: Int) {
LARGE(12), MED(8), SMALL(6);
fun calculateArea(): Double {
// Area of circle given diameter
return (Math.PI / 4).toBigDecimal().setScale(2, RoundingMode.UP).toDouble() * value * value
}
}
// 2
class Pizza(val type: PizzaType, val size: Size) {
fun prepare() {
// 3
println("Prepared ${size.name} sized $type pizza of area ${size.calculateArea()}")
}
}
Here’s what’s going on in the snippet above. You:
- Extract the dimensions into a separate class —
PizzaType
— andSize
. - Have the original class refer to an instance of the extracted class. Here, the class
Pizza
consists of its two-dimension classes. - Make the composed class delegate any size-related calculation to the
Size
class or any type-related calculation to thePizzaType
class. This is how the composed class fulfills its responsibility: by interacting with the instance fields.
Finally, to run the class, open PizzaDemo.kt and replace the code in main()
with:
val largeCheesePizza = Pizza(Cheese("Mozzarella"), Size.LARGE)
val smallVeggiePizza = Pizza(Veggie(listOf("Spinach", "Onion")), Size.SMALL)
val orders = listOf(largeCheesePizza, smallVeggiePizza)
orders.forEach {
it.prepare()
}
Finally, run the file and you get the following output:
Prepared LARGE sized Cheese(cheeseName=Mozzarella) pizza of area 113.76 Prepared SMALL sized Veggie(vegetables=[Spinach, Onion]) pizza of area 28.44
With this implementation, you can add to any of the dimensions without having to worry about the explosion of subclasses.
Next, you’ll see how you can use a composition-based approach to control the exposure of APIs.
Handling the Exposure Problem
The rule of thumb in OOP is to write shy class. Shy class doesn’t reveal unnecessary implementation about itself to others. The java.util‘s Properties
clearly violate this. A better way to implement it would have been to use a composition-based approach instead.
Because Properties
is a built-in class provided by JDK, you won’t be able to modify it. So you’ll learn how it could have been made better, using a simplified version of it as an example. For this, create a new HashtableStore
class and paste the following snippet:
class HashtableStore {
// 1
private val store: Hashtable<String, String> = Hashtable()
// 2
fun getProperty(key: String): String? {
return store[key]
}
fun setProperty(key: String, value: String) {
store[key] = value
}
fun propertyNames() = store.keys
}
Here’s the code breakdown:
- With a composition-based approach, you create a private field in
HashtableStore
and initialize it as an instance ofHashtable
. To provide the functionality of data storage, you need to interact with this instance. Recall the rule of thumb: Write shy classes. Making the instance private prevents outsiders from accessing it, helping you achieve encapsulation! - You expose public methods that the user of this class can access. This class exposes three such methods, and each method forwards its operation to the private field.
In the same file, create main()
and paste the following code inside it:
val properties = HashtableStore()
properties.setProperty("setProperty1", "val1")
properties.setProperty("setProperty2", "100")
properties.propertyNames().toList().forEach {
println("$it: ${properties.getProperty(it.toString())}")
}
If you want all the features Properties
provides while keeping the “exposure area” under your control, you can create a wrapper around it and expose your own methods. Create a new class PropertiesStore
and paste in the following code:
class PropertiesStore {
private val properties = Properties()
fun getProperty(key: String): String? {
return properties.getProperty(key)
}
fun setProperty(key: String, value: String) {
properties.setProperty(key, value)
}
fun propertyNames() = properties.propertyNames()
}
Like HashtableStore
, PropertiesStore
uses a private instance but of Properties
with public methods that interact with it. Because you use Properties
as an instance field, you also get the benefits from any future updates on Properties
.
You’ve learned how a composition-based approach can help you solve design issues. In the next section, you’ll learn about its shortcomings.