Zygote
App architectureintermediate

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.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

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, @ViewModelScoped say 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#

Loading diagram…
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:

AnnotationMeaning
@HiltAndroidAppgenerate the application component
@AndroidEntryPointthis component participates in injection
@HiltViewModelgenerate 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#

ScopeLifetimeTypical contents
@Singletonthe processdatabase, OkHttp client, repositories
@ActivityRetainedScopedacross configuration changesobjects that must outlive an activity
@ActivityScopedone activity instancepresenters, adapters tied to a screen
@ViewModelScopedone ViewModelobjects shared inside a ViewModel's graph
@ServiceScopedone serviceservice-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 SingletonComponent unless it genuinely owns a process-wide resource.
  • Prefer constructor injection to modules. A @Inject constructor needs no module at all; a @Provides function 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#

Architecture changes29 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
  • The first zygote-based app spawning model
41.6 Donut
  • Support for different screen densities via resource qualifiers
52.0 Eclair
  • First multi-core-friendly scheduling and graphics work
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
  • Support for x86 and ARMv7 optimisations
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
113.0 Honeycomb
  • Hardware acceleration for the View system (opt-in per app)
133.2 Honeycomb
  • New screen-size resource qualifiers for compatibility scaling
144.0 Ice Cream Sandwich
  • One system image for phones and tablets
  • Hardware acceleration on by default
164.1 Jelly Bean
  • Project Butter: vsync, triple buffering, Choreographer
  • RenderThread architecture begins
174.2 Jelly Bean
  • Multi-user support on tablets
  • SELinux enforcement begins in the kernel (behind the scenes)
184.3 Jelly Bean
  • SELinux enforcement rolled out more broadly
  • Binder changes for restricted profiles
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
  • Kernel moved under a tighter vendor control
204.4W KitKat (Wear)
  • First time the platform shipped for a new form factor
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
  • 64-bit ARM and x86 support
  • RenderThread and OpenGL ES 3.1
  • Project Volta and JobScheduler
  • SELinux fully enforcing
225.1 Lollipop
  • Device protection ties verified boot to Google accounts
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
  • Doze batches background work
  • Fingerprint HAL
  • Adoptable storage
247.0 Nougat
  • Project Treble groundwork
  • A/B seamless updates
  • Hybrid JIT + AOT compilation in ART
  • MediaServer split into sandboxed processes
268.0 Oreo
  • Project Treble: the vendor interface separates vendor and system code
  • Background execution limits
  • Broadcast and service restrictions
  • HIDL for HALs
278.1 Oreo
  • Android Go: a lighter configuration of the same platform
289 Pie
  • App Standby Buckets
  • Mainline groundwork
  • Non-SDK interface restrictions (greylists) begin
  • BiometricPrompt
2910
  • Project Mainline: APEX and updatable system modules
  • Scoped storage
  • Bubbles
  • Project Mainline starts shipping through Play
3011
  • GKI: the Generic Kernel Image splits the kernel into core + vendor modules
  • App hibernation
  • Package visibility filtering
  • Incremental app installs
3112
  • Bluetooth permissions split into the nearby-devices family
  • Exact alarm permissions
  • Foreground service launch restrictions
3212L
  • Window size classes and activity embedding
3313 Tiramisu
  • Granular media permissions replace READ_EXTERNAL_STORAGE
  • The notification permission becomes a runtime permission
  • Predictive back gesture
3414 Upside Down Cake
  • Foreground service types are mandatory
  • Selected photos access via READ_MEDIA_VISUAL_USER_SELECTED
3515 Vanilla Ice Cream
  • 16 KB page size support for native libraries
  • Edge-to-edge is enforced when targeting 35
3616 Baklava
  • The release moves to Q2 (June) with a Q4 feature drop
  • Two SDK releases per year starting with 36
  • Desktop windowing arrives in developer options then stable
3717 Cinnamon Bun
  • Strict per-device app memory limits are enforced by the system
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector
  • A lock-free MessageQueue for apps targeting API 37
  • Compose-first: new platform and Jetpack APIs target Compose only; View libraries enter maintenance mode
  • Hybrid APK signing (v3.2) combines classical and ML-DSA post-quantum signatures
  • Native library dynamic code loading must be read-only

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#

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.
  • @ActivityRetainedScoped is what makes an object survive rotation without being a singleton. It lives in the ViewModelStore, 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#

See the generated Hilt graph

find build -path "hilt" -name "*.java" | head -20

Check for a ViewModel factory you can delete

grep -rn "ViewModelProvider.Factory" --include=*.kt .

Check yourself#

Check yourself

1 / 12

Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?

0 answered · 0 correct

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.

  1. docsDependency injection on Androiddeveloper.android.comaccessed 2026-10-27↩
  2. docsJetpackdeveloper.android.comaccessed 2026-10-27↩
  3. docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
  4. docsבדיקת אפליקציות ב-Android | Test your app on Androiddeveloper.android.comaccessed 2026-10-27↩
  5. docsAndroidX রিলিজ | Jetpackdeveloper.android.comaccessed 2026-10-27↩
  6. docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩