Photo by Joseph James - Golden Threads: A Symphony of Sunlight and Silk

Mastering Abstraction over Modularization in Android Dagger2

Joseph James (JJ)
4 min readJul 23, 2023

--

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:

  1. :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.
  2. :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.
  3. :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:

  1. Testability: With this approach, writing unit or integration tests becomes a lot easier. We can simply swap the actual implementations with our test implementations.
  2. 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.
  3. Scalability: This structure is very scalable. We can add more features or change existing ones by simply adding new modules or updating existing ones.
  4. 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!

--

--

Joseph James (JJ)
Joseph James (JJ)

No responses yet