← All problems

Mobile Core Concepts

Dependency Injection

Why the wiring between your layers is a build-time contract, not a runtime guess - and the scopes that keep a Singleton from leaking a screen's state across the app.

Free~20 min

Every non-trivial Android app reaches a moment where "where do Retrofit, OkHttpClient, Room, the Auth singleton, and the Repository all come from, and who decides their lifetime" becomes the central architectural question. Dependency injection is the answer: a framework owns the construction graph and hands wired objects to the components that need them. This page covers Hilt's annotation-processor codegen, Dagger's manual version of the same, Koin's runtime service locator, what scope means in practice, and how the graph holds up across multi-module builds. The Architecture entry is the prereq - DI is what makes its Clean Architecture dependency inversion actually wire at runtime.

What DI does on Android

Three problems show up the moment a screen has more than two collaborators. Construction: FeedViewModel needs GetFeedUseCase needs PostRepository needs PostApi needs an authenticated OkHttpClient; hand-rolling that tree at every call site duplicates wiring. Lifetime: OkHttpClient should be one instance per process, FeedViewModel should be one per screen and survive rotation, GetFeedUseCase is stateless and cheap to re-create. Substitutability: tests want a fake PostApi; debug builds want a Chuck-instrumented client; all without touching call sites.

DI frameworks solve all three by separating declaring what depends on what from constructing the graph. The declaration lives in modules and annotations; the framework produces (or computes at runtime) a graph; components ask the graph for what they need.

The Android-specific wrinkle is the lifecycle. An Activity is recreated for a config change, a ViewModel outlives the Activity through rotation but dies on finish(). The framework has to bind "what is a singleton" to the right component, not just "the app." That's what scoping is.

Hilt - the 2025-era default

Hilt is Dagger with a pre-defined component hierarchy aligned to Android's lifecycle, plus annotations that hide the Dagger boilerplate. New Android code Google ships - the official Architecture samples, Now in Android, the Compose codelabs - uses Hilt by default.

Four annotations carry 90% of the surface. @HiltAndroidApp on the Application subclass triggers codegen for the application-level component (SingletonComponent). @AndroidEntryPoint on an Activity, Fragment, Service, or View lets Hilt inject into it. @HiltViewModel plus @Inject constructor(...) lets hiltViewModel() / viewModels() build a ViewModel with dependencies supplied. @Inject constructor(...) on a regular class tells Hilt how to build it - Repository, UseCase, DataSource all participate in the graph just by declaring their constructor.

@HiltAndroidApp
class MyApp : Application()
 
@AndroidEntryPoint
class FeedActivity : ComponentActivity() {
    private val vm: FeedViewModel by viewModels()
}
 
@HiltViewModel
class FeedViewModel @Inject constructor(
    private val getFeed: GetFeedUseCase,
) : ViewModel()

Things Hilt cannot build by constructor (Retrofit, OkHttpClient, Room, anything from a third-party library) live in @Module classes annotated with @InstallIn(<Component>::class). A @Provides method declares "build a Foo this way; install it at this scope."

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun okHttp(auth: AuthTokenInterceptor): OkHttpClient =
        OkHttpClient.Builder().addInterceptor(auth).build()
 
    @Provides @Singleton
    fun retrofit(client: OkHttpClient): Retrofit =
        Retrofit.Builder().baseUrl(BASE).client(client).build()
}

The codegen happens at build time. Hilt's annotation processor (now KSP, formerly kapt) emits factories per binding, components per scope, and field-injection glue per @AndroidEntryPoint. The cost is build time - seconds on small modules, tens of seconds on large multi-module apps. The benefit is compile-time graph validation: a missing binding is error: [Dagger/MissingBinding] AuthTokenProvider cannot be provided, not a runtime crash on the screen that needs it.

Sources: Hilt overview · Dagger and Hilt guide

Scopes

A scope says "while this component is alive, return the same instance." Hilt ships eight pre-defined components, each with a corresponding @Scope annotation and a ViewModelStoreOwner-style lifetime.

ComponentScope annotationLives as long as
SingletonComponent@SingletonThe Application process
ActivityRetainedComponent@ActivityRetainedScopedThe Activity, across config change
ViewModelComponent@ViewModelScopedOne ViewModel instance
ActivityComponent@ActivityScopedOne Activity instance (dies on rotation)
FragmentComponent@FragmentScopedOne Fragment instance
ViewComponent / ViewWithFragmentComponent@ViewScopedOne View attachment
ServiceComponent@ServiceScopedOne Service instance
Rendering diagram…

The arrows mean outlives. A @Singleton is reachable from anywhere. An ActivityRetainedComponent binding is the same instance across rotation but a different one once the Activity finishes; ActivityComponent is the opposite - one instance per Activity creation, so rotation produces a new one.

Picking a scope is mostly "what is this object's natural lifetime." OkHttpClient, Retrofit, Room, WorkManager: @Singleton. Per-screen state surviving rotation: @ActivityRetainedScoped. Per-ViewModel dependencies that don't escape the screen: @ViewModelScoped. Anything wrapping the Activity's UI handle: @ActivityScoped.

Scope and Context have to match. Inject @ApplicationContext into @Singleton bindings and @ActivityContext into Activity-scoped ones; binding the wrong Context into a @Singleton holds the destroyed Activity for the whole process lifetime - a leak. The qualifier annotations exist to enforce that lifetime match.

The component a binding is installed in is just as critical as its scope. A @Module in ActivityComponent is invisible to ViewModelComponent, which outlives the Activity across config changes - so a ViewModel-injectable binding must be @InstallIn(ViewModelComponent::class) (or SingletonComponent for anything visible everywhere), not ActivityComponent.

Unscoped is the right default for stateless graph nodes. A GetFeedUseCase with only injected dependencies costs nothing to re-construct; adding @Singleton would hold an extra instance for the process lifetime for no benefit.

ViewModel injection

ViewModel is awkward to construct because the framework builds it, not your code. ViewModelProvider owns the lifecycle and historically forced no-arg constructors that pulled dependencies from a service locator inside init.

Hilt closes that gap. @HiltViewModel registers the ViewModel with a Hilt-provided ViewModelProvider.Factory; hiltViewModel() in Compose (or by viewModels() on a @AndroidEntryPoint host) routes construction through it, pulling each constructor parameter from the ViewModelComponent graph.

@HiltViewModel
class ComposeMessageViewModel @Inject constructor(
    private val handle: SavedStateHandle,
    private val send: SendMessageUseCase,
) : ViewModel()
 
// In a Composable:
val vm: ComposeMessageViewModel = hiltViewModel()

SavedStateHandle is special - Hilt injects it from ViewModelComponent automatically, so the process-death-restoration story from State Management is a one-parameter addition.

Assisted injection covers the remaining case: a parameter the caller must provide (a route argument the graph cannot know about) combined with graph-supplied parameters. Hilt's @AssistedInject plus a generated factory handles it without forcing the caller to construct the rest of the graph.

Plain Dagger 2

Dagger is the substrate Hilt sits on - Hilt pre-wires Android lifecycle pieces on top of a Dagger graph, but underneath the codegen and runtime are still Dagger.

Teams reach for plain Dagger when the graph needs a custom component hierarchy Hilt's eight don't model (a login sub-graph, an SDK with its own lifetime, a per-user-session scope spanning Activities), when the module isn't Android (a pure-JVM library, a KMP shared module's Android target), or when there's pre-existing Dagger predating Hilt (2020) and wholesale migration is more disruptive than continuing.

The shape of plain Dagger is @Component interfaces with @Subcomponents for narrower scopes:

@Singleton
@Component(modules = [NetworkModule::class, StorageModule::class])
interface AppComponent {
    fun chatComponent(): ChatComponent.Factory
    fun inject(activity: HomeActivity)
}
 
@Subcomponent(modules = [ChatModule::class])
interface ChatComponent {
    @Subcomponent.Factory
    interface Factory { fun create(@BindsInstance chatId: String): ChatComponent }
    fun inject(activity: ChatActivity)
}

@BindsInstance is the escape hatch for runtime values known only at the call site (a chatId, a user-supplied config). Field injection (inject(activity)) is plain Dagger's way of supplying dependencies to framework-constructed objects; Hilt's @AndroidEntryPoint codegen does the same thing under the hood.

Sources: Dagger user's guide

Koin - runtime service locator

Koin takes the opposite tradeoff: no codegen, no annotation processor, no compile-time graph. You declare bindings in a Kotlin DSL; the framework resolves them at runtime by looking up types in a map.

val appModule = module {
    single { OkHttpClient.Builder().build() }
    single { Retrofit.Builder().baseUrl(BASE).client(get()).build() }
    single<PostApi> { get<Retrofit>().create(PostApi::class.java) }
    single<PostRepository> { PostRepositoryImpl(get()) }
    viewModel { FeedViewModel(get()) }
}
 
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin { modules(appModule) }
    }
}
 
// In an Activity:
private val vm: FeedViewModel by viewModel()

get() performs a runtime type lookup. single is process-singleton, factory builds per call, viewModel integrates with the ViewModelProvider factory chain.

The substantive tradeoff vs Hilt: missing bindings show up at runtime, not at compile time. Forgetting to declare PostApi builds clean and crashes on first use with org.koin.core.error.NoBeanDefFoundException. In return: no kapt / KSP step, a smaller learning surface, and a DSL that reads as configuration.

Koin also runs on every Kotlin Multiplatform target. Hilt is JVM-Android-only. Shared KMP code uses Koin, kotlin-inject, or a hand-rolled locator - Hilt is not an option on iOS or JS.

startKoin may be called only once per process. Calling it a second time (common in instrumentation tests that re-bootstrap the graph) throws KoinAppAlreadyStartedException; use loadKoinModules / unloadKoinModules for test-time overrides instead.

Hilt / DaggerKoin
Graph resolutionCompile timeRuntime
Missing bindingBuild errorRuntime exception
Build costAnnotation processing (KSP)Zero
Runtime costGenerated factories, near-zeroMap lookups per resolution
MultiplatformAndroid / JVM onlyAll KMP targets
Learning curveComponents, scopes, modules, qualifiersModule DSL + get()
Refactor safetyCompiler catches breakageTests must catch breakage

The interview answer is to pick the tradeoff knowingly: "Hilt because compile-time missing-binding errors are worth the KSP cost on an Android-only app; with a KMP shared core we'd reach for Koin or kotlin-inject in shared code and Hilt at the Android edge."

Multi-module graphs

Once an app splits into modules - :app, :feature-chat, :feature-feed, :core-network, :core-database - the DI graph spans them. Hilt's model: a single SingletonComponent at :app, every other module contributes bindings via @InstallIn(SingletonComponent::class) modules.

Rendering diagram…

Feature modules expose interfaces and ViewModels; implementations stay internal. :core-network provides Retrofit and OkHttpClient; :feature-chat binds ChatRepository (in :feature-chat-api) to ChatRepositoryImpl (in :feature-chat-impl). @Binds declares the interface-to-implementation binding:

@Module
@InstallIn(SingletonComponent::class)
abstract class ChatRepositoryModule {
    @Binds @Singleton
    abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository
}

@Binds is cheaper than @Provides - no factory body, just a cast at resolution. Use it whenever "this interface resolves to that @Inject constructor class" is the whole story.

Build-time cost scales with graph size. Every module runs KSP, and Hilt's app-level aggregation (hilt_aggregated_deps) runs once per build. The kapt-to-KSP migration roughly halved annotation-processing time; if your build is slow and you're still on kapt, that's the first lever.

Plain Dagger handles multi-module differently: subcomponents per feature, parent components exposing factories. More verbose, but lets features own their own component lifetime.

See also

  • Architecture - the prereq. Clean Architecture's dependency inversion (Domain owns interface PostRepository, Data supplies PostRepositoryImpl) is wired by the DI graph at runtime; @Binds is the mechanism.
  • State Management - SavedStateHandle is injected by Hilt into @HiltViewModel constructors automatically; process-death-restored state arrives before the ViewModel's init block.
  • Concurrency - the CoroutineDispatcher is the canonical "inject me so tests can swap to a TestDispatcher" dependency. Qualifiers (@IoDispatcher, @MainDispatcher) let multiple dispatchers coexist.
  • Persistence - the Room database and DataStore instances are @Singleton providers in :core-database.
  • Build Systems - kapt vs KSP, configuration cache, and how annotation processing impacts build time. The DI graph is one of the biggest annotation-processor surfaces in a typical app.

Used in

  • Messenger App - the chat screen's MVI reducer lives in a @HiltViewModel; the WebSocket holder is @Singleton; the per-conversation cache is @ViewModelScoped.
  • Photo Gallery App - MediaRepository and UploadRepository are @Singleton; the WorkManager worker uses Hilt's @HiltWorker so the factory can construct workers with injected dependencies.
  • Ride-Sharing App - the location-tracking foreground service is @AndroidEntryPoint with a @Singleton LocationSource; the trip session is @ActivityRetainedScoped so it survives rotation but not the trip ending.
  • Doc Editor App - the CRDT engine is @Singleton; the per-document state is @ViewModelScoped; a separate renderer process (if split out) uses plain Dagger because Hilt doesn't model "second Android process owned by the same app."
  • Analytics SDK - host apps integrate by adding the SDK module, which contributes its own @Module @InstallIn(SingletonComponent::class) declaring the public Analytics interface, so the host app gets DI integration for free.

Done reading? Mark it so it sticks in your dashboard.