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.
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.
| Component | Scope annotation | Lives as long as |
|---|---|---|
SingletonComponent | @Singleton | The Application process |
ActivityRetainedComponent | @ActivityRetainedScoped | The Activity, across config change |
ViewModelComponent | @ViewModelScoped | One ViewModel instance |
ActivityComponent | @ActivityScoped | One Activity instance (dies on rotation) |
FragmentComponent | @FragmentScoped | One Fragment instance |
ViewComponent / ViewWithFragmentComponent | @ViewScoped | One View attachment |
ServiceComponent | @ServiceScoped | One Service instance |
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 / Dagger | Koin | |
|---|---|---|
| Graph resolution | Compile time | Runtime |
| Missing binding | Build error | Runtime exception |
| Build cost | Annotation processing (KSP) | Zero |
| Runtime cost | Generated factories, near-zero | Map lookups per resolution |
| Multiplatform | Android / JVM only | All KMP targets |
| Learning curve | Components, scopes, modules, qualifiers | Module DSL + get() |
| Refactor safety | Compiler catches breakage | Tests 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.
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 suppliesPostRepositoryImpl) is wired by the DI graph at runtime;@Bindsis the mechanism. - State Management -
SavedStateHandleis injected by Hilt into@HiltViewModelconstructors automatically; process-death-restored state arrives before the ViewModel'sinitblock. - Concurrency - the
CoroutineDispatcheris the canonical "inject me so tests can swap to aTestDispatcher" dependency. Qualifiers (@IoDispatcher,@MainDispatcher) let multiple dispatchers coexist. - Persistence - the
Roomdatabase andDataStoreinstances are@Singletonproviders 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 -
MediaRepositoryandUploadRepositoryare@Singleton; theWorkManagerworker uses Hilt's@HiltWorkerso the factory can construct workers with injected dependencies. - Ride-Sharing App - the location-tracking foreground service is
@AndroidEntryPointwith a@SingletonLocationSource; the trip session is@ActivityRetainedScopedso 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 publicAnalyticsinterface, so the host app gets DI integration for free.
Done reading? Mark it so it sticks in your dashboard.