The time has come. You've finally decided to split your monolithic Gradle module into smaller ones. But have you considered splitting your almighty Dagger AppComponent
as well? Everything works, so why bother, right?
Note: In the article, references to modules refer to Gradle modules and not to Dagger modules.
Dependency Encapsulation (API vs. Implementation)
Imagine you have 3 modules :app
, :feature
and :library
as in the picture below.
Your :app
module depends on the :feature
module in the usual way
implementation(project(":feature"))
and the :feature
module depends on some external :library
implementation(project(":feature"))
Note: You may have a similar setup if you follow layer-based modularization.
You chose the :library
because it contains some awesome LibraryManager
you want to use in your codebase. Suddenly, your inner voice reminded you of the Dependency inversion principle, so you encapsulated the LibraryManager
into your own class FeatureRepository
. Right after you stopped writing, the inner voice whispered again. “This time check Inversion of Control,” it said. And so you added @Inject
to the constructor of the FeatureRepository
.
class FeatureRepository @Inject constructor(
private val libraryManager: LibraryManager
) {
fun doSomething() {
libraryManager.doSomething()
}
}
Since there was no more whispering, you compiled the project… and it failed.
LibraryManager cannot be provided without an @Inject constructor or an @Provides-annotated method.
Oh yeah, you forgot — Dagger doesn’t know anything about LibraryManager
.
You need to create Dagger @Module
inside your :feature
Gradle module which @Provides
the LibraryManager
.
@Module
object FeatureModule {
@Provides
fun provideLibraryManager() = LibraryManager()
}
Now it should be fine, right? You don’t have any import
from the library in :app
module — only your FeatureRepository
.
You compile again … and it fails, again.
DaggerAppComponent.java:26: error: cannot access LibraryManager
I'll spare your time chasing the error reason. The problem is that Dagger generates the code composition only in a class implementing @Component
interface. The code is generated within the :app
module and attempts to instantiate LibraryManager
from the :library
. It doesn't “see” it though, because of Gradle's implementation
(:library)
.
The same would happen if you wanted to access the class manually from your :app
module. But in this case, IDE error inspection would give you a hint.
The picture below explains what happens graphically.
What can you do about it?
The simplest solution is to change implementation
to api
in Gradle file and it's done 🎤 ⤵️. But please, don't 🙏 . It breaks encapsulation — making FeatureManager
visible to all of your modules! Also, each time :library
ABI changes, Gradle will recompile each module depending on the :feature
module!
The proper solution is a bit harder than a one-line fix. I’ll show you how to do it — but first, I’d like to throw more wood into the fire by describing a similar situation: Encapsulating your own code.
Implementation Encapsulation (Public vs. Internal)
Imagine the same project with :app
and :feature
modules (we'll forget about :library
for this example). You want your FeatureRepository
to be public
interface and implementation FeatureRepositoryImpl
to be internal
, so that nothing but the :feature
module can access its guts.
The picture and code below represent the situation:
interface FeatureRepository {
fun doSomething()
}
internal class FeatureRepositoryImpl @Inject constructor() : FeatureRepository {
override fun doSomething() {
// ommitted
}
}
In this case, you don’t have to explicitly use @Provides
for your FeatureRepository
, but you need to tell Dagger the relation between implementation and its interface with @Binds
(@Provides
would also work, but it's less performant).
@Module
abstract class FeatureModule {
@Binds
abstract fun bindsFeatureRepository(impl: FeatureRepositoryImpl): FeatureRepository
}
As you’ve guessed by now, you will face the same problem as before. But this time, even the IDE error inspection tells you that you can’t do that:
`public` function exposes its `internal` parameter type FeatureRepositoryImpl
What can you do about it?
Again, the simplest solution is to make FeatureRepositoryImpl
as public
. The question then is whether you get any benefit from implementing the interface at all. Also, if you wanted to hide some different implementation of the same interface, you can't. It has to be public
.
Encapsulate Dagger in Gradle Module
To encapsulate dependencies in Gradle modules, you need to create a separate Dagger @Component
in each module. You can't create @Subcomponent
for this, because the subcomponent generates code within its parent component (and therefore still in the :app
).
Technically, encapsulating internal
implementations would work with @Subcomponent
. Java doesn't have internal
visibility modifier and therefore classes compiled from Kotlin are public
. But if you want to encapsulate a 3rd-party dependency, the problem will be the same — it won't compile.
To kill two birds with one stone, we have to stick to component dependencies.
First, we need to create FeatureComponent
and explicitly specify what it can provide outside of the component — this is Dagger's kind of public
/private
visibility modifier:
@Component(modules = [FeatureModule::class])
interface FeatureComponent {
@Component.Factory
interface Factory {
fun create(): FeatureComponent
}
// explicitly specify, otherwise can't provide outside of the component
val featureRepository: FeatureRepository
}
Then, add the component to dependencies
list in AppComponent
:
@Component(
dependencies = [
FeatureComponent::class
]
)
@Singleton
interface AppComponent {
@Component.Factory
interface Factory {
fun create(
featureComponent: FeatureComponent,
): AppComponent
}
}
And finally, instantiate both components in your Application
and pass FeatureComponent
to AppComponent
's factory method.
val featureComponent = DaggerFeatureComponent.factory().create()
val appComponent = DaggerAppComponent
.factory()
.create(featureComponent)
You can now enjoy encapsulated dependencies 🎉. You no longer have to worry about someone using an implementation instead of an abstraction. Nor do you need to worry about polluting your codebase with some library code.
This setup allows both examples from the beginning of the article to work.
Need Scope?
There’s one catch though (there always is, right? 😅). If you want to have your FeatureComponent
scoped (e.g. @FeatureScope
), the compilation will fail again.
AppComponent.java:7: error: This @Singleton component cannot depend on scoped components: @FeatureScope FeatureComponent
This looks like a dead-end, right? Well, not entirely.
Dagger component is a simple annotated interface (or abstract class) with generated implementation. We can trick Dagger compiler to skip the scope checking by passing dependency to a “contract” instead of the real component.
It works like this: AppComponent
depends on FeatureComponentContract
, which is implemented by the FeatureComponent
. This way, AppComponent
doesn't depend directly on the FeatureComponent
. Since the contract is just a plain interface, Dagger won’t complain about scopes. At the same time, it will honor the annotations of the components and will generate their implementations.
However, we will have to move the featureRepository: FeatureRepository
definition to the contract interface. This way, AppComponent
knows that it can get the featureRepository
from the contract and doesn't care about what implements the contract.
There won't be any change in your Application
class, because everything accepts the same interface.
Visually, it looks like this:
Code representation of FeatureComponent
with FeatureComponentContract
looks like this:
interface FeatureComponentContract {
val featureRepository: FeatureRepository
}
@Component(modules = [FeatureModule::class])
@FeatureScope
interface FeatureComponent : FeatureComponentContract {
@Component.Factory
interface Factory {
fun create(): FeatureComponent
}
}
and finally, ApplicationComponent
:
@Component(
dependencies = [
FeatureComponentContract::class // dependency on contract
]
)
@Singleton
interface AppComponent {
@Component.Factory
interface Factory {
fun create(
featureComponent: FeatureComponentContract, // dependency on contract
): AppComponent
}
}
This way, you can have Dagger component in each Gradle module and properly encapsulate your code. Each of your components can hold “local singletons.” Your FeatureComponent
can hold objects annotated with @FeatureScope
, and AppComponent
can hold @Singleton
.
There's one trick for those who’ve just started to modularize their projects and have @Singleton
across the whole codebase. You can have the FeatureComponent
annotated with @Singleton
instead of a custom scope. Be careful, though! This may result in your app containing multiple instances of the same class annotated with @Singleton
, because each scoped class is held in its component. If you have some @Module
which @Provides
the same @Singleton
object inside of multiple components, it will be held as a singleton in each of those components.
Conclusion
Nowadays, many apps have multiple Gradle modules — but stick to one Dagger component. If you want more control over the visibility of your dependencies, you should consider locking Dagger component in each Gradle module.
PS: This setup is also great for dynamic feature modules, but more about that next time. 🤫
Thank you to Iveta Jurcikova and Marek Abaffy for reviewing the article.