Dependency injection means a class receives its dependencies from outside, usually through the constructor, instead of creating them or fetching them from a global.
Testability: a test passes a fake UserApi straight into the constructor; nothing global has to be mocked.
Honest signatures: the constructor lists everything the class needs, so hidden coupling shows up in code review.
Dependency Inversion (the D in SOLID) is a principle: depend on abstractions such as a UserRepository interface, not on concrete classes.
Inversion of Control: something outside the class decides what it gets; DI is one form of it.
Service locator: the class asks a registry (ServiceLocator.userRepository); the dependency is invisible in the signature and every test must configure the global first.
AndroidMiddle
Android Dependency Injection
Hilt, Dagger, Koin, scopes, modules, testability
Questions closed
0 / 24
- 0Review
- 6Asked often
Questions
Constructor injection is the default because the object cannot exist without its dependencies; Activities and Fragments are created by the system through a no-arg constructor, so Hilt has to assign @Inject fields after creation instead.
Mark the class @AndroidEntryPoint and declare @Inject lateinit var analytics: AnalyticsTracker. The field cannot be private, because generated code assigns it.
When fields are filled: in an Activity during super.onCreate(), in a Fragment during super.onAttach(). Reading one earlier, in a property initializer for example, throws UninitializedPropertyAccessException.
An @AndroidEntryPoint Fragment needs a host Activity that is @AndroidEntryPoint too, because the Activity's component supplies its bindings.
Method injection (@Inject on a function) also exists and runs right after field injection; it is rare in practice.
Keep field injection at the edge: the Activity stays thin, and the real work lives in a @HiltViewModel that uses constructor injection and can be built with fakes in a plain unit test.
@Inject marks constructors Dagger may call, @Module with @Provides builds types it cannot construct itself, and @Component is the interface Dagger implements to connect them; the graph is validated at compile time, instances are created lazily at runtime.
**@Inject constructor**: Dagger generates a UserRepository_Factory that calls it. On a field, @Inject means members injection into an object someone else created.
**@Module + @Provides**: a function body for third-party or configured types: Retrofit, OkHttpClient, a Room database. @Binds maps an interface to an implementation.
**@Component**: lists its modules and exposes provision methods (fun userRepository(): UserRepository) or injection methods (fun inject(activity: MainActivity)). Dagger generates DaggerAppComponent.
**@Component.Factory with @BindsInstance**: hands runtime objects such as Context into the graph.
Scopes: @Singleton on the component and on a binding means one instance per component instance, not per process.
The annotation processor (KSP or kapt) reads all of this during compilation, validates the graph and writes plain Java. A missing binding is a build error, and at runtime there is no reflection, only generated factories calling constructors. Hilt uses the same annotations and writes the components for you.
Hilt is Google's DI library for Android: Dagger 2 with a predefined component tree tied to Android lifecycles, so you annotate classes instead of writing and wiring components yourself.
Entry points are annotations: @HiltAndroidApp on the Application generates the root component; @AndroidEntryPoint injects an Activity, Fragment, View, Service or BroadcastReceiver; @HiltViewModel registers a ViewModel for by viewModels() and hiltViewModel().
Fixed hierarchy: SingletonComponent is the root; under it sit ActivityRetainedComponent (parent of ViewModelComponent and ActivityComponent) and ServiceComponent; FragmentComponent and ViewComponent sit under ActivityComponent. Each has its own scope annotation.
**@InstallIn: a module declares which component it belongs to; nobody lists modules in a component interface.
Jetpack integrations**: SavedStateHandle in ViewModels, @HiltWorker for WorkManager, hiltViewModel() for Compose, test APIs.
Everything else is plain Dagger: @Module, @Provides, @Binds, qualifiers, multibindings. For a lifetime Android does not have, such as a logged-in session, you add @DefineComponent.
Setup is the com.google.dagger.hilt.android Gradle plugin and ksp("com.google.dagger:hilt-compiler"); kapt still works but is the slower legacy path. Hilt and plain Dagger can coexist, so an existing Dagger app can migrate screen by screen.
@Binds tells Dagger that one type is satisfied by an existing binding of another type, so it generates nothing but an alias; @Provides runs a function body you wrote. Both fit one module if @Provides goes in a companion object.
Use @Binds when
You own the implementation and it already has an @Inject constructor.
All you need is "when someone asks for AnalyticsTracker, give them FirebaseTracker".
You contribute your own injectable class into a multibinding with @IntoSet or @IntoMap.
Use @Provides when
You do not own the type: Retrofit, OkHttpClient, FirebaseAnalytics, a Room database. There is no constructor you can annotate.
Construction needs configuration: builders, base URLs, timeouts, a Context.
Why it matters: @Binds produces no factory class, just a cast, so a graph built mostly from @Binds generates less code and builds faster.
The module shape follows from that. @Binds methods are abstract, so they live in an abstract class or interface module; @Provides in Kotlin belongs in an object so the function is static. To mix both, use an abstract class with a companion object holding the @Provides functions.
A scope makes a binding one instance per instance of its component, so picking a scope is a claim about how long the object lives; unscoped bindings are built fresh at every injection point, and that is the right default.
@Singleton → SingletonComponent: one per process.
@ActivityRetainedScoped → ActivityRetainedComponent: survives rotation, lives until the Activity really finishes.
@ViewModelScoped → ViewModelComponent: one per @HiltViewModel, shared by its collaborators, released when the ViewModel is cleared.
@ActivityScoped, @FragmentScoped, @ViewScoped, @ServiceScoped: one per Android object, so a rotation gives you a new @ActivityScoped instance.
Why not scope everything
A scoped object is held until its component dies. A @Singleton that caches user data keeps it after logout and is never collected.
Each scoped binding goes through DoubleCheck, a provider with double-checked locking: cheap, but not free on hot paths.
A binding may depend only on its own component or wider ones. A @Singleton cannot take an @ActivityScoped type; the build fails.
@Reusable sits in between: Dagger may cache the instance but promises nothing and takes no lock. Use it for stateless, somewhat expensive objects where sharing is fine but not required.
The @Provides method has no scope, so it is a recipe rather than an instance: Dagger runs it again for every injection point, and three consumers get three clients.
Fix: add the scope of the component the module is installed in: @Provides @Singleton here. A scope that does not belong to that component is a compile error.
Why it hurts: every OkHttpClient has its own connection pool, dispatcher threads and cache, so connections are never reused and TLS handshakes repeat. Two Room databases over one file keep separate invalidation trackers and miss each other's writes; two DataStores over one file throw IllegalStateException: There are multiple DataStores active for the same file.
**@Binds too**: put the scope on the @Binds method or on the implementation class; an unscoped interface binding to an unscoped class is a new object every time.
Diagnose: log System.identityHashCode(this) in init, or count instances in a heap dump.
Do not overcorrect by marking every binding @Singleton: mappers, formatters and stateless use cases are cheap and should die with their owner.
@ViewModelScoped means one instance per ViewModel, not one shared between ViewModels: each @HiltViewModel gets its own ViewModelComponent, so ProductViewModel and CartBadgeViewModel hold two different CartState objects and the badge watches the empty one.
Why: Hilt builds a new ViewModelComponent for every ViewModel it creates, and a @ViewModelScoped binding is cached in that component. It is shared only by the collaborators of one ViewModel: its use cases and helpers.
Why it builds: each ViewModel asks for a type the graph can create, so the graph is valid. Only the assumed lifetime is wrong.
The fix: scope the shared state with @ActivityRetainedScoped. ActivityRetainedComponent is the parent of every ViewModelComponent in that Activity, survives rotation and is dropped when the Activity finishes. If the cart must outlive the Activity, use @Singleton, or better keep it in Room and observe it as a Flow.
**Not @ActivityScoped**: ActivityComponent is not an ancestor of ViewModelComponent, so Dagger rejects that binding at build time; and even where it is visible, it would be recreated on every rotation.
Catch it next time: log System.identityHashCode(cart) in both init blocks; two different numbers mean two instances. In review, treat "@ViewModelScoped state read by another screen" as a bug.
The build fails with [Dagger/MissingBinding]: ViewModelComponent sits under ActivityRetainedComponent, which outlives any single Activity, so there is no @ActivityContext to give it. The error is Hilt's way of preventing a leak. Take @ApplicationContext Context instead.
Fix: take @ApplicationContext Context. It is bound in SingletonComponent, visible in every component, and it is what SharedPreferences, Room, DataStore and WorkManager want. Application can also be injected without a qualifier.
Why it matters: a ViewModel survives rotation. Holding the Activity would keep the destroyed Activity and its whole view tree in memory.
**Where @ActivityContext exists**: ActivityComponent, FragmentComponent and ViewComponent: objects that live no longer than the Activity.
The reverse trap: the application context has no window token and no Activity theme. Showing a dialog with it fails with WindowManager$BadTokenException; inflating themed views gives the wrong styling. Anything that draws takes the Activity.
The leak Dagger cannot catch is the hand-written one: a @Singleton with a setter that stores an Activity. The graph looks fine and the Activity is retained for the life of the process.
A @Qualifier tells Dagger which of several bindings of the same type you mean, and multibinding collects many bindings into one Set or Map.
Qualifiers
Annotate both the binding and the injection point with the same annotation.
Typical cases: an authenticated and a plain OkHttpClient, two Retrofit instances with different base URLs, @IoDispatcher and @DefaultDispatcher.
Prefer your own annotation over @Named("io"): a typo in a string surfaces as a missing binding with a confusing name, a typo in an annotation name does not compile.
Multibindings
@IntoSet adds a binding to a Set<T>; @IntoMap with @StringKey or @ClassKey adds it to a Map<K, V>.
Inject the collection as Set<@JvmSuppressWildcards T>, otherwise Kotlin's generics turn it into Set<? extends T> and Dagger cannot match it.
This is what makes feature modules composable: each feature contributes its routes, settings entries or startup initializers, and the app gets the union without importing any feature.
A hard-coded dispatcher is a dependency in disguise: inject a CoroutineDispatcher behind a qualifier so the test can pass a TestDispatcher and control time.
Define a qualifier per dispatcher (@IoDispatcher, @DefaultDispatcher) and provide them from a module in SingletonComponent.
Take a CoroutineDispatcher in the constructor and keep withContext(io) inside the repository; callers should not need to know the work leaves the main thread.
In the test, pass StandardTestDispatcher(testScheduler) from runTest. Dispatchers.IO runs on real threads with a real clock, so runTest's virtual time never applies and every delay or retry backoff burns real seconds.
The main dispatcher needs no injection: Dispatchers.setMain(...) from kotlinx-coroutines-test covers it.
Work that must outlive a screen, such as a queued upload, needs a process-wide CoroutineScope built with SupervisorJob(). Provide it as a @Singleton behind its own qualifier instead of creating CoroutineScope(Dispatchers.IO) inside a class, where no test can replace or cancel it.
A value known only at runtime cannot be a binding, so it arrives through SavedStateHandle or through an assisted factory.
SavedStateHandle: the first answer for ViewModels
Hilt provides it to any @HiltViewModel constructor.
Navigation arguments and Activity extras land in it automatically; with type-safe routes, savedStateHandle.toRoute<OrderRoute>() returns the parsed arguments.
It survives process death, which a factory argument does not.
Assisted injection for everything else
Mark the constructor @AssistedInject and the runtime parameters @Assisted; the rest still comes from the graph.
Declare an @AssistedFactory interface with one method that takes the assisted values. Dagger implements it; you inject the factory.
Two assisted parameters of the same type need keys: @Assisted("from"), @Assisted("to").
For a ViewModel whose value is not a navigation argument, use @HiltViewModel(assistedFactory = ...) and hiltViewModel<VM, VM.Factory> { it.create(id) } in Compose.
Avoid a half-built object with an init(orderId) call after construction, or an id stashed in a singleton for the ViewModel to read back. Both hide the dependency and break as soon as two such screens are open at once.
Both defer construction until you call get(); Provider resolves the binding on every call, while Lazy builds once on first call and caches the result.
**Provider<T>.get(): unscoped means a fresh instance per call, scoped means the scope's single instance. Use it when you need several instances, or a value that changes over time.
Lazy<T>.get(): memoized per injection point. Use it for something expensive the class often does not touch: a database, an image loader, a heavy SDK.
No binding changes**: you only change the parameter type to Provider<Foo> or Lazy<Foo>; Dagger supports both for any binding. dagger.Lazy is not Kotlin's by lazy.
Startup cost: injecting Lazy for expensive singletons moves their construction from app launch to first use.
Cycles: because the reference is resolved at call time, a Provider can break a dependency cycle. That is a last resort; a cycle usually means a design problem.
AuthInterceptor reads AuthToken once, in its constructor, and the @Singleton OkHttpClient builds that interceptor once. Unscoped means a new instance per injection, not per use, so the token stays frozen at the value it had when the client was built.
Why: an unscoped @Provides runs each time something is injected, not each time the value is read. The singleton client is injected once, so authToken() runs once, and a long-lived object holds a short-lived value for the life of the process (a "captive dependency").
Why Dagger is silent: scope validation only stops a binding from depending on a narrower scope. An unscoped binding is legal in every component, so nothing fails at build time.
The fix: inject Provider<AuthToken> and call get() inside intercept, so the binding is resolved per request. Lazy<AuthToken> would not help: it caches the first value just as the constructor did.
Cleaner still: drop the AuthToken binding, inject TokenStore and call current() per request. A snapshot of mutable state should not be a graph binding at all.
Catch it next time: a test with a fake TokenStore and MockWebServer: refresh the token between two calls and assert that takeRequest().getHeader("Authorization") changed.
The cycle is OkHttpClient -> AuthInterceptor -> TokenRefresher -> AuthApi -> OkHttpClient; break it by giving the refresh AuthApi its own qualified client without the auth interceptor, not by hiding the loop behind Provider.
Why it fails: Dagger rejects the cycle at compile time because none of the four could be constructed first.
Quick fix: inject Provider<TokenRefresher> (or Lazy) into the interceptor. The refresher is resolved on the first request, after the client exists, so it compiles. But the refresh call now goes through the same client and the same interceptor, so a failed refresh can recurse into itself.
Right fix: the refresh endpoint does not need the token it is refreshing. Give AuthApi its own client behind a qualifier, without the auth interceptor. The cycle disappears because the design stops lying about who depends on whom.
Related: for refresh-on-401, OkHttp's Authenticator is called only when the server rejects a request, which is usually a better hook than an interceptor.
Treat a dependency cycle as a design signal first. Provider hides the loop; it does not remove it.
Hilt has a dedicated integration for Workers and an @EntryPoint escape hatch for everything else.
WorkManager
Add androidx.hilt:hilt-work and its compiler, annotate the worker @HiltWorker, and use @AssistedInject with @Assisted Context and @Assisted WorkerParameters; your own dependencies come from the graph.
Make the Application implement Configuration.Provider and build the configuration with the injected HiltWorkerFactory.
Remove WorkManager's default initializer from the manifest; otherwise it initializes itself through App Startup with the default factory, and your workers fail to instantiate.
Entry points
Declare an interface with @EntryPoint and @InstallIn(SingletonComponent::class), one getter per binding you need.
Get it with EntryPointAccessors.fromApplication(context, MyEntryPoint::class.java).
This is the route for a ContentProvider: @AndroidEntryPoint does not support providers, because they are created before Application.onCreate().
It also covers classes a library instantiates for you, and legacy code you migrate a screen at a time.
An entry point deep in app code is a service locator in disguise. Use it at the boundary and pass constructor dependencies inward from there.
Dagger reached a type it has no recipe for, and the request chain printed under the message shows which component asked for it and through whom.
Read the chain from the bottom up. The last lines name the component that did the lookup (SingletonC, ActivityRetainedC, ViewModelC); each line above is one constructor parameter deeper, ending at the type Dagger could not build.
The usual causes, most frequent first
Nothing binds it. If you own the class, add @Inject constructor. If it is an interface or a third-party type, @Binds the implementation or @Provides it.
Qualifier mismatch. The consumer asks for @AuthClient OkHttpClient and the module provides a plain one. The error prints the qualifier; compare it exactly.
Wrong component. The module is @InstallIn(ActivityComponent::class) but the consumer lives in SingletonComponent. Bindings are visible downward, never upward: move the module or the consumer.
The module is not in the build. A feature module's bindings join the graph only if the app module depends on that Gradle module.
Neighbouring errors: the same type bound twice in one component gives [Dagger/DuplicateBindings]; a Hilt module without @InstallIn fails with a message asking for it.
In unit tests you skip Hilt and call constructors with fakes; in instrumented and Robolectric tests you use @HiltAndroidTest with @TestInstallIn, @UninstallModules or @BindValue to swap bindings.
Unit tests
Construct the ViewModel or repository directly with fakes. That is faster and keeps the test independent of the DI graph: one more reason to prefer constructor injection.
Instrumented and Compose UI tests
Annotate the class @HiltAndroidTest, add HiltAndroidRule with order = 0 so it runs before the Compose rule, and call hiltRule.inject() in @Before.
**@TestInstallIn(components = [...], replaces = [...]) in the test source set replaces a module for every test that sees it; it is the usual choice.
@UninstallModules(NetworkModule::class) on one test class removes a module for that class only.
@BindValue** on a test field binds that exact instance into the graph, so the test can hand in a fake and assert on it later. In Kotlin add @JvmField (or use lateinit var): the bound field cannot be private.
A custom AndroidJUnitRunner must create HiltTestApplication; with Robolectric, set it via @Config(application = HiltTestApplication::class).
For Compose, host the screen in an empty @AndroidEntryPoint ComponentActivity declared in a debug manifest and drive it with createAndroidComposeRule.
Hilt builds and checks the whole graph at compile time; Koin resolves dependencies at runtime from a registry, which is simpler to set up but moves a missing binding from the build to the running app.
Hilt
Code generation with KSP: a missing, duplicated or wrongly scoped binding fails the build.
No reflection or lookup at runtime; first-party integration with ViewModel, Navigation, Compose, WorkManager and tests.
More annotations to learn, and build time grows with the graph. Android-only.
Koin
Modules are plain Kotlin functions (single, factory, viewModel); nothing to generate.
Runtime resolution: a missing definition throws NoDefinitionFoundException when that screen first opens. Cover it with a verify() test from koin-test, or use Koin Annotations with KSP and the KOIN_CONFIG_CHECK option to fail the build instead.
Multiplatform: the same modules work in a shared KMP module, where Hilt cannot go.
Calling by inject() inside a class is service-locator style; passing get() into constructors in the module keeps the classes themselves DI-friendly.
Choosing
Android-only app, large graph, many feature modules → Hilt for compile-time guarantees.
Shared KMP code → Koin, kotlin-inject or Metro.
Small app or prototype → Koin, or plain manual DI.
If every class takes its dependencies through the constructor, switching frameworks later touches only the wiring.
Dagger writes a factory per binding and the component implementations, and the Hilt Gradle plugin rewrites each @AndroidEntryPoint class to extend a generated Hilt_ base class that performs the injection. Nothing is resolved by reflection at runtime.
What lands in build/generated
A Foo_Factory for every @Inject constructor, plus a Foo_MembersInjector for every class with injected fields.
App_HiltComponents with one nested component per level: SingletonC, ActivityRetainedC, ActivityC, FragmentC, ViewModelC. Scoped bindings are wrapped in DoubleCheck (double-checked locking); @Reusable gets SingleCheck, which caches without a lock.
Hilt_MainActivity, which registers an OnContextAvailableListener and injects when it fires, inside super.onCreate(). You write MainActivity : AppCompatActivity(); the plugin's bytecode transform swaps the superclass, so you never extend the generated class by hand.
Why it is built this way
A missing or duplicated binding becomes a build error, not a crash on a screen nobody tested.
No reflection: R8 sees the whole graph and can inline and strip it, and startup pays nothing to build a container.
The price is build time (the app module regenerates when any binding changes) and a graph fixed at compile time, which is why runtime values need @AssistedInject or @BindsInstance.
ActivityRetainedComponent survives rotation because Hilt stores it in a ViewModel in the Activity's ViewModelStore.
A subcomponent is generated inside its parent and inherits every parent binding; a component dependency is a separately generated component that sees only what the parent explicitly exposes through provision methods.
Subcomponents
Declared with @Subcomponent; the parent registers it with @Module(subcomponents = ...), exposes its factory, and the generated class is nested in the parent's implementation.
The child sees all parent bindings and can add to the parent's multibound Set and Map.
The parent must know the child when its code is generated, so both are generated together in the module that owns the root. Hilt's whole tree is built this way.
Component dependencies
@Component(dependencies = [CoreDeps::class]): the child's factory takes an instance of CoreDeps, which can be a component or any interface, often a Hilt @EntryPoint.
Only provision methods cross the boundary. That is boilerplate, but also an explicit, reviewable API between modules.
Each component is generated in its own Gradle module, so a feature's graph rebuilds without regenerating the app's.
Multibindings do not merge across the boundary.
Choosing
Nested lifetimes in one app graph → subcomponents, which in practice means Hilt.
Dynamic feature modules, which the base app cannot see at compile time, and SDKs that ship their own graph → component dependencies. This is the documented way to use Dagger in a dynamic feature module of a Hilt app.
A @Singleton outlives the user session: the repository that cached user A's orders is the same object user B gets, because nothing in the graph is tied to being logged in.
Two ways to fix it
A session component. @DefineComponent(parent = SingletonComponent::class) with your own @SessionScope, and a @DefineComponent.Builder that takes the User via @BindsInstance. A @Singleton manager builds the component on login and drops it on logout, so every session-scoped object becomes garbage. The catch: @AndroidEntryPoint cannot inject from a custom component, so its objects come out through an @EntryPoint you call explicitly.
Explicit teardown. Keep everything @Singleton and have logout call clear() on each cache. Much less machinery, but it is a list someone must remember to extend, and the next cache leaks user data until a tester notices.
The option that removes the problem
Keep no user state in memory: key Room tables by user id, read the session as a Flow from DataStore, and let the change propagate through the data layer. Logout becomes one write and there is nothing to invalidate.
The rule underneath: a scope is a claim about lifetime. If data belongs to a session, @Singleton promises it lasts as long as the process, and Dagger keeps that promise.
Hilt collects every @InstallIn module and entry point on the app's classpath and generates all components once, in the application module, which is why that module becomes the build bottleneck as the graph grows.
What follows from that
Any library module can declare bindings; adding it to the app's dependencies is enough to add them to the graph. Nothing is registered by hand.
A binding change anywhere invalidates Hilt's generation in the app module, so incremental builds slow down with graph size. Keep the app module thin, mostly wiring, so little else recompiles after it.
Run hilt-compiler under KSP. kapt first generates Java stubs for every module, which adds a slow step to every build.
How to structure it
Feature modules expose interfaces and bind implementations in their own Hilt modules, so consumers compile against the interface only.
Watch where you install: @InstallIn(SingletonComponent::class) in a feature module makes that binding process-global. Prefer ViewModelComponent or a narrower component when the binding belongs to a screen.
Put shared fakes in a :testing module with @TestInstallIn, so every module's tests get the same replacements.
Dynamic feature modules cannot contribute to the Hilt graph at all; they use component dependencies.
Hilt is Android-only. A shared KMP module uses constructor injection, Koin, kotlin-inject or Metro, and Hilt binds the result at the Android edge.
Dagger's costs are build time, a Java-centred code generator and an Android-only Hilt layer; Metro (a Kotlin compiler plugin) and kotlin-inject (KSP) keep compile-time checking while running inside the Kotlin toolchain and supporting Multiplatform.
What hurts with Dagger
Build time: annotation processing plus compiling the generated Java, and with Hilt a full regeneration in the app module on any binding change. kapt makes it worse; Dagger under KSP helps but still generates Java.
Error messages: long request chains that take practice to read.
Kotlin friction: @JvmSuppressWildcards on generic multibindings, object and companion object modules.
No KMP: a shared module cannot use Dagger or Hilt.
The alternatives
Anvil: Square's Dagger extension that merges @ContributesTo and @ContributesBinding modules automatically. Anvil is deprecated: K1-only, and Square migrated its own Android code to Metro.
Metro: a Kotlin compiler plugin with Dagger-like semantics and Anvil-style aggregation. It generates no source files to compile, works in KMP, and can interoperate with Dagger annotations to migrate gradually.
kotlin-inject: KSP-based, Kotlin-first, Multiplatform; kotlin-inject-anvil adds contribution merging and is in maintenance mode.
When to move
An Android-only app on Hilt with KSP has no urgent reason: you would give up @HiltViewModel, @HiltWorker and Hilt's test APIs and wire those yourself. The case is real when the graph is shared with KMP or when DI generation dominates build profiles. Migrate module by module, and measure before and after.