Preferring Composition Over Inheritance: A Personal Learning Journey
A couple of summers ago, I was introduced to a design concept in software development by a staff engineer — “ Prefer Composition over Inheritance”. Initially, it didn’t strike a chord, but as I delved into Android development and Kotlin, I realized its profound impact, particularly in class structuring and unit testing.
The Initial Approach
My initial approach to structuring classes in Android was through inheritance. It seemed straightforward with a base class UserDataRepository and extended classes for different data sources:
internal open class UserDataRepository {
open fun getUserData(userId: String): UserData { /*…*/ }
}
internal class LocalUserDataRepository : UserDataRepository() {
override fun getUserData(userId: String): UserData { /*…*/ }
}
internal class RemoteUserDataRepository : UserDataRepository() {
override fun getUserData(userId: String): UserData { /*…*/ }
}
However, as the complexity increased, the drawbacks of this rigid hierarchy became apparent, particularly when writing unit tests, which were often convoluted and hard to isolate.
Embracing Composition
Shifting to composition, I defined an interface and implemented it in distinct classes:
internal interface UserDataRepository {
fun getUserData(userId: String): UserData
}
internal class LocalUserDataRepository : UserDataRepository {
override fun getUserData(userId: String): UserData { /*…*/ }
}
internal class RemoteUserDataRepository : UserDataRepository {
override fun getUserData(userId: String): UserData { /*…*/ }
}
The Power of the Composite Repository
The CompositeUserDataRepository combined these implementations, allowing seamless data source switching:
internal class CompositeUserDataRepository(
private val localRepository: UserDataRepository,
private val remoteRepository: UserDataRepository
) : UserDataRepository {
override fun getUserData(userId: String): UserData {
return if (networkAvailable()) {
remoteRepository.getUserData(userId)
} else {
localRepository.getUserData(userId)
}
}
private fun networkAvailable(): Boolean {
// Check network availability
}
}
Demonstrating the Power with Unit Tests
Unit testing became more straightforward and effective with composition. Each component could be tested independently, ensuring isolation and simplicity.
internal class LocalUserDataRepositoryTest {
private val repository = LocalUserDataRepository()
@Test
fun `verify local data fetch`() {
val userData = repository.getUserData("userId")
// Assert that the userData is fetched from the local source
}
}
internal class RemoteUserDataRepositoryTest {
private val repository = RemoteUserDataRepository()
@Test
fun `verify remote data fetch`() {
val userData = repository.getUserData("userId")
// Assert that the userData is fetched from the remote source
}
}
internal class CompositeUserDataRepositoryTest {
private lateinit var localRepository = mock<UserDataRepository>()
private lateinit var remoteRepository = mock<UserDataRepository>()
private val compositeRepository = CompositeUserDataRepository(localRepository, remoteRepository)
@Test
fun `verify correct data source used based on network availability`() {
// Setup conditions to simulate network availability or lack thereof
// Verify that the correct data source is used under each condition
}
}
Conclusion
My journey of understanding and implementing Prefer Composition over Inheritance in Android development has been a transformative experience, especially highlighted in the realm of unit testing. This approach has not only streamlined the process of managing data from various sources but has also vastly improved the testability of my code.
With composition, each data source — local or remote — is encapsulated within its own class, allowing for independent and isolated unit tests. This isolation makes the tests more reliable and easier to understand, as each test focuses on a single responsibility. Furthermore, the CompositeUserDataRepository can be tested by mocking its dependencies, ensuring that our tests accurately simulate different scenarios, like varying network conditions.
Embracing composition has transformed my code into something much more manageable and easier to test. This principle has become a key guide in how I design my apps, highlighting the value of creating flexible and robust software. This journey, which began with a simple piece of advice, has fundamentally changed how I approach Android app development, showing how the right architecture can significantly improve software quality.
Happy Coding! 🚀