In the previous tutorial, you learned about Clean Architecture layers. But there is a problem — who connects everything together? Who creates the repository and gives it to the ViewModel? Who creates the database and gives it to the repository?
You could wire everything manually. But with 10 ViewModels, 5 repositories, and 3 data sources, you will spend more time wiring than coding.
Hilt solves this. You annotate your classes and Hilt connects everything automatically. It is Google’s recommended DI framework for Android.
Prerequisites: Compose Tutorial #14: Hilt basics covers the fundamentals. This tutorial goes deeper — custom scopes, assisted injection, multi-module setup, and testing.
What is Dependency Injection?
Dependency injection means: a class declares what it needs, and something else provides it.
Think of it like a restaurant. The chef (ViewModel) says “I need tomatoes” (repository). The chef doesn’t go to the farm. The supply chain (Hilt) delivers the tomatoes.
Without DI
class UserViewModel : ViewModel() {
// ViewModel creates its own dependencies
private val api = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
.create(UserApi::class.java)
private val db = Room.databaseBuilder(/*...*/).build()
private val repository = UserRepositoryImpl(api, db.userDao())
}
Problems: the ViewModel knows how to create Retrofit, Room, and the repository. Changing the API URL means changing every ViewModel. Testing is impossible without a real database.
With Hilt
@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel()
The ViewModel says “I need a UserRepository.” Hilt provides one. The ViewModel does not know or care how it is created.
Hilt vs Koin vs Manual DI
| Feature | Hilt | Koin | Manual DI |
|---|---|---|---|
| Compile-time safety | Yes | No (runtime) | Yes |
| Performance | Fast (generated code) | Slower (reflection) | Fastest |
| Boilerplate | Medium | Low | High |
| Google recommended | Yes | No | No |
| Multi-module | Built-in | Manual setup | Manual setup |
| Learning curve | Medium | Low | Low |
Hilt catches errors at compile time. If you forget to provide a dependency, the build fails — not the app at runtime.
Setup with KSP
Hilt now uses KSP (Kotlin Symbol Processing) instead of KAPT. KSP is up to 2x faster because it works directly with Kotlin instead of going through Java stubs.
Step 1: Version Catalog
# gradle/libs.versions.toml
[versions]
hilt = "2.56.2"
hiltNavigationCompose = "1.2.0"
ksp = "2.1.21-2.0.5"
okhttp = "4.12.0"
[libraries]
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Step 2: Project-Level build.gradle.kts
plugins {
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}
Step 3: App-Level build.gradle.kts
plugins {
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.hilt.work)
implementation(libs.okhttp.logging.interceptor)
}
Step 4: Application Class
@HiltAndroidApp
class MyApp : Application()
Register it in AndroidManifest.xml:
<application
android:name=".MyApp"
...>
Step 5: Activity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
AppNavigation()
}
}
}
}
Core Annotations
@HiltViewModel
Every ViewModel that needs injection must be annotated:
@HiltViewModel
class TaskViewModel @Inject constructor(
private val repository: TaskRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// savedStateHandle is provided by Hilt automatically
private val taskId: Long = savedStateHandle["taskId"] ?: 0L
}
In Compose, get the ViewModel with hiltViewModel():
@Composable
fun TaskScreen(
viewModel: TaskViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// ...
}
@Module and @Provides
When Hilt cannot figure out how to create something (interfaces, third-party classes), you tell it with a module:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"app.db"
).build()
}
@Provides
fun provideTaskDao(database: AppDatabase): TaskDao {
return database.taskDao()
}
@Provides
fun provideUserDao(database: AppDatabase): UserDao {
return database.userDao()
}
}
@Binds — Connecting Interfaces to Implementations
When your repository has an interface and an implementation, use @Binds:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindTaskRepository(
impl: TaskRepositoryImpl
): TaskRepository
@Binds
@Singleton
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
For this to work, the implementation must have @Inject constructor:
class TaskRepositoryImpl @Inject constructor(
private val dao: TaskDao,
private val api: TaskApi
) : TaskRepository {
// ...
}
@Provides vs @Binds
| Use | When |
|---|---|
@Provides | Third-party classes (Retrofit, Room, OkHttp) |
@Binds | Your own interfaces (Repository, DataSource) |
@Binds is more efficient — Hilt generates less code. Use it whenever possible.
Network Module
Here is a complete network module with Retrofit and OkHttp:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(
Json.asConverterFactory("application/json".toMediaType())
)
.build()
}
@Provides
@Singleton
fun provideTaskApi(retrofit: Retrofit): TaskApi {
return retrofit.create(TaskApi::class.java)
}
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi {
return retrofit.create(UserApi::class.java)
}
}
Notice how provideRetrofit receives OkHttpClient as a parameter. Hilt looks at the parameter types, finds a @Provides method that returns OkHttpClient, and connects them automatically.
Hilt Scopes
Scopes control how long an instance lives:
| Scope | Lifecycle | Use For |
|---|---|---|
@Singleton | App lifetime | Database, Retrofit, OkHttp |
@ViewModelScoped | ViewModel lifetime | Use cases shared within a ViewModel |
@ActivityScoped | Activity lifetime | Rarely needed with Compose |
@ActivityRetainedScoped | Survives config changes | Similar to ViewModel scope |
@ViewModelScoped Example
@Module
@InstallIn(ViewModelComponent::class)
abstract class ViewModelModule {
@Binds
@ViewModelScoped
abstract fun bindGetTasksUseCase(
impl: GetTasksUseCaseImpl
): GetTasksUseCase
}
Most of the time, you only need @Singleton and no scope annotation (unscoped — a new instance is created each time).
Assisted Injection
Sometimes a class needs both Hilt-provided dependencies and runtime parameters. Assisted injection handles this.
Example: Worker with Hilt Injection
For WorkManager workers, use @HiltWorker — it is the Hilt-native approach and requires no manual factory:
@HiltWorker
class UploadWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val api: TaskApi // Hilt provides this
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val taskId = inputData.getLong("task_id", 0L)
return try {
api.uploadTask(taskId)
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
// No manual @AssistedFactory needed — @HiltWorker generates it
}
Also configure WorkManager to use Hilt’s factory in your Application class:
@HiltAndroidApp
class MyApp : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
And disable WorkManager’s default initializer in AndroidManifest.xml:
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
Example: Screen with Navigation Arguments
class TaskDetailViewModel @AssistedInject constructor(
@Assisted private val taskId: Long, // Runtime parameter
private val repository: TaskRepository // Hilt provides this
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(taskId: Long): TaskDetailViewModel
}
}
For most cases with Compose Navigation, SavedStateHandle is simpler than assisted injection. Use assisted injection when you need parameters that are not in the navigation back stack.
@EntryPoint — Injecting into Non-Hilt Classes
Some Android classes cannot use @AndroidEntryPoint. Content providers and some framework classes need @EntryPoint:
// Define the entry point interface
@EntryPoint
@InstallIn(SingletonComponent::class)
interface TaskEntryPoint {
fun taskRepository(): TaskRepository
}
// Use it in a ContentProvider
class TaskContentProvider : ContentProvider() {
private lateinit var repository: TaskRepository
override fun onCreate(): Boolean {
val entryPoint = EntryPointAccessors.fromApplication(
context!!.applicationContext,
TaskEntryPoint::class.java
)
repository = entryPoint.taskRepository()
return true
}
// ... other ContentProvider methods
}
You will rarely need this. But when you do, it is the only way.
Hilt in Multi-Module Projects
In a multi-module app, each module can define its own Hilt modules:
app/ → @HiltAndroidApp, depends on all modules
feature-tasks/ → TaskViewModel, TaskScreen
feature-users/ → UserViewModel, UserScreen
core-data/ → Repositories, DAOs, API services
core-network/ → Retrofit, OkHttp setup
core-domain/ → Use cases, domain models
Module Setup
// core-network/di/NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit { /*...*/ }
}
// core-data/di/DataModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindTaskRepository(impl: TaskRepositoryImpl): TaskRepository
}
// feature-tasks/viewmodel/TaskViewModel.kt
@HiltViewModel
class TaskViewModel @Inject constructor(
private val repository: TaskRepository // From core-data module
) : ViewModel()
Hilt merges all @Module classes from all modules at compile time. The app module sees everything because it depends on all other modules.
Key Rule
Each module’s build.gradle.kts needs the Hilt and KSP plugins:
plugins {
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}
Testing with Hilt
Hilt makes testing easier by letting you replace real dependencies with fakes.
Unit Testing ViewModels
For unit tests, you don’t need Hilt at all. Just pass fake dependencies:
class TaskViewModelTest {
private lateinit var viewModel: TaskViewModel
private lateinit var fakeRepository: FakeTaskRepository
@Before
fun setup() {
fakeRepository = FakeTaskRepository()
viewModel = TaskViewModel(fakeRepository)
}
@Test
fun `loads tasks on init`() = runTest {
fakeRepository.setTasks(listOf(
Task(1, "Buy groceries", false),
Task(2, "Write code", true)
))
val state = viewModel.state.first()
assertEquals(2, state.tasks.size)
}
}
Instrumented Testing with Hilt
For UI tests, use @HiltAndroidTest to replace modules:
@HiltAndroidTest
class TaskScreenTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
@Module
@InstallIn(SingletonComponent::class)
abstract class TestModule {
@Binds
@Singleton
abstract fun bindRepository(
impl: FakeTaskRepository
): TaskRepository
}
@Test
fun showsTasks() {
composeRule.onNodeWithText("Buy groceries").assertIsDisplayed()
}
}
Add the test dependencies:
// build.gradle.kts
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.compiler)
Migrating from KAPT to KSP
If your project still uses KAPT, migration is straightforward:
- Replace
kaptwithkspin plugins:
// Before
plugins {
id("kotlin-kapt")
}
// After
plugins {
alias(libs.plugins.ksp)
}
- Replace
kapt()withksp()in dependencies:
// Before
kapt(libs.hilt.compiler)
// After
ksp(libs.hilt.compiler)
- Remove any
kapt { }blocks from your build files.
Build times typically improve by 20-50% after migration.
Common Mistakes
1. Forgetting @AndroidEntryPoint
// This will crash at runtime!
class MainActivity : ComponentActivity() { // Missing @AndroidEntryPoint
2. Using @Inject on Private Constructors
// This won't compile
class TaskRepository @Inject private constructor() // Must be public or internal
3. Circular Dependencies
// A needs B, B needs A — Hilt can't resolve this
class ServiceA @Inject constructor(private val b: ServiceB)
class ServiceB @Inject constructor(private val a: ServiceA)
Solution: use Lazy<ServiceA> or Provider<ServiceA> to break the cycle.
4. Not Using @Singleton When Needed
Without @Singleton, Hilt creates a new instance every time someone requests it. Your database gets created multiple times. Always scope expensive objects.
What’s Next?
In this tutorial, you learned:
- What dependency injection is and why it matters
- How to set up Hilt with KSP for fast builds
- Core annotations:
@HiltViewModel,@Module,@Provides,@Binds - Scopes, assisted injection, and entry points
- Multi-module Hilt setup
- Testing with Hilt
Next up: Android Tutorial #3: Repository Pattern — where you will learn how to build the data layer that Hilt connects to your ViewModels.
Related Articles
- Compose Tutorial #14: Hilt Basics — getting started with Hilt
- Android Tutorial #1: Architecture — MVVM, MVI, Clean Architecture
- Android Tutorial #5: Multi-Module — Hilt across modules
This is part 2 of the Android Development Tutorial series.