Dependency Injection with Hilt: A Complete Android Implementation Guide

by | Nov 18, 2025 | Tutorials | 0 comments

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.

Written by RyuPy Team

Related Posts

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *