Dependency injection is a design pattern that removes hard-coded dependencies and makes code more modular, testable, and maintainable. Instead of components creating their own dependencies, they receive them from external sources. Hilt, built on Dagger, is Android’s recommended dependency injection library, providing compile-time verification, optimal performance, and seamless integration with Android components.
This guide demonstrates comprehensive Hilt implementation, from basic setup through advanced patterns used in production applications.
Understanding Dependency Injection
Consider a ViewModel that needs a repository to fetch data. Without dependency injection, the ViewModel creates its own repository instance, which creates its own API client, which creates its own HTTP client. This chain of internal construction makes testing difficult, creates tight coupling, and scatters configuration throughout the codebase.
// Without DI - tightly coupled, hard to test
class ProductViewModel : ViewModel() {
// ViewModel constructs its own dependencies
private val repository = ProductRepository(
ProductApi(RetrofitClient.instance),
ProductDatabase.getInstance(applicationContext)
)
}
With dependency injection, the ViewModel declares what it needs and receives those dependencies from outside. The ViewModel does not know or care how the repository is constructed. Testing becomes trivial—provide fake implementations of dependencies.
// With DI - loosely coupled, easily testable
class ProductViewModel @Inject constructor(
private val repository: ProductRepository
) : ViewModel() {
// Repository injected from outside
}
Setting Up Hilt
Hilt integration requires Gradle plugin configuration and annotation of your Application class.
// build.gradle.kts (project level)
plugins {
id("com.google.dagger.hilt.android") version "2.48" apply false
}
// build.gradle.kts (app module)
plugins {
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.48")
ksp("com.google.dagger:hilt-compiler:2.48")
// For ViewModel injection
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// For WorkManager injection
implementation("androidx.hilt:hilt-work:1.1.0")
ksp("androidx.hilt:hilt-compiler:1.1.0")
}
Annotate your Application class with @HiltAndroidApp. This triggers Hilt’s code generation and creates the application-level component.
@HiltAndroidApp
class MyApplication : Application() {
// Hilt generates necessary code
}
Annotate Activities and Fragments with @AndroidEntryPoint to enable injection into Android components.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// Can now inject dependencies
}
@AndroidEntryPoint
class ProductFragment : Fragment() {
@Inject
lateinit var analyticsTracker: AnalyticsTracker
}
Providing Dependencies
Hilt needs to know how to create instances of the types you want to inject. There are several ways to provide this information.
Constructor injection is the simplest approach. Annotate the class constructor with @Inject, and Hilt can create instances automatically.
class ProductRepository @Inject constructor(
private val productApi: ProductApi,
private val productDao: ProductDao
) {
suspend fun getProducts(): List<Product> {
// Implementation
}
}
For classes you do not own or that require complex construction, use @Module classes with @Provides functions.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
loggingInterceptor: HttpLoggingInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
}
@Provides
@Singleton
fun provideProductApi(retrofit: Retrofit): ProductApi {
return retrofit.create(ProductApi::class.java)
}
}
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"app_database"
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
}
@Provides
fun provideProductDao(database: AppDatabase): ProductDao {
return database.productDao()
}
}
For interface bindings where you want to inject an interface but provide an implementation, use @Binds in abstract modules.
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindProductRepository(
impl: ProductRepositoryImpl
): ProductRepository
@Binds
@Singleton
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
Understanding Scopes and Components
Hilt provides predefined components that map to Android lifecycle scopes. The component you install a module into determines when instances are created and destroyed.
SingletonComponent lives for the entire application lifetime. Use @Singleton scope for dependencies that should exist as single instances across the app—database instances, HTTP clients, repositories.
ActivityRetainedComponent survives configuration changes. Use @ActivityRetainedScoped for dependencies tied to an Activity that should not be recreated on rotation.
ViewModelComponent is scoped to ViewModel lifetime. Dependencies here are created when the ViewModel is created and destroyed when the ViewModel is cleared.
ActivityComponent, FragmentComponent, and ViewComponent have progressively shorter lifetimes matching their corresponding Android components.
// Singleton - one instance for entire app
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideAnalytics(): Analytics { ... }
}
// ViewModelComponent - scoped to ViewModel
@Module
@InstallIn(ViewModelComponent::class)
object ViewModelModule {
@Provides
@ViewModelScoped
fun provideStateManager(): StateManager { ... }
}
// ActivityComponent - recreated with each Activity
@Module
@InstallIn(ActivityComponent::class)
object ActivityModule {
@Provides
fun provideNavigationController(@ActivityContext context: Context): NavController { ... }
}
ViewModel Injection
Hilt integrates smoothly with ViewModels through the @HiltViewModel annotation.
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val getProducts: GetProductsUseCase,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductListUiState())
val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
init {
loadProducts()
}
private fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
getProducts()
.onSuccess { products ->
_uiState.update { it.copy(
isLoading = false,
products = products
)}
}
.onFailure { error ->
_uiState.update { it.copy(
isLoading = false,
error = error.message
)}
}
}
}
}
In Compose, use hiltViewModel() to retrieve injected ViewModels:
@Composable
fun ProductListScreen(
viewModel: ProductListViewModel = hiltViewModel(),
onProductClick: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ProductListContent(
products = uiState.products,
isLoading = uiState.isLoading,
onProductClick = onProductClick
)
}
Qualifiers for Multiple Bindings
When you need multiple instances of the same type with different configurations, use qualifiers to distinguish them.
@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 {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
}
@Provides
@Singleton
@PublicClient
fun providePublicClient(): OkHttpClient {
return OkHttpClient.Builder().build()
}
}
// Usage
class ApiClient @Inject constructor(
@AuthenticatedClient private val authenticatedClient: OkHttpClient,
@PublicClient private val publicClient: OkHttpClient
) { ... }
WorkManager Integration
Hilt supports dependency injection in WorkManager Workers through a custom WorkerFactory.
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val syncRepository: SyncRepository
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
syncRepository.syncData()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry()
else Result.failure()
}
}
}
// In Application class
@HiltAndroidApp
class MyApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
Testing with Hilt
Hilt simplifies testing by allowing you to replace real dependencies with test doubles. Use @UninstallModules to remove production modules and provide test implementations.
@HiltAndroidTest
@UninstallModules(NetworkModule::class)
class ProductListViewModelTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var viewModel: ProductListViewModel
@BindValue
val fakeRepository: ProductRepository = FakeProductRepository()
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun `loadProducts updates state with products`() = runTest {
(fakeRepository as FakeProductRepository).setProducts(testProducts)
viewModel.uiState.test {
val state = awaitItem()
assertEquals(testProducts, state.products)
}
}
}
@Module
@InstallIn(SingletonComponent::class)
object TestNetworkModule {
@Provides
@Singleton
fun provideFakeProductApi(): ProductApi = FakeProductApi()
}
Best Practices
Keep modules focused on specific concerns. Separate network configuration from database configuration from feature-specific bindings. This organization improves maintainability and makes it easier to swap implementations for testing.
Prefer constructor injection over field injection when possible. Constructor injection makes dependencies explicit and enables immutability. Use field injection only for Android components where constructor injection is not possible.
Avoid over-scoping dependencies. Not everything needs to be a singleton. Create instances where appropriate scope matches their actual usage patterns.
Use interfaces for dependencies you might want to fake in tests. Binding interfaces to implementations allows easy substitution without changing consuming code.
Conclusion
Hilt eliminates the boilerplate of manual dependency injection while providing compile-time safety and optimal performance. Its Android-specific components align naturally with platform lifecycles. The integration with ViewModels, WorkManager, and testing frameworks makes it the complete solution for Android dependency injection.
At RyuPy, Hilt is our standard for dependency injection. It enables clean architecture, comprehensive testing, and maintainable codebases that scale with project complexity.

0 Comments