Hilt @Qualifier: Inject Multiple Implementations of Same Interface
The Problem
I came across an Android developer quiz on Reddit that tested “Hilt qualifier patterns” as a senior-level topic. One commenter, Zhuinden, mentioned a question about whether to “Add an abstract @Binds function from DefaultAuthRepository to AuthRepository in a Hilt module” or “Replace the interface with the concrete class.”
His comment revealed a common frustration: the temptation to just remove interfaces when there’s only one implementation. He called it the “Clean Architecture Things To Seem Smart” anti-pattern.
That comment stuck with me. The real question isn’t whether you need an interface for a single implementation. The question is: what do you do when you have multiple implementations of the same interface and need to inject different ones in different places?
This is where @Qualifier comes in.
Why You Need Qualifiers
Consider this scenario: you have a UserRepository interface with two implementations. One fetches data from a local database, the other from a remote API. A SyncManager needs both implementations to sync data between them.
UserRepository (interface) | +-- LocalUserRepository (Room database) | +-- RemoteUserRepository (Retrofit API)
SyncManager needs BOTH: - LocalUserRepository for saving data - RemoteUserRepository for fetching dataWithout qualifiers, Hilt has no way to distinguish between the two implementations. You’d get a compilation error about multiple bindings for the same type.
flowchart TD A[UserRepository Interface] --> B[LocalUserRepository] A --> C[RemoteUserRepository] D[SyncManager] -->|needs| B D -->|needs| C E[Hilt Container] -.->|Which one?| A style E fill:#ffccccThe @Qualifier Solution
Hilt’s solution is the @Qualifier annotation. You create custom annotations to mark different implementations, then use those annotations at injection points.
Step 1: Define Custom Qualifiers
@Qualifier@Retention(AnnotationRetention.BINARY)annotation class LocalDataSource
@Qualifier@Retention(AnnotationRetention.BINARY)annotation class RemoteDataSourceThe @Retention(AnnotationRetention.BINARY) ensures the annotation is stored in the compiled class files, which is required for Dagger/Hilt to process it at compile time.
Step 2: Create Your Implementations
class LocalUserRepository @Inject constructor( private val database: AppDatabase) : UserRepository { override suspend fun getData(): List<User> { return database.userDao().getAll() }
override suspend fun saveData(users: List<User>) { database.userDao().insertAll(users) }}
class RemoteUserRepository @Inject constructor( private val api: UserApi) : UserRepository { override suspend fun getData(): List<User> { return api.getUsers() }
override suspend fun saveData(users: List<User>) { // Remote repo might not support saving directly throw UnsupportedOperationException("Use local repository for saving") }}Step 3: Bind with Qualifiers in a Module
@Module@InstallIn(SingletonComponent::class)abstract class RepositoryModule {
@LocalDataSource @Binds @Singleton abstract fun bindLocalRepository( local: LocalUserRepository ): UserRepository
@RemoteDataSource @Binds @Singleton abstract fun bindRemoteRepository( remote: RemoteUserRepository ): UserRepository}The @Binds annotation is more efficient than @Provides for interface bindings because it generates less code and is processed faster by the Dagger compiler.
Step 4: Inject with Qualifiers
class SyncManager @Inject constructor( @LocalDataSource private val localRepo: UserRepository, @RemoteDataSource private val remoteRepo: UserRepository) { suspend fun sync(): SyncResult { return try { val remoteData = remoteRepo.getData() localRepo.saveData(remoteData) SyncResult.Success(remoteData.size) } catch (e: Exception) { SyncResult.Error(e.message ?: "Unknown error") } }}
sealed class SyncResult { data class Success(val count: Int) : SyncResult() data class Error(val message: String) : SyncResult()}Now Hilt knows exactly which implementation to inject at each site.
@Qualifier vs @Named
You might have seen @Named used for similar purposes. It’s a built-in qualifier that uses string identifiers:
@Module@InstallIn(SingletonComponent::class)object DataModule {
@Named("local") @Provides @Singleton fun provideLocalRepository(database: AppDatabase): UserRepository { return LocalUserRepository(database) }
@Named("remote") @Provides @Singleton fun provideRemoteRepository(api: UserApi): UserRepository { return RemoteUserRepository(api) }}
// Injection with @Namedclass SyncManager @Inject constructor( @Named("local") private val localRepo: UserRepository, @Named("remote") private val remoteRepo: UserRepository) { ... }This works, but I recommend custom qualifiers for production code. Here’s why:
| Aspect | Custom @Qualifier | @Named |
|---|---|---|
| Type safety | Compile-time checking | Runtime string matching |
| IDE support | Autocomplete, refactoring | No autocomplete for strings |
| Typos | Caught at compile time | Runtime failures |
| Readability | Self-documenting | Requires string constants |
// This compiles but fails at runtimeclass BadInjection @Inject constructor( @Named("locla") private val repo: UserRepository // Typo!)
// With custom qualifiers, this would be a compile errorclass GoodInjection @Inject constructor( @LoaclDataSource private val repo: UserRepository // Doesn't compile!)Real-World Use Cases
Qualifiers shine in several common scenarios:
Multiple Retrofit Services
@Qualifier@Retention(AnnotationRetention.BINARY)annotation class AuthApi
@Qualifier@Retention(AnnotationRetention.BINARY)annotation class UserApi
@Module@InstallIn(SingletonComponent::class)object NetworkModule {
@AuthApi @Provides @Singleton fun provideAuthRetrofit(): Retrofit { return Retrofit.Builder() .baseUrl("https://auth.example.com/") .addConverterFactory(GsonConverterFactory.create()) .build() }
@UserApi @Provides @Singleton fun provideUserRetrofit(): Retrofit { return Retrofit.Builder() .baseUrl("https://api.example.com/") .addConverterFactory(GsonConverterFactory.create()) .build() }
@AuthApi @Provides fun provideAuthService(@AuthApi retrofit: Retrofit): AuthService { return retrofit.create(AuthService::class.java) }
@UserApi @Provides fun provideUserService(@UserApi retrofit: Retrofit): UserService { return retrofit.create(UserService::class.java) }}
class AuthManager @Inject constructor( @AuthApi private val authService: AuthService, @UserApi private val userService: UserService) { ... }Different Data Sources
@Qualifier@Retention(AnnotationRetention.BINARY)annotation class Cache
@Qualifier@Retention(AnnotationRetention.BINARY)annotation class Network
@Module@InstallIn(SingletonComponent::class)abstract class DataSourceModule {
@Cache @Binds @Singleton abstract fun bindCacheDataSource( cache: CacheDataSource ): DataSource
@Network @Binds @Singleton abstract fun bindNetworkDataSource( network: NetworkDataSource ): DataSource}
class SmartRepository @Inject constructor( @Cache private val cache: DataSource, @Network private val network: DataSource, private val connectivityManager: ConnectivityManager) { suspend fun getData(): Data { return if (connectivityManager.isNetworkAvailable) { network.fetch() } else { cache.fetch() } }}Different Scopes for Same Type
@Qualifier@Retention(AnnotationRetention.BINARY)annotation class UserScoped
@Module@InstallIn(SingletonComponent::class)object AppModule { @UserScoped @Provides @Singleton fun provideUserPreferences(@ApplicationContext context: Context): SharedPreferences { return context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE) }}
@Module@InstallIn(ActivityComponent::class)object ActivityModule { @Provides fun provideActivityPreferences(activity: Activity): SharedPreferences { return activity.getPreferences(Context.MODE_PRIVATE) }}@Binds vs @Provides
When using qualifiers, you’ll often choose between @Binds and @Provides:
// @Binds: Use when you have a concrete implementation ready@Module@InstallIn(SingletonComponent::class)abstract class BindModule {
@LocalDataSource @Binds @Singleton abstract fun bindLocal(repo: LocalUserRepository): UserRepository}
// @Provides: Use when you need to construct the object@Module@InstallIn(SingletonComponent::class)object ProvideModule {
@LocalDataSource @Provides @Singleton fun provideLocal(database: AppDatabase): UserRepository { // Custom construction logic return LocalUserRepository(database).apply { enableCaching() } }}Use @Binds when:
- You’re binding an implementation to its interface
- The implementation can be injected by Hilt
- No custom construction logic is needed
Use @Provides when:
- You need to configure the object before returning it
- The class can’t be injected (third-party libraries)
- You need to choose between implementations at runtime
Do You Really Need That Interface?
Let me address the Reddit commenter’s point. Sometimes developers create interfaces just to follow “Clean Architecture” patterns, even when there’s only one implementation.
// Is this interface necessary?interface AuthRepository { suspend fun login(email: String, password: String): Result<User>}
// With only one implementationclass DefaultAuthRepository @Inject constructor( private val api: AuthApi) : AuthRepository { override suspend fun login(email: String, password: String): Result<User> { return api.login(email, password) }}Ask yourself:
- Will there ever be a second implementation? (Testing doesn’t count - use mocking frameworks)
- Do you need to swap implementations at runtime?
- Is this a public API that external code will implement?
If the answer to all three is “no,” consider using the concrete class directly:
// Just use the concrete classclass AuthRepository @Inject constructor( private val api: AuthApi) { suspend fun login(email: String, password: String): Result<User> { return api.login(email, password) }}
// No module needed, no qualifier neededclass LoginViewModel @Inject constructor( private val authRepository: AuthRepository) { ... }Common Mistakes
Mistake 1: Forgetting @Retention
@Qualifierannotation class LocalDataSource // Won't work at compile time!
// Correct@Qualifier@Retention(AnnotationRetention.BINARY)annotation class LocalDataSourceMistake 2: Qualifying the Wrong Place
// WRONG: Qualifier on the class itself@LocalDataSourceclass LocalUserRepository @Inject constructor(...): UserRepository
// CORRECT: Qualifier on the binding in the module@Module@InstallIn(SingletonComponent::class)abstract class RepositoryModule { @LocalDataSource @Binds abstract fun bindLocal(local: LocalUserRepository): UserRepository}Mistake 3: Mixing Qualifier Styles
// WRONG: Mixing @Named and custom qualifiersclass Inconsistent @Inject constructor( @Named("local") private val local: UserRepository, @RemoteDataSource private val remote: UserRepository)
// CORRECT: Use one style consistentlyclass Consistent @Inject constructor( @LocalDataSource private val local: UserRepository, @RemoteDataSource private val remote: UserRepository)Summary
Qualifier patterns solve Hilt’s “same interface, different implementations” problem. Here’s what I covered:
- Custom
@Qualifierannotations provide type-safe dependency distinction @Namedworks for prototyping but lacks compile-time safety@Bindsis more efficient than@Providesfor interface bindings- Real use cases include multiple API services, cache/network sources, and scoped preferences
- Question unnecessary interfaces - don’t create them just to follow patterns
The key insight from the Reddit discussion: qualifiers are a tool for when you genuinely need multiple implementations. If you find yourself creating an interface with a single DefaultXxx implementation, pause and ask if the abstraction is earning its keep.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments