Tomas Mlynaric6 min

Lock Your Dagger in Gradle Modules

EngineeringApr 1, 2021

Engineering

/

Apr 1, 2021

Tomas MlynaricAndroid Engineer

Share this article

You finally decided to split your monolithic Gradle module, into smaller ones. But have you considered splitting your almighty Dagger AppComponent as well?

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 implementationto 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.

Share this article



Sign up to our newsletter

Monthly updates, real stuff, our views. No BS.