Dependency injection with Hilt

Hilt setup and modules, injecting ViewModels and repositories, scoping and generated components, and testing with fakes.

Setup and modules

@HiltAndroidApp
class App : Application()

@AndroidEntryPoint
class MainActivity : ComponentActivity()

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

    @Provides
    @Singleton
    fun provideJson(): Json = Json { ignoreUnknownKeys = true }

    @Provides
    @Singleton
    fun provideOkHttp(tokenStore: TokenStore): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor(AuthInterceptor(tokenStore))
            .callTimeout(30, TimeUnit.SECONDS)
            .build()

    @Provides
    @Singleton
    fun provideApi(client: OkHttpClient, json: Json): BookApi =
        Retrofit.Builder()
            .baseUrl(BuildConfig.API_URL)
            .client(client)
            .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
            .build()
            .create()

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
        Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
            .addMigrations(MIGRATION_2_3)
            .build()
}
  • @HiltAndroidApp generates the application component; every @AndroidEntryPoint Activity, Fragment or Service gets its own component under it.
  • A @Provides method returns an instance you construct yourself, which is right for third-party types. Use @Binds when you are just mapping an interface to an implementation - it generates no code.
  • Constructor injection needs @Inject constructor on the class, which is the preference: no module needed, and the dependency is visible in the signature.
  • Scoping with @Singleton means one instance per component, not one per process. A @ViewModelScoped binding is one instance per ViewModel.
⚠️
An unqualified @Singleton that holds Context must take @ApplicationContext. An Activity context in a singleton outlives the Activity and leaks the whole view hierarchy. This is the most common Hilt mistake and it survives every code review until it shows up in a memory dump.

Injecting into the app

// bindings that need no module
class DefaultBookRepository @Inject constructor(
    private val api: BookApi,
    private val dao: BookDao,
) : BookRepository

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    @Singleton
    abstract fun bindBookRepository(impl: DefaultBookRepository): BookRepository
}

// a ViewModel at a screen
@HiltViewModel
class BookViewModel @Inject constructor(
    private val repo: BookRepository,
    savedState: SavedStateHandle,
) : ViewModel()

@Composable
fun BooksRoute(vm: BookViewModel = hiltViewModel()) { /* ... */ }

// qualifiers for two of the same type
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class Authenticated
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class Anonymous

@Module
@InstallIn(SingletonComponent::class)
object ClientModule {
    @Provides @Authenticated
    fun authenticated(cfg: Config, token: TokenStore): OkHttpClient = /* ... */

    @Provides @Anonymous
    fun anonymous(): OkHttpClient = OkHttpClient.Builder().build()
}
ComponentCreated forLifetime
SingletonComponentThe ApplicationThe whole process
ActivityRetainedComponentAn Activity, across configuration changesUntil the Activity is finished
ViewModelComponentOne ViewModelUntil the ViewModel clears
ActivityComponentAn ActivityUntil the Activity is destroyed
FragmentComponentA FragmentThe Fragment view lifecycle

Choosing the narrowest component that works keeps lifetimes obvious. A repository is @Singleton; a screen-specific coordinator belongs in the ViewModel component; a presenter tied to a view belongs in the Fragment component.

Testing with Hilt

@HiltAndroidTest
class BooksScreenTest {

    @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
    @get:Rule(order = 1) val composeRule = createAndroidComposeRule<MainActivity>()

    @Inject lateinit var repo: BookRepository

    @Before fun setUp() = hiltRule.inject()

    @Test fun showsBooks() {
        composeRule.onNodeWithText("Loading").assertIsDisplayed()
        composeRule.waitForIdle()
        composeRule.onNodeWithText("The Dispossessed").assertIsDisplayed()
    }
}

// replace a binding for the test build
@Module
@InstallIn(SingletonComponent::class)
@TestInstallIn(components = [SingletonComponent::class], replaces = [RepositoryModule::class])
object FakeRepositoryModule {
    @Provides @Singleton
    fun repo(): BookRepository = FakeBookRepository(listOf(Book(1, "Test", 2)))
}

// a JVM test for the ViewModel needs no Hilt at all
@Test fun emitsReady() = runTest {
    val vm = BookViewModel(FakeBookRepository(listOf(Book(1, "A", 2))), SavedStateHandle())
    vm.load()
    assertEquals(BookUiState(books = listOf(Book(1, "A", 2))), vm.uiState.value)
}
  • Rule ordering matters: Hilt must inject before the Compose rule launches the Activity, which is why the Hilt rule comes first.
  • Most ViewModel tests need no Hilt at all: constructor injection means you pass a fake directly, which is faster and clearer.
  • Prefer a hand-written fake over a mocking framework. A fake repository with an in-memory list is more readable and survives refactors that break a mock.
  • Reserve @TestInstallIn for genuinely shared test bindings; per-test overrides are usually a sign the class under test depends on too much.

FAQ

Does Hilt slow down the build?
It adds annotation processing and generates code per component, so a large project sees a real build-time cost. The trade is explicit wiring, compile-time detection of missing bindings, and testable seams - worth it for anything beyond a small app.
Hilt or manual injection?
Manual constructor injection with a small object graph is fine for a tiny app and has no build cost. Hilt becomes worth it when you have many screens, several environments and tests that need substituted bindings.

Architecture: ViewModel, lifecycle and saved state Testing Android apps

Last refreshed 2026-09-18.