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

FeatureHiltKoinManual DI
Compile-time safetyYesNo (runtime)Yes
PerformanceFast (generated code)Slower (reflection)Fastest
BoilerplateMediumLowHigh
Google recommendedYesNoNo
Multi-moduleBuilt-inManual setupManual setup
Learning curveMediumLowLow

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

UseWhen
@ProvidesThird-party classes (Retrofit, Room, OkHttp)
@BindsYour 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:

ScopeLifecycleUse For
@SingletonApp lifetimeDatabase, Retrofit, OkHttp
@ViewModelScopedViewModel lifetimeUse cases shared within a ViewModel
@ActivityScopedActivity lifetimeRarely needed with Compose
@ActivityRetainedScopedSurvives config changesSimilar 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:

  1. Replace kapt with ksp in plugins:
// Before
plugins {
    id("kotlin-kapt")
}

// After
plugins {
    alias(libs.plugins.ksp)
}
  1. Replace kapt() with ksp() in dependencies:
// Before
kapt(libs.hilt.compiler)

// After
ksp(libs.hilt.compiler)
  1. 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.



This is part 2 of the Android Development Tutorial series.