Skip to content
All posts
May 20, 20266 min read

Hilt Dependency Injection in Android: Scopes, Modules, and Testing

A practical guide to Hilt DI in Android — how scopes work, how to structure modules, common mistakes, and how to write tests against Hilt-injected code without fighting the framework.

AndroidKotlinHiltArchitectureTesting
Share:

Hilt is the standard dependency injection framework for Android. It's built on Dagger but removes most of the boilerplate — component generation, component relationships, and Android lifecycle integration all happen automatically.

After using Hilt across 22 apps, here's the complete picture: scopes, module design, and testing.


How Hilt Works

Hilt generates Dagger components for you. You declare what things need injection (

code
@Inject
), where dependencies come from (
code
@Module
+
code
@Provides
/
code
@Binds
), and what scope they live in (
code
@Singleton
,
code
@ActivityScoped
, etc.).

The entry points are annotated:

kotlin
@HiltAndroidApp
class MyApplication : Application()

@AndroidEntryPoint
class MainActivity : ComponentActivity()

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val repository: ItemRepository
) : ViewModel()

code
@HiltAndroidApp
triggers code generation for the entire app.
code
@AndroidEntryPoint
enables injection on Activities, Fragments, Views, Services, and BroadcastReceivers.
code
@HiltViewModel
wires ViewModels into Hilt's component hierarchy.


Scopes: What Lives Where

Scope determines how long a dependency lives and whether it's shared.

ScopeAnnotationLifetime
App-wide singleton
code
@Singleton
App process lifetime
Activity-scoped
code
@ActivityScoped
Activity lifetime
Fragment-scoped
code
@FragmentScoped
Fragment lifetime
ViewModel-scoped
code
@ViewModelScoped
ViewModel lifetime
Unscoped(no annotation)New instance per injection

The Most Common Scope Mistake

Injecting an

code
@ActivityScoped
dependency into a
code
@Singleton
— Hilt will give you a compile error because a singleton can't hold a reference to something that lives shorter than itself. Scope hierarchy matters:

code
SingletonComponent
  └── ActivityRetainedComponent
        └── ViewModelComponent
              └── ActivityComponent
                    └── FragmentComponent

Each component can only inject dependencies from its own scope or any parent scope. Never from a child scope.

When to Use @Singleton

Use

code
@Singleton
for:

  • Network clients (
    code
    OkHttpClient
    ,
    code
    Retrofit
    )
  • Databases (
    code
    RoomDatabase
    )
  • Repositories (stateless data coordinators)
  • Shared preferences wrappers

Don't use

code
@Singleton
for anything that holds UI context or Activity references.

kotlin
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient =
        OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build()

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit =
        Retrofit.Builder()
            .baseUrl(BuildConfig.BASE_URL)
            .client(client)
            .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
            .build()

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

@ViewModelScoped for ViewModel-local dependencies

If a dependency should live exactly as long as the ViewModel and isn't shared across ViewModels, use

code
@ViewModelScoped
:

kotlin
@Module
@InstallIn(ViewModelComponent::class)
object ViewModelModule {

    @Provides
    @ViewModelScoped
    fun providePagingConfig(): PagingConfig =
        PagingConfig(pageSize = 20, enablePlaceholders = false)
}

Module Design

@Provides vs @Binds

Use

code
@Provides
when you need to construct the object yourself. Use
code
@Binds
when you're just telling Hilt which implementation of an interface to use — it's more efficient because Hilt doesn't need to generate a method body.

kotlin
// @Provides — when you control construction
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
    Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
        .fallbackToDestructiveMigration()
        .build()

// @Binds — when binding an implementation to an interface
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindItemRepository(
        impl: ItemRepositoryImpl
    ): ItemRepository
}

code
@Binds
requires an abstract module and an abstract function.
code
@Provides
works in a concrete object. You can have both in the same module by using a companion object:

kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {

    @Binds
    abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository

    companion object {
        @Provides
        @Singleton
        fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
            Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()

        @Provides
        fun provideItemDao(database: AppDatabase): ItemDao = database.itemDao()
    }
}

@ApplicationContext and @ActivityContext

Hilt provides these qualifiers for injecting Context without coupling to a specific Activity:

kotlin
class ItemLocalDataSource @Inject constructor(
    @ApplicationContext private val context: Context,
    private val dao: ItemDao
)

Never inject

code
Activity
or
code
Context
directly into a
code
@Singleton
— use
code
@ApplicationContext
when you need app-level context in a long-lived class.


Qualifiers: When You Have Multiple Implementations

If you need two different instances of the same type, use a qualifier annotation:

kotlin
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthenticatedClient

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

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    @AuthenticatedClient
    fun provideAuthenticatedClient(authInterceptor: AuthInterceptor): OkHttpClient =
        OkHttpClient.Builder().addInterceptor(authInterceptor).build()

    @Provides
    @Singleton
    @PublicClient
    fun providePublicClient(): OkHttpClient =
        OkHttpClient.Builder().build()
}

// Usage
class ApiService @Inject constructor(
    @AuthenticatedClient private val client: OkHttpClient
)

Testing with Hilt

Replace modules in tests with @TestInstallIn

The standard approach: create a fake module that replaces the real one in tests.

kotlin
// Production module
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}

// Test replacement
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RepositoryModule::class]
)
@Module
abstract class FakeRepositoryModule {
    @Binds
    abstract fun bindItemRepository(impl: FakeItemRepository): ItemRepository
}

code
@TestInstallIn
replaces the module for all tests in the module. The fake implementation:

kotlin
class FakeItemRepository @Inject constructor() : ItemRepository {
    var items = mutableListOf<Item>()
    var shouldThrowError = false

    override suspend fun getItems(): Result<List<Item>> =
        if (shouldThrowError) Result.failure(Exception("Test error"))
        else Result.success(items.toList())
}

ViewModel tests with HiltAndroidTest

kotlin
@HiltAndroidTest
class HomeViewModelTest {

    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val mainDispatcherRule = MainDispatcherRule()

    @Inject
    lateinit var repository: FakeItemRepository

    private lateinit var viewModel: HomeViewModel

    @Before
    fun setUp() {
        hiltRule.inject()
        viewModel = HomeViewModel(repository)
    }

    @Test
    fun `loading items emits success state`() = runTest {
        repository.items = mutableListOf(Item("1", "Test Item"))

        viewModel.load()

        val state = viewModel.uiState.value
        assertThat(state).isInstanceOf(HomeUiState.Success::class.java)
        assertThat((state as HomeUiState.Success).items).hasSize(1)
    }

    @Test
    fun `repository error emits error state`() = runTest {
        repository.shouldThrowError = true

        viewModel.load()

        assertThat(viewModel.uiState.value).isInstanceOf(HomeUiState.Error::class.java)
    }
}

MainDispatcherRule for coroutine testing

kotlin
class MainDispatcherRule(
    private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {

    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
        dispatcher.cleanupTestCoroutines()
    }
}

Common Mistakes

Injecting into non-Hilt classes: Constructor injection only works on classes Hilt knows about. If you new up a class manually (

code
val x = MyClass()
), Hilt won't inject its dependencies. Use
code
@AndroidEntryPoint
or inject via a Hilt entry point.

Circular dependencies: A → B → A. Hilt will fail at compile time. Break the cycle by extracting the shared logic into a third class that neither depends on the other.

Using field injection when constructor injection works: Field injection (

code
@Inject lateinit var x: X
) is only necessary when you can't control the constructor (Activities, Fragments). For everything else, prefer constructor injection — it's testable without Hilt.

kotlin
// Prefer this
class ItemRepository @Inject constructor(
    private val dao: ItemDao,
    private val api: ApiService
)

// Over this (only use for Activities/Fragments/etc.)
class ItemRepository {
    @Inject lateinit var dao: ItemDao
    @Inject lateinit var api: ApiService
}

The Structure That Holds Up

Across 22 apps, this module organization has been the most maintainable:

code
di/
├── NetworkModule.kt      — OkHttpClient, Retrofit, ApiService
├── DatabaseModule.kt     — RoomDatabase, DAOs
├── RepositoryModule.kt   — @Binds repository implementations
└── UseCaseModule.kt      — @Binds use case implementations (if needed)

Each module in

code
SingletonComponent
. One module per architectural layer. No cross-layer dependencies in modules.

The test equivalents live under

code
src/test/
with
code
@TestInstallIn
replacing each production module with a fake version. This makes every test run with full Hilt wiring — no manual DI wiring in test setup.

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this