Dagger Fundamentals & Manual DI Explained
ARCHITECTURE › Dependency Injection
Hilt is Dagger with Android-specific defaults baked in, this lesson is about the layer underneath: **plain Dagger**. The two foundational pieces are the same @Inject you already know, and @Component, an interface that declares a dependency graph:
class UserRepository @Inject constructor(private val api: UserApi)
@Component
interface ApplicationGraph {
fun repository(): UserRepository
}
At build time, Dagger reads that interface and generates a real implementation, conventionally named DaggerApplicationGraph, plus a factory for every type it knows how to build. No reflection, no runtime scanning:
val graph: ApplicationGraph = DaggerApplicationGraph.create()
val repo = graph.repository()
That generated class is the entire dependency graph, made concrete and inspectable.
Just like in Hilt, @Inject only works when you own the constructor. For interfaces or types you don't own, like Retrofit, you write a @Module:
@Module
object NetworkModule {
@Provides
fun provideRetrofit(): Retrofit =
Retrofit.Builder().baseUrl("https://api.example.com/").build()
}
@Binds stays the leaner option for a straight interface-to-implementation mapping, an abstract method with no body:
@Module
abstract class RepositoryModule {
@Binds
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
A plain Dagger @Component then lists which modules feed its graph: @Component(modules = [NetworkModule::class, RepositoryModule::class]).
Two bindings of the same type collide unless you disambiguate them. Dagger's built-in qualifier is @Named, tag each binding with a string and tag the matching injection site the same way:
@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("plain") private val client: OkHttpClient
)
Custom @Qualifier annotations (which is what Hilt's @ApplicationContext and your own @AuthClient-style annotations are built from) do the same job with a real type instead of a string, catching typos at compile time instead of at injection time.
The headline advantage of Dagger's approach: **the graph is validated at compile time**. Every dependency must be satisfiable and cycle-free, or the build fails with a specific error naming the missing binding:
error: [Dagger/MissingBinding] MissingService cannot be
provided without an @Inject constructor or an @Provides-annotated method.
Contrast that with Koin, a popular runtime alternative: it's a Kotlin DSL service locator, no annotation processing, faster builds, but a missing registration only surfaces as a RuntimeException the moment get() actually resolves it, possibly deep into a user's session. Dagger trades a bit of build-time cost and boilerplate for catching that class of bug before the app ever ships.
Plain Dagger scopes work the same way Hilt's do, @Singleton (or any custom scope annotation) ties an instance's lifetime to *its component*, not to the whole JVM process:
@Singleton
class UserRepository @Inject constructor(private val db: AppDatabase)
@Singleton
@Component(modules = [DatabaseModule::class])
interface AppComponent {
fun userRepository(): UserRepository
}
A second, independently created AppComponent (say, in a test) would hold its **own** UserRepository, scopes are per-component, not global. @Subcomponent builds shorter-lived child graphs that inherit everything from their parent, ideal for a login flow or a checkout flow you want to spin up and then throw away, releasing every object it created, when the flow ends.
Not every app needs a framework. **Manual DI** means plain constructor injection, pass dependencies in through the constructor, plus a hand-written container class that centralizes the shared instances:
class AppContainer {
val database = AppDatabase.build()
val userRepository = UserRepository(database.userDao())
}
class MyApp : Application() {
val container = AppContainer() // one instance, held by Application
}
AppContainer is a regular class, not a singleton object, that distinction matters for testing, you can construct a fresh, fake one per test instead of fighting a global. Shorter-lived dependencies get their own **flow container** (a LoginContainer, say), created when the flow starts and discarded when it ends, mirroring what a Dagger @Subcomponent gives you automatically.
Knowing when a framework is the *wrong* choice is as interview-relevant as knowing the annotations. Hilt is Android-specific, it hooks into Application, Activity, and friends, so it's a poor fit for:
- **Kotlin Multiplatform shared code**, which has no Android Application or Activity to hook into - **Standalone libraries**, which shouldn't force a DI framework choice onto every consumer - **Tiny apps**, where a two-class AppContainer is less overhead than Hilt's setup and build-time cost
Manual DI (or plain Dagger, without the Android-specific layer) works in all of those, because it's 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.
One more nuance worth knowing: Dagger fails the build on a genuine cycle, A needs B, B needs A, constructed eagerly. The fix isn't to merge the classes, it's to defer one side's construction by injecting Provider<T> or Lazy<T> instead of the type directly:
class A @Inject constructor(
private val bProvider: Provider<B> // built lazily, breaks the cycle
) {
fun doWork() = bProvider.get().helpA()
}
class B @Inject constructor(private val a: A)
bProvider.get() doesn't run until doWork() calls it, by which point A already fully exists, so B's constructor can safely receive it. It's the same compile-time-first philosophy running through this whole lesson: Dagger would rather force you to be explicit about ordering than silently stack-overflow at runtime.
Every Dagger binding so far has been something Dagger builds itself, but some values only exist at runtime: the Application instance, a userId read from an Intent, nothing you could hand Dagger at compile time. @Component.Factory (the modern replacement for the older @Component.Builder) plus @BindsInstance solves this: mark a factory parameter @BindsInstance and Dagger feeds that exact object straight into the graph instead of trying to construct it.
@Component(modules = [AppModule::class])
interface AppComponent {
@Component.Factory
interface Factory {
fun create(@BindsInstance application: Application): AppComponent
}
}
val component = DaggerAppComponent.factory().create(this)
Anything downstream can now inject Application directly, as if a @Provides method existed for it, without you writing one.
Sometimes you don't want one binding, you want several modules to each contribute an entry into a single collection Dagger assembles for you. 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<T>, @IntoMap does the same for key-value pairs.
@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>
)
Analytics never names FirebaseTracker or MixpanelTracker, it just asks for Set<Tracker> and gets whatever every module contributed, a plugin-style registry with zero central wiring.
Constructor injection assumes Dagger can supply every parameter itself, but sometimes one parameter is only known at the call site, a trackId picked by the user, not a dependency Dagger could ever look up. **Assisted injection** handles that split: mark the constructor @AssistedInject, tag the runtime-only parameter @Assisted, and Dagger generates a factory interface you annotate @AssistedFactory.
class PlayerViewModel @AssistedInject constructor(
private val repository: PlayerRepository,
@Assisted val trackId: String
) {
@AssistedFactory
interface Factory {
fun create(trackId: String): PlayerViewModel
}
}
You inject PlayerViewModel.Factory like any other Dagger type, then call factory.create(trackId) wherever you actually have the trackId, Dagger fills in repository from the graph and passes your runtime value straight through.