Dependency injection
Dependency injection is not a library. It is the decision to pass collaborators in rather than construct them — and Android's component lifecycle is why that decision needs help.
Every Android app already does dependency injection. The question is only whether it does it with
constructor parameters or with a singleton that reaches for Context from inside a function.1
TL;DR#
- DI is passing dependencies in. Manual DI — a container built in
Application— is a complete, valid answer for small and medium apps. - generates the wiring, gives components scopes that match Android lifetimes, and removes the container boilerplate.
- The hard part on Android is not object construction; it is lifetime. An activity-scoped object must not leak into a singleton.
- Scopes are the contract:
@Singleton,@ActivityScoped,@ViewModelScopedsay how long an instance lives.1 - The payoff is testability: a that takes a repository can be tested on the JVM in milliseconds.
Mental model#
Think of a restaurant kitchen.
Without DI, each cook fetches their own ingredients from the supplier, at whatever time they need them, with their own account. With DI, one person orders everything, keeps a pantry, and hands each cook what they need. The cooks do not know where the tomatoes came from, and that is the point: swap the supplier and the recipe still works.
Where the analogy breaks#
In a kitchen, the pantry is one thing. In Android, there are several pantries with different lifetimes: one for the process (singletons), one per activity, one per navigation graph, one per ViewModel. Putting a perishable item in the wrong pantry is exactly the leak bug, and no kitchen analogy prepares you for it.
The big picture#
Text description of this diagram
The three-layer architecture, annotated with the lifetime each layer's objects have. Data-layer objects — the database, the network client, repositories — are process-scoped singletons. Domain-layer use cases are usually stateless and can be created per use or held as singletons. UI-layer ViewModels are scoped to a navigation destination and survive configuration changes. Composables are scoped to the composition. The diagram shows the dependency arrows pointing downward and the lifetime shrinking as you go up.
Deep dive#
Manual DI is real DI#
class AppContainer(context: Context) {
private val database: NotesDatabase by lazy {
Room.databaseBuilder(context, NotesDatabase::class.java, "notes.db").build()
}
val notesRepository: NotesRepository by lazy {
NotesRepository(
api = NotesApi(okHttpClient),
dao = database.notesDao(),
)
}
}
class NotesApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
class NotesViewModel(
private val repository: NotesRepository,
) : ViewModel() {
class Factory(private val container: AppContainer) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return NotesViewModel(container.notesRepository) as T
}
}
}That is a complete dependency-injection setup, with no library.1 It is the right answer for a small app, and it is the right mental model for Hilt: Hilt is a generated version of the same container, with scopes.
The cost is visible in Factory: every ViewModel needs a factory, and every new dependency means
editing the factory. Multiply by thirty ViewModels and the boilerplate is the reason people reach for a
library.
Hilt#
@HiltAndroidApp
class NotesApp : Application()
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun database(@ApplicationContext context: Context): NotesDatabase =
Room.databaseBuilder(context, NotesDatabase::class.java, "notes.db").build()
@Provides
fun notesDao(db: NotesDatabase): NotesDao = db.notesDao()
}
@AndroidEntryPoint
class NotesActivity : ComponentActivity() {
private val viewModel: NotesViewModel by viewModels()
}
@HiltViewModel
class NotesViewModel @Inject constructor(
private val repository: NotesRepository,
) : ViewModel()Four annotations carry the whole system:
| Annotation | Meaning |
|---|---|
@HiltAndroidApp | generate the application component |
@AndroidEntryPoint | this component participates in injection |
@HiltViewModel | generate the ViewModel factory |
@InstallIn(SingletonComponent::class) | this module's bindings live in the app-scoped component |
@HiltViewModel is the one that removes the most code: hiltViewModel() in Compose or
by viewModels() in a View, and the factory is generated.2
Scopes and their lifetimes#
| Scope | Lifetime | Typical contents |
|---|---|---|
@Singleton | the process | database, OkHttp client, repositories |
@ActivityRetainedScoped | across configuration changes | objects that must outlive an activity |
@ActivityScoped | one activity instance | presenters, adapters tied to a screen |
@ViewModelScoped | one ViewModel | objects shared inside a ViewModel's graph |
@ServiceScoped | one service | service-local helpers |
@Module
@InstallIn(ActivityComponent::class)
object EditorModule {
@Provides
@ActivityScoped
fun editorState(): EditorState = EditorState()
}Injecting @ActivityScoped into a @Singleton is a compile error in Hilt — which is the entire value
proposition. The leak you would have found in production is a build failure instead.
What is injected, and what is not#
A good rule: inject things with behaviour and lifetime; construct things with neither.
Inject: repositories, API clients, dispatchers, clocks, analytics, formatters that depend on locale.
Do not inject: data classes, UI models, Modifiers, anything created per item in a list. A DI graph is
for collaborators, not for values.
// Inject the clock so tests can control time.
class NotesViewModel @Inject constructor(
private val repository: NotesRepository,
private val clock: Clock,
) : ViewModel()
// Provide it once.
@Module
@InstallIn(SingletonComponent::class)
object TimeModule {
@Provides fun clock(): Clock = Clock.systemUTC()
}That clock injection looks excessive and is the difference between a flaky test and a deterministic one.
Dispatchers are dependencies#
@Module
@InstallIn(SingletonComponent::class)
object DispatchersModule {
@Provides
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}
class NotesViewModel @Inject constructor(
private val repository: NotesRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
fun refresh() = viewModelScope.launch {
withContext(io) { repository.refresh() }
}
}Injecting dispatchers lets a test substitute UnconfinedTestDispatcher, which makes coroutine tests
deterministic. Hard-coding Dispatchers.IO makes every test that exercises the path non-deterministic.
Compose and Hilt#
@Composable
fun NotesRoute(
viewModel: NotesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
NotesScreen(state = state, onRefresh = viewModel::refresh)
}
@Composable
fun NotesScreen(
state: NotesUiState,
onRefresh: () -> Unit,
) { /* stateless */ }hiltViewModel() resolves the ViewModel from the nearest NavBackStackEntry or ViewModelStoreOwner,
which is why a ViewModel survives rotation and is scoped to the navigation destination. The *Route
versus *Screen split is the Composition-era version of "activity wires, view renders".
What DI does not solve#
Dependency injection makes construction explicit. It does not make the graph correct, and it does not fix a design where a class has twelve dependencies. Twelve constructor parameters is a signal that the class is doing twelve things, and Hilt will happily inject all twelve.
The useful rule: if a class needs more than about five collaborators, look for the missing abstraction before reaching for an annotation. DI makes a bad design easier to wire, not better.
Alternatives worth knowing#
- Manual container — no codegen, no build-time cost, explicit. Fine up to a few dozen bindings.
- Koin — a runtime service locator with a DSL. Simpler than Hilt, no compile-time graph checking.
- Dagger — the library Hilt is built on. More power, much more ceremony.
koin-compose/metro— newer entrants that integrate with Compose's lifecycle.
The honest comparison: Hilt's advantage is compile-time verification of the graph, which catches missing bindings and scope violations before the app runs. Its cost is build time and generated code. On a large app, the trade is usually worth it; on a small one, it may not be.
Multi-module graphs#
In a modularized app, each module contributes bindings and Hilt assembles them:
// :feature:notes
@Module
@InstallIn(ViewModelComponent::class)
object NotesFeatureModule {
@Provides
@ViewModelScoped
fun notesValidator(): NotesValidator = NotesValidator()
}The compile-time guarantee is what makes this scale: a feature module that forgets to provide a binding fails when the app module assembles the graph, not at runtime on a user's device. That is the same argument as a type checker — the failure moves earlier and becomes cheaper.
Two rules keep a multi-module graph navigable:
- Modules provide, the app assembles. A feature module should not install anything into
SingletonComponentunless it genuinely owns a process-wide resource. - Prefer constructor injection to modules. A
@Inject constructorneeds no module at all; a@Providesfunction is only necessary for interfaces, third-party types and configuration.
The cost, honestly#
Hilt is not free. It adds:
- Build time. KSP generates a component per annotated class.
- Generated code in the APK, though R8 removes most of it.
- A learning curve around scopes and component hierarchy, which is where the bugs are.
The trade is worth it when the graph is large enough that a missing binding is likely. For an app with five dependencies, a hand-written container is clearer and faster to build. The decision is the same one as modularization: add structure when the absence of structure has already caused a problem.
The graph is documentation#
A Hilt module list is the most accurate description of an app's dependencies that exists, because it
is the one the compiler enforces. Reading SingletonComponent's bindings tells you what is process-wide
and therefore what must be safe to share; reading a feature's ViewModelComponent bindings tells you
what that feature owns.
That is a maintenance argument as much as a testing one. A graph that has grown to forty singletons is a graph where nobody can say what the app depends on, and the first symptom is a startup that gets slower with every release.
Testing with DI#
@Test
fun `refresh shows error on failure`() = runTest {
val repository = FakeNotesRepository(failWith = IOException("offline"))
val viewModel = NotesViewModel(repository, testDispatcher, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC))
viewModel.refresh()
advanceUntilIdle()
assertEquals("offline", viewModel.state.value.error)
}No Hilt, no Robolectric, no device. That test exists because the ViewModel takes its collaborators as constructor parameters — which is the whole point of DI, independent of the framework.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 4 | 1.6 Donut |
|
| 5 | 2.0 Eclair |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 13 | 3.2 Honeycomb |
|
| 14 | 4.0 Ice Cream Sandwich |
|
| 16 | 4.1 Jelly Bean |
|
| 17 | 4.2 Jelly Bean |
|
| 18 | 4.3 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 22 | 5.1 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 27 | 8.1 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 32 | 12L |
|
| 33 | 13 Tiramisu |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
| 37 | 17 Cinnamon Bun |
|
DI on Android went from manual singletons (2008), through Dagger with custom components (2015), to Hilt's standardised components (2020). The direction is toward making the graph declarative and checkable, because a hand-wired container cannot be verified.
Under the hood#
- Application.javathe process-wide hook a DI container is built from
- Activity.javathe component Hilt's activity scope is tied to
- ComponentCallbacks.javathe lifecycle callbacks a container may need to observe
Curiosity corner#
- Hilt is a code generator, not a reflection library. Everything is resolved at compile time, which
is why a missing binding is a build error rather than a
ClassNotFoundException. @ActivityRetainedScopedis what makes an object survive rotation without being a singleton. It lives in theViewModelStore, which the system retains across configuration changes.- KSP replaced KAPT for Hilt and Room, which roughly halved annotation-processing time in many builds.
Try it#
find build -path "hilt" -name "*.java" | head -20
grep -rn "ViewModelProvider.Factory" --include=*.kt .
Check yourself#
Check yourself
Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsDependency injection on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsJetpackdeveloper.android.comaccessed 2026-10-27↩
- docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
- docsבדיקת אפליקציות ב-Android | Test your app on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsAndroidX রিলিজ | Jetpackdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩