Dependency injection with Hilt
Lesson 3 solved the shared-repository problem by hand: an AppContainer made once in the Application, and every route reaching into it to build its ViewModel. It works, and you can see its weak point already. Every new screen means a new viewModel { … } block that knows which objects to pass in what order; every new dependency means editing the container and every ViewModel that wants it; and a mistake in any of them is found when the app runs, not when it builds. Hilt is Google's library for doing the same job from annotations: you say what each class needs and how long things should live, and it writes the container, checks it at compile time, and hands the right objects to Android's own classes. This lesson moves Mandi Bhav to Hilt, measures what it costs, breaks it three ways, and fixes a scoping bug in module 6's navigation that the checks for this module turned up.
What dependency injection is, and what Hilt adds
You have been doing dependency injection since lesson 3: PriceDetailViewModel does not create its repository, it receives one in its constructor. That is the whole idea. The class says what it needs; somebody else decides which object it gets. The pay-off is the one lesson 3's tests used: a test hands it a FakePriceRepository, and the app hands it the real one.
What Hilt adds is the "somebody else":
| Job | By hand (lesson 3) | With Hilt |
|---|---|---|
| Make one repository for the app | AppContainer, in MandiBhavApplication |
@Singleton on a binding |
Build a ViewModel with its dependencies |
A viewModel { … } block in every route |
@HiltViewModel and @Inject constructor, then hiltViewModel() |
| Say which class implements an interface | val priceRepository: PriceRepository = SamplePriceRepository() |
A module with @Binds |
| Notice a missing dependency | A crash when the screen opens | A build error naming the class and who needs it |
| Give a test different objects | Construct the class yourself | The same, for unit tests; Hilt's test rules for instrumented tests |
Hilt is built on Dagger, a dependency-injection library that generates plain Java code at build time, so there is no reflection and no lookup at run time. Hilt is the Android layer on top: a standard set of containers, called components, tied to the lifetimes of Application, Activity and ViewModel. Google's architecture guide recommends using dependency injection and Hilt in Android apps.
Choosing versions
Hilt needs three things: the Hilt runtime and compiler, the Hilt Gradle plugin, and KSP, Kotlin Symbol Processing, which runs Hilt's compiler over your Kotlin code. Compose apps also need the AndroidX Hilt library that provides hiltViewModel().
On 8 November 2026 the latest stable versions were:
| What | Coordinates | Version | Where checked |
|---|---|---|---|
| Hilt runtime, compiler and Gradle plugin | com.google.dagger:hilt-android, hilt-compiler, plugin com.google.dagger.hilt.android |
2.60.1 | Maven Central, and the Gradle plugin portal for the plugin |
| KSP | plugin com.google.devtools.ksp |
2.3.13 | Maven Central and the plugin portal |
hiltViewModel() for Compose |
androidx.hilt:hilt-lifecycle-viewmodel-compose |
1.4.0 | Google Maven |
Google's Hilt page still showed 2.57.1 in its setup snippet that day, which is why the course reads the repositories rather than the snippet. Older KSP releases were numbered after the Kotlin version they matched, such as 2.0.21-1.0.28; current ones are not, and 2.3.13 built this project on Kotlin 2.4.21 and AGP 9.5.1 without a warning. If a version in this table fails to resolve or to build on your machine, check the same three repositories before anything else.
Older tutorials import hiltViewModel from androidx.hilt:hilt-navigation-compose. Google's Hilt-and-Jetpack page now lists hilt-lifecycle-viewmodel-compose for both Navigation Compose and Navigation 3, and that is the one Mandi Bhav uses.
Adding Hilt
In gradle/libs.versions.toml:
[versions]
hilt = "2.60.1"
ksp = "2.3.13"
androidxHilt = "1.4.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" }
androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
In the root build.gradle.kts, add alias(libs.plugins.ksp) apply false and alias(libs.plugins.hilt) apply false. In app/build.gradle.kts, add alias(libs.plugins.ksp) and alias(libs.plugins.hilt) to plugins, and three lines to dependencies:
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
ksp(…) is a new kind of dependency: the compiler runs at build time and is not packaged into the app.
The application and the activity
AppContainer.kt goes. MandiBhavApplication stays, in its own file, with one annotation and no body:
package com.riztechacademy.mandibhav
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
/**
* The app's Application class. @HiltAndroidApp makes Hilt generate the
* app-wide container here, so it lives exactly as long as the process.
*/
@HiltAndroidApp
class MandiBhavApplication : Application()
The manifest's android:name=".MandiBhavApplication" stays exactly as lesson 3 added it. MainActivity gets @AndroidEntryPoint from dagger.hilt.android, which lets Hilt provide ViewModels to everything shown inside it. Compose needs nothing per composable: the activity is the entry point for the whole UI.
Telling Hilt how to make things
Hilt can make any class whose constructor is marked @Inject, by making its parameters first. FindBestMarketUseCase needs only that:
class FindBestMarketUseCase @Inject constructor(private val repository: PriceRepository) {
SamplePriceRepository has a constructor full of defaults for tests, and Hilt cannot provide a List<CommodityPrice>. So it gets a second constructor, the one Hilt uses, inside the class:
/** The constructor Hilt uses: the sample data, with every default. */
@Inject
constructor() : this(initialPrices = SamplePrices.all)
That leaves the interface. Hilt cannot construct PriceRepository, because it is an interface; it has to be told which class to use. That is a module, in di/DataModule.kt:
package com.riztechacademy.mandibhav.di
import com.riztechacademy.mandibhav.data.PriceRepository
import com.riztechacademy.mandibhav.data.SamplePriceRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Tells Hilt which class to hand out when something asks for a
* [PriceRepository]. Module 8 changes the one line in this file.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
/** One repository for the whole app: the single source of truth. */
@Binds
@Singleton
abstract fun bindPriceRepository(repository: SamplePriceRepository): PriceRepository
}
Read it as a sentence: in the app-wide container (SingletonComponent), when anything asks for a PriceRepository, give it a SamplePriceRepository, and only ever one (@Singleton). @Binds is for "this interface, that class". When you need to construct something yourself, a Retrofit service in module 9 for example, you write an @Provides function that returns it instead.
| Annotation | On | Means |
|---|---|---|
@HiltAndroidApp |
The Application |
Generate the app-wide container here |
@AndroidEntryPoint |
An activity | Hilt can inject into it and the screens inside it |
@Inject constructor |
A class | Hilt may build it, from these parameters |
@Module + @InstallIn(X::class) |
A class or object | Bindings that live in container X |
@Binds |
An abstract function | When asked for the return type, use the parameter's class |
@Provides |
A function | When asked for the return type, call this |
@Singleton |
A binding or class | One instance for the app's whole life |
@HiltViewModel |
A ViewModel |
Hilt builds it for hiltViewModel() |
@AssistedInject, @Assisted, @AssistedFactory |
A constructor, a parameter, an interface | Some arguments come from the caller, the rest from Hilt |
ViewModels
PricesViewModel and MarketsViewModel need only injected things, plus a SavedStateHandle, which Hilt provides to every ViewModel by default:
@HiltViewModel
class PricesViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val repository: PriceRepository,
private val findBestMarket: FindBestMarketUseCase,
) : ViewModel() {
and their routes ask for them with no factory at all:
viewModel: PricesViewModel = hiltViewModel(),
imported from androidx.hilt.lifecycle.viewmodel.compose. The createSavedStateHandle and appContainer imports go.
PriceDetailViewModel and MarketViewModel need something Hilt cannot know: the price id or market name from the navigation key. That is assisted injection: the caller supplies some arguments, Hilt the rest.
@HiltViewModel(assistedFactory = PriceDetailViewModel.Factory::class)
class PriceDetailViewModel @AssistedInject constructor(
@Assisted priceId: String,
repository: PriceRepository,
findBestMarket: FindBestMarketUseCase,
) : ViewModel() {
// …uiState as in lesson 4…
/** How Hilt builds this ViewModel for one id: the id from the key, the rest injected. */
@AssistedFactory
interface Factory {
fun create(priceId: String): PriceDetailViewModel
}
}
and the route passes the id through the factory:
viewModel: PriceDetailViewModel = hiltViewModel<PriceDetailViewModel, PriceDetailViewModel.Factory>(
creationCallback = { factory -> factory.create(priceId) },
),
MarketViewModel is the same with @Assisted market: String. The imports are dagger.assisted.Assisted, AssistedFactory and AssistedInject, and dagger.hilt.android.lifecycle.HiltViewModel.
Where the ViewModel lives is still decided by navigation, not by Hilt. hiltViewModel() asks the nearest ViewModelStoreOwner, as viewModel() did, and inside a Navigation 3 entry that is the store rememberViewModelStoreNavEntryDecorator gives each entry. Hilt only supplies the factory. On the emulator, the module 6 check gives the same answer: open Lasalgaon onion, rotate twice, Back; open Pune onion, Home, adb shell am kill, reopen from Recents, Back. One ViewModel per visit, none created by rotation, each cleared by Back, and the Pune onion screen restored after process death.
The ViewModel tests do not change at all. They construct PricesViewModel(SavedStateHandle(), repository, FindBestMarketUseCase(repository)) with a fake, which is what an @Inject constructor is: an ordinary constructor. Unit tests never need Hilt. Replacing a binding for an instrumented test, which does need it, is part of the testing module. ./gradlew testDebugUnitTest still runs 52 tests.
What it cost, measured
On this machine, with a warm Gradle daemon and the build cache off, the same project before and after Hilt:
| Measure | Before Hilt | With Hilt |
|---|---|---|
Clean assembleDebug, two runs |
4.5 s, 4.1 s | 8.8 s, 7.8 s |
assembleDebug after editing one ViewModel |
0.6 s | 1.8 s |
| Debug APK size | 12,012,131 bytes | 12,298,015 bytes |
Cold start TotalTime, median of 7 on the emulator |
847 ms | 845 ms |
The build roughly doubled, because KSP and Dagger generate and compile code on every build that touches an annotated class. The APK grew 286 KB in a debug build. Start-up did not change measurably. Those are the costs at four ViewModels; the build cost grows with the number of injected classes. The alternative, a hand-built container, costs nothing at build time and more in every route, and its mistakes appear at run time. For an app this size either is defensible; Mandi Bhav takes Hilt because modules 8 to 10 add a database, an HTTP client, a worker and their dependencies, and Hilt is what Android teams use, so it is what you will meet at work.
Breaking it three ways
Forget @Singleton. Delete it from bindPriceRepository and the app builds and runs. Choose "Onion", tap "Refresh", and read the screen:
'Best today: Lasalgaon APMC, ₹2,450'
'Onion at Lasalgaon APMC, 2,525 rupees per quintal. …'
'Onion at Nashik APMC, 2,560 rupees per quintal. …'
Lesson 3's bug, worse: the wrong answer is on the same screen as the right prices. Without a scope, a Hilt binding is a new object every time it is asked for. PricesViewModel got one SamplePriceRepository, and FindBestMarketUseCase, built for the same ViewModel, got another; the refresh updated the first, and the best-market line read the second. No annotation, no error, and two sources of truth. Scoping is the one decision Hilt cannot make for you, and Google's page warns against scoping by default, because a scoped object lives as long as its container. A repository holding the app's data is exactly the case that needs it.
Forget @AndroidEntryPoint. It builds. It crashes on launch:
E AndroidRuntime: java.lang.IllegalStateException: Given component holder class com.riztechacademy.mandibhav.MainActivity does not implement interface dagger.hilt.internal.GeneratedComponent or interface dagger.hilt.internal.GeneratedComponentManager
E AndroidRuntime: at dagger.hilt.EntryPoints.get(EntryPoints.java:62)
E AndroidRuntime: at dagger.hilt.android.internal.lifecycle.HiltViewModelFactory.createInternal(HiltViewModelFactory.java:207)
The words to read are "component holder class … MainActivity": the first hiltViewModel() asked the activity for Hilt's container, and the activity was never given one.
Forget the binding. Delete bindPriceRepository, and this time the build fails:
error: [Dagger/MissingBinding] com.riztechacademy.mandibhav.data.PriceRepository cannot be provided without an @Provides-annotated method.
com.riztechacademy.mandibhav.data.PriceRepository is injected at
[com.riztechacademy.mandibhav.MandiBhavApplication_HiltComponents.ViewModelC] com.riztechacademy.mandibhav.ui.MarketsViewModel(repository)
It is also requested at:
com.riztechacademy.mandibhav.domain.FindBestMarketUseCase(repository)
com.riztechacademy.mandibhav.ui.MarketViewModel(…, repository)
com.riztechacademy.mandibhav.ui.PriceDetailViewModel(…, repository, …)
com.riztechacademy.mandibhav.ui.PricesViewModel(…, repository, …)
This is what the build time buys. Lesson 3's container would have compiled without the line and crashed on the first screen; Hilt lists every class that needed the missing thing before the app ever runs.
A scoping bug from module 6
While checking this module on the emulator, a log line in PriceDetailViewModel showed something wrong that had been in the app since module 6. Open Markets, Lasalgaon APMC, Lasalgaon onion; switch to the Prices tab and open Lasalgaon onion there too; press Back; switch to Markets:
D Scope ( 9374): PriceDetailViewModel created viewModel=67521554 priceId=lasalgaon-onion-2026-11-07
D Scope ( 9374): PriceDetailViewModel onCleared viewModel=67521554
D Scope ( 9374): PriceDetailViewModel created viewModel=203225138 priceId=lasalgaon-onion-2026-11-07
The Prices tab never created a ViewModel: it was handed the Markets tab's. Back on the Prices tab then cleared it, while the Markets tab was still showing that screen, and the Markets tab had to make a new one. Two screens on two tabs were sharing one ViewModel, and either could destroy it under the other.
The cause is in module 6's per-tab stacks. The decorator that gives each entry its ViewModels finds them by the entry's content key, which by default is the navigation key itself, PriceDetail("lasalgaon-onion-2026-11-07"). Both tabs had that key, so both found the same store; the decorators for different tabs share their storage in the activity. With lesson 3's read-once ViewModels nobody would notice, and with lesson 6's live ones, or a ViewModel holding a half-typed price alert, it would lose the user's work. The fix gives each tab its own content keys, in TopLevelNavigation.entries:
val entriesByStack = backStacks.mapValues { (topLevel, stack) ->
rememberDecoratedNavEntries(
backStack = stack,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
// The decorators find an entry's saved state and ViewModels by its
// content key, and by default that is the key itself. The same
// price open on two tabs would then share one ViewModel, and Back
// on one tab would clear it under the other. Prefixing the tab
// gives each stack its own.
entryProvider = { key ->
val entry = entryProvider(key)
NavEntry(key = key, contentKey = "$topLevel/${entry.contentKey}", metadata = entry.metadata) {
entry.Content()
}
},
)
}
with NavEntry imported from androidx.navigation3.runtime. The same steps, then two rotations, then process death and Back:
D Scope ( 9536): PriceDetailViewModel created viewModel=67521554 priceId=lasalgaon-onion-2026-11-07
D Scope ( 9536): PriceDetailViewModel created viewModel=182602786 priceId=lasalgaon-onion-2026-11-07
D Scope ( 9536): PriceDetailViewModel onCleared viewModel=182602786
D Scope ( 9649): PriceDetailViewModel created viewModel=133552925 priceId=lasalgaon-onion-2026-11-07
D Scope ( 9649): PriceDetailViewModel onCleared viewModel=133552925
Two ViewModels, one per tab; rotation created none; Back cleared only the Prices tab's; the Markets tab's screen came back after process death and Back cleared it. The lesson generalises: a ViewModel's scope is decided by whoever owns the ViewModelStore, and a shared key is a shared ViewModel.
Check your work
What dependency injection means. A class receives what it needs instead of creating it.
What Hilt adds over lesson 3's container. It generates the container from annotations, provides ViewModels without per-route factories, and checks the graph at build time.
Why SamplePriceRepository needed a second constructor. Hilt cannot provide its test-friendly parameters; the @Inject constructor supplies the defaults.
What @Binds says. When asked for this interface, use that class.
Why PriceDetailViewModel uses assisted injection. The price id comes from the navigation key, which only the caller knows.
What happened without @Singleton, and why. Each request got a new repository, so the refresh changed one and the best-market line read another.
Which mistake fails the build, and which only fails at run time. A missing binding fails the build; a missing @AndroidEntryPoint crashes on launch; a missing scope does neither.
Why the two tabs shared a ViewModel. Their entries had the same content key, and the decorators look stores up by it.
Practice 4. The bug does not return. On the emulator, "Onion" and "Refresh" gave "Best today: Nashik APMC, ₹2,560" above Nashik at ₹2,560: one repository. An unscoped @Binds hands out whatever the class's own binding gives, and the class is now scoped. Both places work; Mandi Bhav keeps the scope on the binding so that the one file that says which repository the app uses also says how long it lives, and module 8 changes both in the same place.
Practice
- Add the versions, plugins and dependencies; annotate the application, activity, use case, repository and
ViewModels; addDataModule; switch every route tohiltViewModel(); deleteAppContainer.kt. Build, run the 52 tests, then repeat the module 6 rotation and process-death check. - Measure your own build before and after: run
./gradlew cleanthen time./gradlew assembleDebug --no-build-cache, twice each. - Reproduce all three breakages: no
@Singleton, no@AndroidEntryPoint, no binding. For each, say whether you would find it in the build, on launch, or only by reading the screen. - Break it differently: remove
@Singletonfrom the binding and put it on theSamplePriceRepositoryclass instead. Predict whether the refresh bug returns, then check. - Reproduce the shared
ViewModelwith the module 6 version ofTopLevelNavigation.entriesand the log lines from module 6's navigation lesson. Then apply the fix and run it again. - Harder: module 9 adds a Retrofit service that only an
@Providesfunction can build. Write the module that provides aRetrofitand a service interface from it, decide which component it belongs in and whether each binding should be scoped, and argue each decision from this lesson. (It does not need to compile until module 9 adds the library.)
Official documentation
- Dependency injection with Hilt — Setup, modules,
@Bindsand@Provides, the components and their scopes. - Use Hilt with other Jetpack libraries —
@HiltViewModel, assisted injection, andhiltViewModel()inside Navigation 3 entries. - Dependency injection in Android — The idea without the library, and why Android needs it.
- Hilt: ViewModels — The Dagger project's own page, with assisted injection in more detail.
Next: flows, so a screen that is already open sees new prices.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship