Mastering Abstraction over Modularization in Android Dagger2
Introduction
In the rapidly evolving world of Android app development, modularization is more than a buzzword — it’s a fundamental strategy to create maintainable, scalable, and robust applications. To reach the next level of modularization, we introduce a concept known as abstraction.
In this article, we will deep dive into an approach where we use abstraction layers over modularization. We’ll create a feature module with three sub-modules: :api
, :implementation
, and :test
.
Understanding the Three Sub-Modules
Let’s breakdown what these three sub-modules represent:
- :api: Exposed to the outer world, this layer contains only interfaces. It’s our contract that describes how the rest of our application can interact with our module.
- :implementation: The
:implementation
module is where we flesh out the actual functionality of our feature. This module isn't directly exposed to other modules. It uses a Dagger module to bind the:api
interfaces with their implementations. - :test: This sub-module provides alternate implementations for testing purposes. The
:test
module, similar to the:implementation
module, uses a Dagger module to bind the:api
interfaces to these test implementations.
Now that we have our structure laid out, let’s see how we can put these components together using Dagger2.
Implementing Modularization with Dagger2
Consider an authentication module in our application. We’ll implement this using our :api
, :implementation
, and :test
structure.
The :api
would look something like this:
// AuthAPI.kt
interface AuthAPI {
fun login(username: String, password: String): Boolean
fun logout()
}
We then provide a concrete implementation in the :implementation
module:
// FirebaseAuthImpl.kt in :implementation module
class FirebaseAuthImpl : AuthAPI {
override fun login(username: String, password: String): Boolean {
// Firebase-specific login code
}
override fun logout() {
// Firebase-specific logout code
}
}
We also provide an alternate implementation for testing in our :test
module:
// FakeAuthImpl.kt in :test module
class FakeAuthImpl : AuthAPI {
override fun login(username: String, password: String): Boolean {
// For tests, just return true
return true
}
override fun logout() {
// No operation for tests
}
}
Next, we use Dagger2 to bind these implementations to our AuthAPI
interface. In the :implementation
module, we do this:
// AuthModule.kt in :implementation module
@Module
abstract class AuthModule {
@Binds
abstract fun bindAuthAPI(impl: FirebaseAuthImpl): AuthAPI
}
In our :test
module, we do something similar but bind AuthAPI
to FakeAuthImpl
:
// TestAuthModule.kt in :test module
@Module
abstract class TestAuthModule {
@Binds
abstract fun bindAuthAPI(impl: FakeAuthImpl): AuthAPI
}
Setting Up the Dagger Components
Next, we need to set up our Dagger components to use the right modules.
In the :main
module of our app, we'll set up our ApplicationComponent to include the AuthModule from our :implementation
module:
// ApplicationComponent.kt in :main module
@Component(modules = [AuthModule::class])
interface ApplicationComponent {
fun inject(app: MainApplication)
// Other injection targets...
}
In our MainApplication
class, we then initialize Dagger:
// MainApplication.kt
class MainApplication : Application() {
lateinit var appComponent: ApplicationComponent
override fun onCreate() {
super.onCreate()
appComponent = DaggerApplicationComponent.create()
appComponent.inject(this)
}
}
For our tests, we will set up a separate TestApplicationComponent that uses the TestAuthModule from our :test
module:
// TestApplicationComponent.kt in :test module
@Component(modules = [TestAuthModule::class])
interface TestApplicationComponent {
fun inject(app: TestApplication)
// Other injection targets...
}
And similarly, in our TestApplication
class, we initialize Dagger:
// TestApplication.kt
class TestApplication : Application() {
lateinit var testAppComponent: TestApplicationComponent
override fun onCreate() {
super.onCreate()
testAppComponent = DaggerTestApplicationComponent.create()
testAppComponent.inject(this)
}
}
This way, when running our app, Dagger uses the AuthModule from :implementation
to provide AuthAPI
instances. But when running tests, it uses the TestAuthModule from :test
to provide AuthAPI
instances, ensuring we're using our fake implementations during tests.
Harnessing the Power of Abstraction
By using the :api
, :implementation
, and :test
structure, we've effectively abstracted our authentication module. This is powerful for several reasons:
- Testability: With this approach, writing unit or integration tests becomes a lot easier. We can simply swap the actual implementations with our test implementations.
- Encapsulation: We’re also practicing good object-oriented design principles by encapsulating the complexity of our implementations and exposing only the high-level
:api
interfaces. - Scalability: This structure is very scalable. We can add more features or change existing ones by simply adding new modules or updating existing ones.
- Flexibility: It gives us the flexibility to change our underlying implementations without affecting other modules that rely on our
:api
.
In conclusion, abstracting over modularization offers a powerful way to structure your Android apps. By combining this approach with Dagger2, we can achieve clean separation of concerns, easy testing, and a flexible and scalable architecture.
Happy coding!