Why Dependency Injection Matters
Setting Up Hilt
hiltViewModel() moved. The androidx.hilt 1.3.0 notes: "The hiltViewModel() APIs for Compose have been moved to a new artifact (androidx.hilt:hilt-lifecycle-viewmodel-compose) and package (androidx.hilt.lifecycle.viewmodel.compose) such that they can be used without transitively depending on androidx.navigation." The sample used to pull in hilt-navigation-compose for it.
- In Compose you annotate one activity. "In Compose, you don't need to annotate individual composables. Instead, annotate your root ComponentActivity with @AndroidEntryPoint."
If the project keeps its dependency versions in a catalog, these lines belong there; see version catalogs and convention plugins.// build.gradle.kts (project level)
plugins {
id("com.google.dagger.hilt.android") version "2.60.1" apply false
id("com.google.devtools.ksp") version "2.3.12" apply false
}
// build.gradle.kts (app level)
plugins {
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.60.1")
ksp("com.google.dagger:hilt-android-compiler:2.60.1")
// hiltViewModel() for Compose, without the navigation dependency
implementation("androidx.hilt:hilt-lifecycle-viewmodel-compose:1.4.0")
}
// Application class
@HiltAndroidApp
class MyApplication : Application()
// The one entry point a Compose app needs
@AndroidEntryPoint
class MainActivity : ComponentActivity()Modules: Telling Hilt How to Provide Dependencies
@Inject. A module covers the cases where that is not possible.
The first case is an interface. The guide: "If AnalyticsService is an interface, then you cannot constructor-inject it. Instead, provide Hilt with the binding information by creating an abstract function annotated with @Binds inside a Hilt module." The function's return type is the interface; its parameter is the implementation.
The second is a class you do not own, such as a Retrofit service or an HTTP client, or anything that needs configuration to build. That is @Provides: a function whose body creates the instance.
Use @Binds when all you are saying is which implementation stands behind an interface, and @Provides when there is construction logic to write.// Binding interfaces to implementations
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
// Providing objects you cannot annotate
@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("https://api.example.com/")
.client(client)
.addConverterFactory(
Json.asConverterFactory(
"application/json".toMediaType()
)
)
.build()
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService =
retrofit.create(ApiService::class.java)
}How long does a Hilt binding live?
| Android class | Component | Scope |
|---|---|---|
| Application | SingletonComponent | @Singleton |
| Activity (across rotation) | ActivityRetainedComponent | @ActivityRetainedScoped |
| ViewModel | ViewModelComponent | @ViewModelScoped |
| Activity | ActivityComponent | @ActivityScoped |
| Service | ServiceComponent | @ServiceScoped |
FragmentComponent with @FragmentScoped and ViewComponent with @ViewScoped, for apps that still have fragments and custom views.
Scope only what must be shared. A network client and a database are singletons because two of them would be a bug. A use case that holds no state does not need a scope at all: an unscoped binding is created when asked for and collected when its owner goes away. Making everything @Singleton keeps objects alive for the whole process that one screen needed for a minute.// Unscoped: a new instance each time it is requested.
// It holds no state, so it needs no scope.
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)
}
}
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val getUserProfile: GetUserProfileUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val userId: String = checkNotNull(savedStateHandle["userId"])
val profile: StateFlow<UserProfile?> = flow {
emit(getUserProfile(userId))
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
}Qualifiers: Multiple Bindings of the Same Type
// 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
}
// Ask for the one 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
viewModel.profile.first(), and the first value of a stateIn flow is its initial value, null. The test now waits for the first non-null value, and replaces the main dispatcher because viewModelScope runs on it. The reasoning is in the testing section of the coroutines deep dive.
For instrumented tests there are two ways to swap a binding, and the guide states a preference:
- @TestInstallIn replaces a module for every test in the source set.
- @UninstallModules with @BindValue replaces it for one test class. The guide warns that it "can significantly impact unit test build times" and says to "prefer using @TestInstallIn when the bindings need to be replaced in all test classes".
In Kotlin a @BindValue field also needs @JvmField, as the guide's own sample shows. The earlier version of this article left it out.// Unit test: no Hilt, just the constructor
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileViewModelTest {
@Before
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `profile loads successfully`() = runTest {
val userRepo = FakeUserRepository().apply {
addUser(User("1", "Jane"))
}
val settingsRepo = FakeSettingsRepository().apply {
addSettings(Settings("1", darkMode = true))
}
val viewModel = ProfileViewModel(
getUserProfile = GetUserProfileUseCase(userRepo, settingsRepo),
savedStateHandle = SavedStateHandle(mapOf("userId" to "1"))
)
// The first value is the initial null. Wait for the real one.
val profile = viewModel.profile.first { it != null }
assertEquals("Jane", profile?.user?.name)
}
}
// Instrumented test: replace one module for this class
@UninstallModules(NetworkModule::class)
@HiltAndroidTest
class ProfileScreenTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@BindValue
@JvmField
val api: ApiService = FakeApiService()
@Before
fun setup() {
hiltRule.inject()
}
}Key Takeaways
- 1Hilt is Jetpack's recommended DI library. It generates the wiring at compile time on top of Dagger.
- 2Use @Binds to map an interface to an implementation and @Provides when there is construction logic.
- 3Bindings are unscoped by default: a new instance per request. Scope only what must be shared.
- 4hiltViewModel() now lives in androidx.hilt:hilt-lifecycle-viewmodel-compose, not the navigation artifact.
- 5Unit tests do not need Hilt. Call the constructor with fakes.
- 6Prefer @TestInstallIn to replace a binding everywhere; @UninstallModules with @BindValue and @JvmField for one test class.
Frequently Asked
Should I use Hilt or manual DI?
Hilt, for most production apps. Android's documentation calls it Jetpack's recommended library and describes manual injection as constructing every class and its dependencies by hand. Manual DI stays reasonable in a very small app or a library module, where a few constructor calls are easier to read than generated components.
Do I need @AndroidEntryPoint on every composable?
No. The Hilt guide says that in Compose you annotate the root ComponentActivity with @AndroidEntryPoint and nothing else. That activity is the single entry point for the whole UI hierarchy. Composables then obtain a ViewModel annotated with @HiltViewModel by calling hiltViewModel(), which comes from the hilt-lifecycle-viewmodel-compose artifact.
Do I need Hilt in unit tests?
No. The testing guide states that Hilt is not necessary for unit tests: a class that uses constructor injection can be created directly with fake dependencies. Keep Hilt for instrumented tests, where @TestInstallIn replaces a module for all tests and @UninstallModules with @BindValue replaces it for a single test class.
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.