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()
}@HiltAndroidAppgenerates the application component; every@AndroidEntryPointActivity, Fragment or Service gets its own component under it.- A
@Providesmethod returns an instance you construct yourself, which is right for third-party types. Use@Bindswhen you are just mapping an interface to an implementation - it generates no code. - Constructor injection needs
@Inject constructoron the class, which is the preference: no module needed, and the dependency is visible in the signature. - Scoping with
@Singletonmeans one instance per component, not one per process. A@ViewModelScopedbinding 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()
}| Component | Created for | Lifetime |
|---|---|---|
SingletonComponent | The Application | The whole process |
ActivityRetainedComponent | An Activity, across configuration changes | Until the Activity is finished |
ViewModelComponent | One ViewModel | Until the ViewModel clears |
ActivityComponent | An Activity | Until the Activity is destroyed |
FragmentComponent | A Fragment | The 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
@TestInstallInfor 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.
Related
Architecture: ViewModel, lifecycle and saved state Testing Android apps
Last refreshed 2026-09-18.