Android Dependency Injection
Hilt, Dagger, Koin, scopes, modules, testability
- 01
What is dependency injection and why use it on Android?
EasyDependency injection (DI) means giving an object its collaborators from outside, rather than letting it construct or look them up itself.
// Manual DI — fine for small projects class UserRepo(private val api: UserApi, private val dao: UserDao) class UserViewModel(private val repo: UserRepo) : ViewModel() // Composition root in MyApp.onCreate class MyApp : Application() { … - 02
What is Hilt? How does it differ from Dagger 2?
MediumHilt is Google's official DI library for Android, and it is Dagger 2 with the component tree already written for you.
// Application @HiltAndroidApp class MyApp : Application() // Module — provide bindings @Module … - 03
What are scopes in Hilt and how do they map to component lifecycles?
MediumA Hilt scope pins a binding to the lifetime of one Android component, from the whole application down to a single ViewModel.
@Module @InstallIn(SingletonComponent::class) object AppModule { @Provides @Singleton fun provideAnalyticsClient(): AnalyticsClient = ProdAnalyticsClient() } … - 04
What are @Qualifier and multibinding in Dagger/Hilt?
MediumA
@Qualifiertells Dagger which of several bindings of the same type you mean, and multibinding collects many bindings into oneSetorMap.// Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AuthClient @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher … - 05
How do you replace dependencies in tests with Hilt?
MediumHilt provides the
@HiltAndroidTestrule and@TestInstallIn/@UninstallModulesto swap real bindings for fakes in instrumented tests.// Production @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideUserApi(): UserApi = realApi } … - 06
Hilt vs Koin — when to choose which?
MediumHilt buys compile-time verification of the whole object graph; Koin buys a plain Kotlin DSL and no code generation.
// Hilt — annotation-driven @HiltViewModel class MyVM @Inject constructor(private val repo: Repo) : ViewModel() @Module @InstallIn(SingletonComponent::class) … - 07
When do you use
@Bindsinstead of@Provides?Medium@Bindssays one type is satisfied by an existing binding of another type, so Dagger generates nothing but an alias;@Providesruns a method body you wrote.interface AnalyticsTracker { fun log(event: String) } class FirebaseTracker @Inject constructor( private val firebase: FirebaseAnalytics ) : AnalyticsTracker { override fun log(event: String) = firebase.logEvent(event, null) … - 08
The system creates Activities and Fragments, so Hilt cannot use their constructors. How does it inject them?
EasyHilt falls back to field injection for classes the framework instantiates, and
@AndroidEntryPointis what makes it happen.@AndroidEntryPoint class MainActivity : AppCompatActivity() { @Inject lateinit var analytics: AnalyticsTracker // not private private val vm: MainViewModel by viewModels() // @HiltViewModel … - 09
A detail screen's ViewModel needs the
orderIdit was opened with. Where does that value come from, given the DI graph has never heard of it?HardA value known only at runtime cannot be a binding, so it either arrives through
SavedStateHandleor is handed in by an assisted factory.class OrderLoader @AssistedInject constructor( @Assisted private val orderId: String, // runtime value private val repo: OrderRepository // from the graph ) { suspend fun load(): Order = repo.load(orderId) … - 10
What do
Provider<T>andLazy<T>give you, and how does one of them break a dependency cycle?MediumBoth defer construction until you call
get();Providerhands you a new instance every call,Lazybuilds once and caches.class ReportGenerator @Inject constructor( private val db: Lazy<AppDatabase>, // opened on first get(), then cached private val sessions: Provider<Session> // a fresh Session per call ) { fun run(): Report { val dao = db.get().reports() // DB opened only if run() is called … - 11
How do you inject into a class Hilt does not own — a WorkManager
Worker, a ContentProvider, or an object a third-party library constructs?MediumHilt has a dedicated integration for Workers, and an
@EntryPointescape hatch for everything else.@HiltWorker class SyncWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, private val repo: FeedRepository ) : CoroutineWorker(appContext, params) { … - 12
In a Gradle project with forty modules, where does Hilt actually assemble the graph, and what does that do to build times?
HardHilt aggregates every
@InstallInmodule in the whole project and generates the components once, in the app module — which is exactly why that module becomes the bottleneck.interface OrderRepository { suspend fun load(id: String): Order } // :feature:orders:api @Module // :feature:orders:impl @InstallIn(SingletonComponent::class) abstract class OrderModule { @Binds abstract fun bindRepo(impl: OrderRepositoryImpl): OrderRepository … - 13
A
@Singletonthumbnail cache needs aContext— which one does Hilt hand you, and what breaks if you keep an Activity's?EasyHilt binds two of them behind qualifiers —
@ApplicationContextlives as long as the process,@ActivityContextdies with the Activity — and only the first one is legal inside a@Singleton.// WRONG — the graph is fine, the leak is hand-written @Singleton class ThumbnailCache @Inject constructor() { private var context: Context? = null fun attach(activity: Activity) { context = activity } // retained for the whole process } … - 14
A repository test passes on your laptop and times out on CI; the repository calls
withContext(Dispatchers.IO)itself — what do you change?EasyHard-coded dispatchers are dependencies in disguise: inject a
CoroutineDispatcherbehind a qualifier so a test can hand the repository aTestDispatcherand control time.@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher @Qualifier @Retention(AnnotationRetention.BINARY) annotation class ApplicationScope @Module @InstallIn(SingletonComponent::class) object CoroutinesModule { … - 15
The build fails with
[Dagger/MissingBinding] PaymentGateway cannot be provided without an @Provides-annotated method— how do you read that?MediumDagger is telling you it reached a type it has no recipe for, and the request chain printed under the message says which component asked and through whom.
// The error, trimmed: // // [Dagger/MissingBinding] PaymentGateway cannot be provided without an // @Provides-annotated method. // PaymentGateway is injected at // CheckoutRepository(gateway, ...) … - 16
Two composable destinations in one
NavHosteach callhiltViewModel<CartViewModel>()and get different instances — what decides that?MediumhiltViewModel()resolves the nearestViewModelStoreOwnerfromLocalViewModelStoreOwner, and inside aNavHosteach destination'sNavBackStackEntryis its own owner — so one instance per destination, cleared when that entry is popped.@HiltViewModel class CartViewModel @Inject constructor( private val cart: CartRepository, private val state: SavedStateHandle ) : ViewModel() … - 17
A Koin app crashes with
NoDefinitionFoundExceptionon a screen QA rarely opens — how do you turn that into a build or test failure?MediumKoin resolves at runtime by type key, so a missing definition only surfaces when that code path runs — the fix is to verify the modules in a test, or move to Koin Annotations and let KSP check at compile time.
val dataModule = module { singleOf(::AuthInterceptor) single { OkHttpClient.Builder().addInterceptor(get<AuthInterceptor>()).build() } singleOf(::OrderRepositoryImpl) { bind<OrderRepository>() } factoryOf(::OrderFormatter) // a new instance per get() viewModelOf(::OrderViewModel) // SavedStateHandle comes from the platform … - 18
The Memory Profiler shows nine live
OkHttpClientinstances in an app whose module provides exactly one — what went wrong?MediumAn unscoped binding is a recipe, not an instance: Dagger runs the
@Providesmethod again for every injection point, so nine consumers get nine clients.@Module @InstallIn(SingletonComponent::class) object NetworkModule { // WRONG (before) — no scope, so Dagger re-runs this for every injection point // @Provides … - 19
You never wrote a class called
Hilt_MainActivity, yet it shows up in stack traces — what do Hilt and Dagger actually generate at build time?HardEffectively everything: Dagger writes a factory per binding and the component implementations, and the Hilt Gradle plugin rewrites each
@AndroidEntryPointclass's superclass to a generatedHilt_base that performs the injection.// You write: @AndroidEntryPoint class MainActivity : AppCompatActivity() { @Inject lateinit var analytics: AnalyticsTracker } … - 20
After logging out and signing in as a different user, the app briefly shows the previous user's orders — what does the DI graph have to do with it?
HardA
@Singletonoutlives the user session: the repository that cached user A's orders is the very same object user B is handed, because nothing in the graph is tied to "being logged in".@Scope @Retention(AnnotationRetention.RUNTIME) annotation class SessionScope @DefineComponent(parent = SingletonComponent::class) interface SessionComponent @DefineComponent.Builder …