Mastodon

Dagger Party Tricks: Private Dependencies

Leveraging Dagger qualifiers to hide intermediate dependencies.

Dagger Party Tricks: Private Dependencies

This is part 2 of a blog series of a talk I gave at Droidcon NYC and Londroid earlier this year. Dagger has a steep learning curve that frequently leaves developers not wanting to explore it further once they've integrated it. This series attempts to show some neat "party tricks" and other clever Dagger patterns to inspire more advanced usage.


The Problem

Consider the following Dagger module:

@Module
object NetworkModule {
  @Provides fun client(): OkHttpClient {
    //...
  }
  
  @Provides fun retrofit(client: Lazy<OkHttpClient>): Retrofit {
    //...
  }
}
See the first post in this series for why the client should be lazy!

For all intents and purposes, the OkHttpClient being provided here is just an implementation detail of the Retrofit provider. NetworkModule might be included in several other feature modules though, and consumers could (unintentionally or unscrupulously) ask for that client for their own purposes. That is to say, I could write another module like this and Dagger would happily comply:

@Module(includes = [NetworkModule::class])
object FeatureModule {
  @Provides
  fun networkAccessor(client: OkHttpClient): NetworkAccessor {
    // ಠ_ಠ
  }
}

In a way, this breaks encapsulation. You effectively want NetworkModule to have a limited "public API" of sorts and just expose exactly what you want. However, everything in a module effectively has the same visibility. Not only this, but Dagger only generates public factories for modules. How can we address this?

The Solution

Dagger has a notion of qualifiers. In short - these are custom annotations that are in turn annotated with @Qualifier, used to indicate added metadata context. In Dagger, qualifiers are considered part of the type signature. That is to say - String and @MyCustomQualifier String are considered to be two distinct dependencies as far as Dagger is concerned.

What do qualifiers have to do with this? Private qualifiers! Dagger may generate public-everything, but we can qualify these private dependencies with qualifier annotations that aren't visible outside of our module.

@Retention(BINARY)
@Qualifier
private annotation class InternalApi

@Module
object NetworkModule {
  @Provides 
  @InternalApi 
  fun provideClient(): OkHttpClient {
    //...
  }
  
  @Provides
  fun provideRetrofit(
    @InternalApi client: Lazy<OkHttpClient>
  ): Retrofit {
    //...
  }
}
Kotlin - file-private annotation class
@Module
public abstract class NetworkModule {

  @Retention(CLASS)
  @Qualifier
  private @interface InternalApi {}

  @Provides 
  @InternalApi 
  static OkHttpClient provideClient() {
    //...
  }
  
  @Provides
  static Retrofit provideRetrofit(
    @InternalApi Lazy<OkHttpClient> client
  ) {
    //...
  }
}

Java - private nested class

We can use these to guard access to private intermediate dependencies. The compiler actually enforces this visibility for us too. As mentioned above, Dagger now sees OkHttpClient as @InternalApi OkHttpClient. Since @InternalApi is private, no one downstream could write this:

@Module(includes = [NetworkModule::class])
object FeatureModule {
  @Provides
  @InternalApi // <-- Won't compile!
  fun networkAccessor(client: OkHttpClient): NetworkAccessor {
    // ಠ_ಠ
  }
}

Other Benefits

This helps avoid accidentally using a dependency you don't own

@Module(includes = [NetworkModule::class])
object FeatureModule {
  // This no longer compiles.
  // Dagger will fail and say it can't find a matching dependency
  @Provides
  fun networkAccessor(client: OkHttpClient): NetworkAccessor {
    // ಠ_ಠ
  }
}

By extension, this also applies to all other consumer mechanisms too!

@Component(modules = [NetworkModule::class])
interface FeatureComponent {
  // This no longer compiles.
  // Dagger will fail and say it can't find a matching dependency
  fun client(): OkHttpClient
  
  @InternalApi // <-- Won't compile!
  fun internalClient(): OkHttpClient
}
Components
@Component(modules = [NetworkModule::class])
interface FeatureComponent {
  fun inject(controller: FeatureController)
}

class FeatureController @Inject constructor(
  // This no longer compiles.
  // Dagger will fail and say it can't find a matching dependency
  val client: OkHttpClient,

  @InternalApi // <-- Won't compile!
  val internalClient: OkHttpClient
)
Injections

Hope this is useful! It's definitely the kind of thing that's usually only a problem in larger/distributed codebases, but it's also a good hygiene tactic to prevent dependencies from leaking.

If you want some real examples in action, I use this in my CatchUp side project: