Dependency Injection with Hilt: The Complete Android Guide

Learn how to structure your Android app with Hilt for testable, maintainable code. Covers modules, scopes, qualifiers, and testing patterns.

Introduction

Hilt simplifies dependency injection on Android by providing standard components and scopes built on Dagger. It eliminates boilerplate, provides compile-time safety, and integrates with Android lifecycle. This guide covers setup, patterns, and testing strategies.

Hilt Setup and Basics

Add Hilt Gradle plugins and dependencies. Annotate Application with @HiltAndroidApp. Use @AndroidEntryPoint for Activities, Fragments, and ViewModels. Hilt generates components automatically.

Modules and Bindings

Create @Module classes with @Provides methods for dependencies. Use @Binds for interface implementations. Organize modules by feature or layer. Scope bindings appropriately with @Singleton, @ViewModelScoped, or custom scopes.

Scopes and Component Hierarchies

Hilt provides predefined scopes: @Singleton for app lifetime, @ViewModelScoped for ViewModel lifetime. Use @ActivityScoped for UI-related dependencies. Understand component hierarchy for correct scope selection.

Qualifiers and Named Bindings

Use @Named or custom qualifier annotations to distinguish multiple bindings of the same type. Create qualifiers for different API clients, database instances, or configuration variants.

Testing with Hilt

Use @HiltAndroidTest for instrumented tests. Replace production modules with test modules using @UninstallModules. Provide fake implementations for repositories and use cases.

Frequently Asked Questions

Should I use Hilt or manual DI?

Use Hilt for production apps. It provides standard components, reduces boilerplate, and integrates with Android lifecycle. Manual DI is only reasonable for very small apps.

How do I inject into custom views?

Use @AndroidEntryPoint on the containing Activity/Fragment and pass dependencies to the view. Alternatively, use Hilt's @CustomComponent for custom injection.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:38:02
UP00:01
MOD · ARTICLE · ARCHITECTURES/N · AX-HILT-DPUBLISHED
ArchitectureFeb 20, 202611 MIN

Dependency Injection with Hilt: The Complete Android Guide

Learn how to structure your Android app with Hilt for testable, maintainable code. Covers modules, scopes, qualifiers, and testing patterns.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why Dependency Injection Matters

Dependency injection is the practice of supplying an object's dependencies from the outside rather than having the object create them internally. Without DI, classes instantiate their own collaborators, creating tight coupling that makes testing difficult and refactoring dangerous. Consider a ViewModel that creates its own Repository, which creates its own Retrofit service, which creates its own OkHttpClient. Changing any layer requires modifying every class above it. With DI, each class declares what it needs, and a framework wires everything together. Swapping a real repository for a fake one in tests becomes trivial. Hilt is Google's recommended DI framework for Android. It builds on Dagger's compile-time code generation but removes most of the boilerplate through predefined Android-specific components and scopes.

Setting Up Hilt

Hilt requires a few gradle dependencies and an Application class annotation. Once configured, you can inject dependencies anywhere in your Android components.
kotlin
// build.gradle.kts (project level)
plugins {
    id("com.google.dagger.hilt.android") version "2.51" apply false
    id("com.google.devtools.ksp") version "2.0.0-1.0.22" apply false
}

// build.gradle.kts (app level)
plugins {
    id("com.google.dagger.hilt.android")
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.51")
    ksp("com.google.dagger:hilt-android-compiler:2.51")

    // For ViewModel injection
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")

    // Testing
    testImplementation("com.google.dagger:hilt-android-testing:2.51")
    kspTest("com.google.dagger:hilt-android-compiler:2.51")
}

// Application class
@HiltAndroidApp
class MyApplication : Application()

Modules: Telling Hilt How to Provide Dependencies

Hilt modules define how to create instances that Hilt cannot construct automatically. You need modules when working with interfaces (Hilt doesn't know which implementation to use), third-party classes (you can't annotate their constructors), and objects requiring custom configuration. There are two approaches: @Binds for mapping an interface to its implementation, and @Provides for more complex construction logic. Prefer @Binds when possible -- it generates less code.
kotlin
// Binding interfaces to implementations
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindUserRepository(
        impl: UserRepositoryImpl
    ): UserRepository

    @Binds
    @Singleton
    abstract fun bindAnalyticsService(
        impl: FirebaseAnalyticsService
    ): AnalyticsService
}

// Providing third-party objects
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = if (BuildConfig.DEBUG)
                    HttpLoggingInterceptor.Level.BODY
                else HttpLoggingInterceptor.Level.NONE
            })
            .build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(client)
            .addConverterFactory(
                Json.asConverterFactory(
                    "application/json".toMediaType()
                )
            )
            .build()
    }

    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }
}

Scopes and Component Hierarchy

Hilt provides predefined scopes that align with Android lifecycle components. The scope determines how long an instance lives: - @Singleton: lives as long as the application - @ActivityRetainedScoped: survives configuration changes (tied to ViewModel lifecycle) - @ViewModelScoped: one instance per ViewModel - @ActivityScoped: one instance per Activity - @FragmentScoped: one instance per Fragment The most common pattern is @Singleton for network and database objects, and @ViewModelScoped for use cases that aggregate data for a specific screen. Avoid over-scoping -- making everything a Singleton wastes memory for objects that are only needed temporarily.
kotlin
// Singleton: one instance for the entire app
@Singleton
class AppDatabase @Inject constructor(
    @ApplicationContext context: Context
) {
    val db = Room.databaseBuilder(
        context, AppDb::class.java, "app.db"
    ).build()
}

// ViewModelScoped: one per ViewModel instance
@ViewModelScoped
class GetUserProfileUseCase @Inject constructor(
    private val userRepo: UserRepository,
    private val settingsRepo: SettingsRepository
) {
    suspend operator fun invoke(userId: String): UserProfile {
        val user = userRepo.getUser(userId)
        val settings = settingsRepo.getUserSettings(userId)
        return UserProfile(user, settings)
    }
}

// ViewModel: inject use cases, not raw repositories
@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val getUserProfile: GetUserProfileUseCase,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val userId = savedStateHandle.get<String>("userId")!!

    val profile = flow {
        emit(getUserProfile(userId))
    }.stateIn(viewModelScope, SharingStarted.Lazily, null)
}

Qualifiers: Multiple Bindings of the Same Type

When you need multiple instances of the same type with different configurations, qualifiers tell Hilt which one to inject. The most common use case is providing different dispatchers, different API base URLs, or different database instances.
kotlin
// Define qualifiers
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher

// Provide qualified instances
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {

    @IoDispatcher
    @Provides
    fun provideIoDispatcher(): CoroutineDispatcher =
        Dispatchers.IO

    @DefaultDispatcher
    @Provides
    fun provideDefaultDispatcher(): CoroutineDispatcher =
        Dispatchers.Default
}

// Inject the specific dispatcher you need
class UserRepositoryImpl @Inject constructor(
    private val api: ApiService,
    private val dao: UserDao,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : UserRepository {

    override suspend fun getUser(id: String): User =
        withContext(ioDispatcher) {
            dao.getUser(id) ?: api.fetchUser(id).also {
                dao.insertUser(it)
            }
        }
}

Testing with Hilt

Hilt's testing support lets you replace production modules with test doubles. Use @UninstallModules to remove a module and @BindValue to inject fakes directly. For unit tests without the full Hilt setup, constructor injection makes it simple to pass fakes manually. The key insight: if your class takes dependencies through its constructor (which @Inject constructor enforces), you can test it without Hilt at all. Hilt is for wiring the app together -- tests can wire manually.
kotlin
// Unit test: no Hilt needed, just pass fakes
class ProfileViewModelTest {

    private val fakeUserRepo = FakeUserRepository()
    private val fakeSettingsRepo = FakeSettingsRepository()

    private val useCase = GetUserProfileUseCase(
        fakeUserRepo, fakeSettingsRepo
    )

    @Test
    fun `profile loads successfully`() = runTest {
        fakeUserRepo.addUser(User("1", "Jane"))
        fakeSettingsRepo.addSettings(
            Settings("1", darkMode = true)
        )

        val viewModel = ProfileViewModel(
            getUserProfile = useCase,
            savedStateHandle = SavedStateHandle(
                mapOf("userId" to "1")
            )
        )

        val profile = viewModel.profile.first()
        assertEquals("Jane", profile?.user?.name)
    }
}

// Integration test: replace modules with test versions
@HiltAndroidTest
@UninstallModules(NetworkModule::class)
class ProfileScreenTest {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @BindValue
    val fakeApi: ApiService = FakeApiService()

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun profileDisplaysUserName() {
        // fakeApi is injected everywhere ApiService is used
        (fakeApi as FakeApiService).setUser(
            User("1", "Jane Doe")
        )
        // ... launch composable and assert
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Hilt removes Dagger boilerplate with Android-specific components and scopes.
  2. 2Use @Binds for interface-to-implementation mappings, @Provides for complex construction.
  3. 3Scope wisely: @Singleton for global objects, @ViewModelScoped for screen-specific use cases.
  4. 4Qualifiers disambiguate multiple bindings of the same type (dispatchers, URLs).
  5. 5Constructor injection makes classes testable without Hilt -- only the wiring needs DI.
  6. 6Use @UninstallModules and @BindValue to swap real dependencies for fakes in integration tests.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

Should I use Hilt or manual DI?

Use Hilt for production apps. It provides standard components, reduces boilerplate, and integrates with Android lifecycle. Manual DI is only reasonable for very small apps.

How do I inject into custom views?

Use @AndroidEntryPoint on the containing Activity/Fragment and pass dependencies to the view. Alternatively, use Hilt's @CustomComponent for custom injection.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA