Skip to content

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.

The Multiple Implementation Problem
UserRepository (interface)
|
+-- LocalUserRepository (Room database)
|
+-- RemoteUserRepository (Retrofit API)
SyncManager needs BOTH:
- LocalUserRepository for saving data
- RemoteUserRepository for fetching data

Without qualifiers, Hilt has no way to distinguish between the two implementations. You’d get a compilation error about multiple bindings for the same type.

Dependency conflict
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:#ffcccc

The @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

Custom Qualifier Annotations
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalDataSource
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RemoteDataSource

The @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

Repository 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

Hilt Module with Qualified Bindings
@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

Injecting Qualified Dependencies
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:

Using @Named (Less Type-Safe)
@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 @Named
class 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:

AspectCustom @Qualifier@Named
Type safetyCompile-time checkingRuntime string matching
IDE supportAutocomplete, refactoringNo autocomplete for strings
TyposCaught at compile timeRuntime failures
ReadabilitySelf-documentingRequires string constants
The Problem with @Named
// This compiles but fails at runtime
class BadInjection @Inject constructor(
@Named("locla") private val repo: UserRepository // Typo!
)
// With custom qualifiers, this would be a compile error
class GoodInjection @Inject constructor(
@LoaclDataSource private val repo: UserRepository // Doesn't compile!
)

Real-World Use Cases

Qualifiers shine in several common scenarios:

Multiple Retrofit Services

Multiple API 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

Cache vs Network 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

Activity vs Application Scoped
@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 vs @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.

Unnecessary Interface
// Is this interface necessary?
interface AuthRepository {
suspend fun login(email: String, password: String): Result<User>
}
// With only one implementation
class 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:

  1. Will there ever be a second implementation? (Testing doesn’t count - use mocking frameworks)
  2. Do you need to swap implementations at runtime?
  3. Is this a public API that external code will implement?

If the answer to all three is “no,” consider using the concrete class directly:

Simpler Without Interface
// Just use the concrete class
class 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 needed
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository
) { ... }

Common Mistakes

Mistake 1: Forgetting @Retention

Wrong: Missing Retention
@Qualifier
annotation class LocalDataSource // Won't work at compile time!
// Correct
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalDataSource

Mistake 2: Qualifying the Wrong Place

Wrong: Qualifier on Implementation
// WRONG: Qualifier on the class itself
@LocalDataSource
class 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

Consistent Qualifier Usage
// WRONG: Mixing @Named and custom qualifiers
class Inconsistent @Inject constructor(
@Named("local") private val local: UserRepository,
@RemoteDataSource private val remote: UserRepository
)
// CORRECT: Use one style consistently
class 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 @Qualifier annotations provide type-safe dependency distinction
  • @Named works for prototyping but lacks compile-time safety
  • @Binds is more efficient than @Provides for 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