Dagger Fundamentals & Manual DI Explained
ARCHITECTURE › Dependency Injection
Hilt is Dagger with Android specific defaults layered on top. This lesson works one level down, at plain Dagger, because interviewers use it to check whether you understand what Hilt is actually doing or whether you have just memorized which annotation goes where.
Two annotations do the heavy lifting. @Inject on a constructor tells Dagger how to build that class and what it needs. @Component is an interface that declares a dependency graph, a list of types the graph knows how to produce.
At build time, Dagger reads that interface and writes a real class, conventionally named DaggerApplicationGraph, along with a factory for every type it can build. Nothing is reflective and nothing happens at startup by scanning annotations. The generated class is the graph, made concrete, and you can open it and read exactly how each object gets built.
class UserRepository @Inject constructor(private val api: UserApi)
@Component
interface ApplicationGraph {
fun repository(): UserRepository
}
// Dagger generates DaggerApplicationGraph at build time
val graph: ApplicationGraph = DaggerApplicationGraph.create()
val repo = graph.repository()
@Inject only works when you own the constructor. For interfaces or third-party types like Retrofit, you can't add an annotation to their constructor, so Dagger needs another way in: a @Module class with a @Provides method that builds the instance yourself.
For the common case of mapping an interface straight to one implementation, @Binds is the leaner option. It's an abstract method with no body, you just declare that an implementation satisfies an interface, and Dagger's generated code skips the extra indirection a @Provides method would produce. Reach for @Provides when you actually need to write construction logic, and @Binds when you're only pointing at an implementation.
@Module
object NetworkModule {
@Provides
fun provideRetrofit(): Retrofit =
Retrofit.Builder().baseUrl("https://api.example.com/").build()
}
@Module
abstract class RepositoryModule {
@Binds
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
Two bindings of the same type collide. If you have two OkHttpClient instances in your graph, Dagger has no way to tell them apart, and the build fails as ambiguous. A qualifier tags each binding so Dagger knows which one belongs where. The built-in @Named annotation does this with a string: tag the provider and tag the injection site with the same string, and Dagger wires them together.
Custom @Qualifier annotations do the same job with a real type instead of a string, which is what Hilt's own qualifiers like @ApplicationContext are built from, and they catch a typo at compile time instead of leaving it to blow up wherever the string mismatches.
@Module
object NetworkModule {
@Provides @Named("auth")
fun provideAuthClient(): OkHttpClient =
OkHttpClient.Builder().addInterceptor(AuthInterceptor()).build()
@Provides @Named("plain")
fun providePlainClient(): OkHttpClient = OkHttpClient.Builder().build()
}
class ApiClient @Inject constructor(
@Named("auth") private val client: OkHttpClient
)
The headline reason to reach for Dagger is that the graph gets checked at compile time. Every dependency has to be satisfiable, and no cycles are allowed, or the build fails with an error naming the exact missing binding. You find out about a broken graph while you're still writing the code, not months later in production.
Compare that to Koin, a popular runtime alternative built as a Kotlin DSL service locator. Koin does no annotation processing, so its builds are faster, but a missing registration only shows up as a RuntimeException the moment something actually calls get() and resolves it, which might be deep inside a user's session on a device you'll never see. Dagger trades some build time and boilerplate for catching that entire class of bug before the app ships at all.
error: [Dagger/MissingBinding] MissingService cannot be
provided without an @Inject constructor or an @Provides method.
Scoping in plain Dagger works the same way it does under Hilt. @Singleton, or any custom scope annotation, ties an instance's lifetime to its component, not to the whole process. Put @Singleton on a binding and its owning @Singleton component, and you get one shared instance for as long as that component lives. Build a second, independent instance of that same component, in a test, say, and it gets its own separate instance. Scope is per graph, never global.
@Subcomponent builds a shorter-lived child graph that inherits everything the parent already provides. That's the shape you want for a login flow or a checkout flow: create the subcomponent when the flow starts, let it hold flow-scoped instances, and release it when the flow ends so everything it built becomes eligible for garbage collection.
@Singleton
class UserRepository @Inject constructor(private val db: AppDatabase)
@Singleton
@Component(modules = [DatabaseModule::class])
interface AppComponent {
fun userRepository(): UserRepository
}
Dagger fails the build the moment it sees a genuine cycle: class A needs B and B needs A, both built eagerly at construction time. Merging the two classes isn't the fix, and usually isn't even possible. The idiomatic fix is to defer one side, inject a Provider of the type instead of the type itself, or Lazy if you also want the result cached after the first call.
A Provider doesn't build anything until you call get() on it. By the time doWork() actually calls bProvider.get(), A already fully exists as an object, so B's constructor can safely receive a reference to it. It's the same philosophy running through the rest of Dagger: force you to be explicit about ordering, rather than let something silently blow the stack at runtime.
class A @Inject constructor(
private val bProvider: Provider<B>
) {
fun doWork() = bProvider.get().helpA()
}
class B @Inject constructor(private val a: A)
Every binding covered so far is something Dagger builds itself from other bindings. Some values only exist at runtime though: the Application instance, a userId read off an Intent, nothing you could hand Dagger at compile time because it doesn't exist yet. @Component.Factory paired with @BindsInstance solves exactly that: mark a factory parameter @BindsInstance and Dagger feeds that exact object straight into the graph, instead of trying to construct one itself.
Anything downstream can now inject that value directly, as though a @Provides method existed for it, without you ever writing one.
@Component(modules = [AppModule::class])
interface AppComponent {
@Component.Factory
interface Factory {
fun create(@BindsInstance application: Application): AppComponent
}
}
val component = DaggerAppComponent.factory().create(this)
Sometimes you don't want a single binding, you want several modules, possibly written by different people in different feature modules, to each contribute one entry into a shared collection. That's what multibindings are for. Annotate a @Binds or @Provides method with @IntoSet and Dagger merges every contribution across every module into one injectable Set. @IntoMap does the same thing for key-value pairs.
The class that injects the collection never names any of the individual contributors. It just asks for Set<Tracker> and gets whatever every module added, which makes multibindings the natural shape for a plugin-style registry with no central list to maintain.
@Module
abstract class TrackerModule {
@Binds @IntoSet
abstract fun bindFirebase(impl: FirebaseTracker): Tracker
@Binds @IntoSet
abstract fun bindMixpanel(impl: MixpanelTracker): Tracker
}
class Analytics @Inject constructor(
private val trackers: Set<@JvmSuppressWildcards Tracker>
)
Constructor injection assumes Dagger can supply every parameter on its own, but sometimes one parameter is only known at the call site. A trackId a user picked isn't a dependency Dagger could ever look up in advance. Assisted injection is the split for that case: mark the constructor @AssistedInject, tag the runtime-only parameter @Assisted, and Dagger generates a factory interface for you, one you mark @AssistedFactory.
You inject that factory like any other Dagger-provided type, and call create with the runtime value wherever you actually have it. Dagger fills in the graph-provided parameters itself and passes your runtime value straight through to the constructor.
class PlayerViewModel @AssistedInject constructor(
private val repository: PlayerRepository,
@Assisted val trackId: String
) {
@AssistedFactory
interface Factory {
fun create(trackId: String): PlayerViewModel
}
}
Not every app needs a framework at all, and knowing when to skip one is as interview-relevant as knowing the annotations. Manual DI is just constructor injection, pass every dependency in through the constructor, plus a hand-written container class that centralizes the instances shared across the app.
AppContainer is a regular class, not a singleton object accessed through getInstance() calls. That distinction matters for testing: a regular class lets you construct a fresh, fake container per test instead of fighting a global you can't reset. Shorter-lived dependencies get their own flow container, a LoginContainer say, created when a flow starts and thrown away when it ends, which is the hand-rolled version of what a Dagger @Subcomponent gives you for free.
class AppContainer {
val database = AppDatabase.build()
val userRepository = UserRepository(database.userDao())
}
class MyApp : Application() {
val container = AppContainer()
}
// In Activity/Fragment:
val repo = (application as MyApp).container.userRepository
Hilt itself is Android-specific, it hooks directly into Application, Activity, and the rest of the Android component lifecycle to generate its scoping automatically. That's exactly what makes it a poor fit outside of a full Android app. Kotlin Multiplatform shared code has no Application or Activity for Hilt to hook into at all. A standalone library shouldn't force a DI framework choice onto every app that consumes it. And a tiny app can find that a two-class AppContainer is genuinely less overhead than Hilt's setup and build-time cost.
Manual DI, or plain Dagger without the Android-specific layer, works in every one of those cases, because underneath it's all just constructor injection and ordinary classes. Reach for Hilt specifically when you're deep in Android's component lifecycle and want its generated scoping for free, not by default.
Everything in this lesson, the generated DaggerApplicationGraph, the factories, the compile-time errors, all comes from an annotation processor running during the build, not from anything happening at app startup. In a modern Android project that processor runs through KSP, Kotlin's faster compiler-plugin based processing API, with the older KAPT still available as a fallback for cases KSP doesn't yet support. Both Hilt and plain Dagger use this same mechanism.
Whichever backend you pick, the important thing for an interview is the sequence: the processor reads your annotations during compilation, writes ordinary Kotlin or Java source implementing your graph, and the regular compiler then compiles that generated code right alongside your own. There's no reflection at any point, and nothing about the graph is decided while the app is actually running.
// build.gradle.kts
dependencies {
implementation("com.google.dagger:hilt-android:2.51")
ksp("com.google.dagger:hilt-compiler:2.51") // KSP: modern, faster
// kapt("com.google.dagger:hilt-compiler:2.51") // legacy fallback
}