Chapter 2 — Dependency Injection by Hand
Chapter 1 ended on a question we deliberately left open. Once classes stop constructing their own dependencies, someone still has to construct them — build the real OkHttpClient, the real database, the real sources, assemble the PlantRepository, and hand it to whatever needs it. We moved that work; we didn't delete it. So where does it go?
This chapter answers that with our own two hands. We're going to wire the whole of Verdant's graph without Hilt, without Koin, without a single annotation. That's not busywork. When you reach for a framework in Part 2, you'll know precisely what it's doing on your behalf, because you'll have done it yourself first. Frameworks feel like magic only to people who never saw the trick performed slowly.
By the end you'll have a working manual DI setup, a hand-rolled container, and a Service Locator built beside it — plus a clear-eyed understanding of why one of those two is the right default and the other is a trap that keeps looking like a shortcut.
2.1 The Composition Root
Start with the principle, because it reframes everything.
If no class builds its own dependencies, then the knowledge of which concrete implementations to use has to live somewhere. The best place for it is a single location, as close to the program's entry point as possible, where the entire object graph is assembled from the leaves up. That location has a name:
The composition root is the one place in an application where the concrete implementations are chosen and the object graph is wired together. Ideally there is exactly one, and it sits at the entry point.
Every class below the composition root stays blissfully ignorant. PlantRepository never learns that its RemotePlantSource is Retrofit-backed. The ViewModel never learns which repository it got. Only the root knows the concrete truth, and it hands finished objects downward. The coupling from Chapter 1 didn't vanish — DI can't make it vanish — it collected into one honest, centralized place instead of being smeared across dozens of classes.
On Android, the natural composition root is the Application class. It's created once, before any Activity, and it lives as long as the process. That makes it the right home for the graph.
2.2 Wiring Verdant by Hand
Let's build it. First, the concrete implementations of the interfaces we designed in Chapter 1 — the details that the abstractions point at:
class RetrofitPlantSource(
private val api: PlantApi,
) : RemotePlantSource {
override suspend fun fetchPlant(id: PlantId): Plant =
api.fetchPlant(id).toDomain()
}
class RoomPlantSource(
private val dao: PlantDao,
) : LocalPlantSource {
override suspend fun get(id: PlantId): Plant? = dao.findById(id.value)?.toDomain()
override suspend fun put(plant: Plant) = dao.upsert(plant.toEntity())
}
Now the composition root. We'll gather the wiring into a dedicated class — an AppContainer — so the Application stays tidy and the graph lives in one readable place:
class AppContainer(context: Context) {
// --- Leaves: third-party infrastructure we don't own ---
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder().build()
}
private val plantApi: PlantApi by lazy {
Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(kotlinxConverterFactory())
.build()
.create(PlantApi::class.java)
}
private val database: PlantDatabase by lazy {
Room.databaseBuilder(context, PlantDatabase::class.java, "verdant.db").build()
}
// --- Our own abstractions, satisfied by concrete implementations ---
private val remoteSource: RemotePlantSource by lazy { RetrofitPlantSource(plantApi) }
private val localSource: LocalPlantSource by lazy { RoomPlantSource(database.plantDao()) }
// --- The thing the rest of the app actually asks for ---
val plantRepository: PlantRepository by lazy {
PlantRepository(remoteSource, localSource)
}
}
And the Application that owns it:
class VerdantApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
Read the container top to bottom and you're reading the object graph itself. The leaves — okHttpClient, database — are the infrastructure we don't own. They feed the sources, the sources feed the repository, the repository is what the UI reaches for. Dependencies point downward; construction flows upward from the leaves.
Two small but important choices are hiding in that code:
by lazygives us single instances. Each dependency is built the first time it's touched and then reused.okHttpClientis created once and shared by everything downstream — exactly what you want for an expensive, thread-safe client. This is a singleton lifetime, implemented with nothing but a Kotlin delegate. Hold that thought; Chapter 3 names it.- The container only wires. It contains no business logic, no policy, no branching beyond build config. Its single job is assembly. The moment a container starts making decisions, it stops being a composition root and starts being a god object.
2.3 The Android Problem, Up Close
We have a repository. Now the ViewModel needs it. In Chapter 1 our ViewModel built its own repository; now it should receive one:
class PlantDetailViewModel(
private val plantId: PlantId, // a runtime value
private val repository: PlantRepository, // an injected dependency
) : ViewModel() {
// ... same StateFlow-driven logic as Chapter 1
}
Here we hit the wall Chapter 1 warned about. You do not call this constructor. The Android framework instantiates ViewModels through a ViewModelProvider, at a moment of its choosing, so it can survive configuration changes. Constructor injection assumes you're holding the new. For a ViewModel, you aren't.
The manual escape hatch is a factory — an object the framework will call, which closes over the dependencies and constructs the ViewModel for it:
class PlantDetailViewModelFactory(
private val plantId: PlantId,
private val repository: PlantRepository,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
PlantDetailViewModel(plantId, repository) as T
}
And at the call site, we reach into the composition root through the Application and hand the factory over:
@Composable
fun PlantDetailScreen(plantId: PlantId) {
val app = LocalContext.current.applicationContext as VerdantApp
val viewModel: PlantDetailViewModel = viewModel(
factory = PlantDetailViewModelFactory(plantId, app.container.plantRepository),
)
// ...
}
It works. It's also the first genuinely irritating thing we've written. Look at what this one screen costs: a hand-written factory class, a cast we had to suppress, and a call site that has to know the Application is a VerdantApp, reach into its container, and pluck out the exact dependency. Now imagine forty screens. Every ViewModel needs its own factory. Every factory needs threading the right slice of the graph. This is boilerplate that grows linearly with your app and never gets more interesting.
That factory also taught us something worth naming. The ViewModel needed a mix of a runtime value (plantId, known only when the screen opens) and an injected dependency (repository, known at assembly time). A plain constructor injection can't supply the runtime half, and the container can't supply it either, because the container is built long before any plantId exists. Whenever you need to combine "known at startup" with "known at call time," you need a factory. This exact pattern — assisted construction — is something the frameworks give first-class support for later; here we're doing it the long way.
2.4 Scoped Containers
AppContainer holds things that live as long as the app. But not everything should. Suppose Verdant gains a "garden session" — a temporary mode where the user arranges plants, and we want a shared GardenSessionState that exists only while that flow is open and is thrown away when they leave.
Putting it in AppContainer would be wrong: it would live forever, holding memory long after the session ended, and it might leak references to a screen that's gone. What we want is a shorter-lived container, created when the flow starts and released when it ends:
class GardenSessionContainer(
private val appContainer: AppContainer, // parent graph, for shared deps
) {
val sessionState: GardenSessionState by lazy { GardenSessionState() }
val arrangePlantsUseCase: ArrangePlantsUseCase by lazy {
ArrangePlantsUseCase(appContainer.plantRepository, sessionState)
}
}
The session container borrows long-lived dependencies from its parent (plantRepository) and adds its own short-lived ones (sessionState). When the flow closes, you drop your reference to the GardenSessionContainer, and everything it uniquely owns becomes garbage. The parent survives; the child doesn't.
You've just hand-built a scope: a boundary that controls how long a set of objects lives, nested inside a wider boundary. Getting these boundaries right — wide enough to share, narrow enough to release — is one of the central skills of DI, and Chapter 3 gives it proper treatment. For now, notice that you can express it with plain objects and references. Nothing magic.
2.5 A Tempting Detour: the Service Locator
There's a pattern that looks like it solves the boilerplate from §2.3, and it's worth building so you understand exactly why we won't rely on it. Instead of pushing dependencies into classes, we expose a global registry that classes pull from:
object ServiceLocator {
lateinit var appContainer: AppContainer
fun plantRepository(): PlantRepository = appContainer.plantRepository
}
Initialize it once in the Application:
class VerdantApp : Application() {
override fun onCreate() {
super.onCreate()
ServiceLocator.appContainer = AppContainer(this)
}
}
And now any class, anywhere, can grab what it needs without a factory, without threading anything through a constructor:
class PlantDetailViewModel(
private val plantId: PlantId,
) : ViewModel() {
private val repository = ServiceLocator.plantRepository() // reach out and grab it
// ...
}
Look how much plumbing evaporated. No factory, no reaching into the Application at the call site, no passing the repository down through layers. For about thirty seconds this feels like the answer.
It isn't, and the reason is precisely the disease we cured in Chapter 1. Look at the constructor again: it takes only plantId. It has become a liar once more. It reaches out to a global for its repository, so the dependency is invisible in the signature, undiscoverable without reading the body — the exact opacity we spent Chapter 1 eliminating. We didn't remove the coupling; we hid it and made it global.
The costs are concrete:
- Hidden dependencies. You cannot know what a class needs by looking at its API. The need is buried in the implementation.
- Global mutable state.
ServiceLocatoris a singleton with alateinit var. Any test that runs before it's initialized crashes; any test that swaps its contents pollutes every test that follows unless you carefully reset it. - Testability regresses. To test the ViewModel you must populate the global locator with a fake before construction and tear it down after — action at a distance, in every test.
- Runtime failures. Ask the locator for something it doesn't have and you find out when the code runs, not when it compiles.
The distinction underneath all of this is simple and worth memorizing:
| Dependency Injection | Service Locator | |
|---|---|---|
| Direction | Dependencies are pushed in from outside | Dependencies are pulled out of a registry |
| Visibility | Declared in the constructor signature | Hidden inside the method body |
| Coupling | To an abstraction, passed by the root | To the global locator itself |
| Testing | Pass a fake to the constructor | Mutate global state before/after each test |
| Failure mode | Missing dependency is visible at the wiring site | Missing dependency surfaces at runtime |
Both hand a class its collaborators from elsewhere, so they're easy to confuse. The difference that matters is push versus pull, and with it visible versus hidden. Real dependency injection keeps the constructor honest. A service locator quietly undoes that. This is why, when the docs and this book say "DI," they mean the push model — and why the frameworks in Parts 2 and 3 are injection frameworks, not glorified service locators.
2.6 Why This Doesn't Scale
Everything in this chapter works. You could ship Verdant on exactly this setup. So why does an entire industry reach for Hilt and Koin? Because the manual approach degrades as the app grows, along predictable lines:
- Factory sprawl. Every framework-instantiated class — every ViewModel, every Worker — needs a hand-written factory that threads the right dependencies through. That's boilerplate proportional to your feature count.
- Wiring order and plumbing. As the graph deepens, the container grows into a long, carefully ordered list of
by lazydeclarations. Insert a dependency in the middle of a chain and you're editing several links by hand. - Passing dependencies through layers that don't want them. Sometimes a deep object needs something only the root can provide, and you find yourself passing it down through three classes that don't use it, just to reach the one that does.
- No verification. Nothing checks that your graph is complete and consistent. Forget to wire something and you learn about it from a
NullPointerExceptionor a crash on a specific screen. There's no tool telling you a binding is missing. - Scope management by discipline. Nesting containers correctly, releasing them at the right moment, never leaking a short-lived object into a long-lived one — all of it rests on you remembering to do it right, every time.
None of these is fatal alone. Together, in an app with dozens of features and a team of engineers, they add up to a meaningful, ongoing tax. A DI framework's entire pitch is to automate this specific work: generate the factories, resolve the wiring order, manage the scopes, and — for at least one of our two frameworks — verify the whole graph before the app ever runs.
2.7 Pitfalls & Misconceptions
Scattering mini composition roots through the app. The value of a composition root is that it's one place. If classes deep in the tree start
new-ing up their own collaborators "just for this one case," you've fragmented the root and reintroduced the coupling. Keep assembly at the top.Letting the container hold logic. A composition root wires; it does not decide. Business rules, branching, and policy belong in the classes being wired, not in the container. A container with
ifstatements about user state is a design smell.Confusing a container with a service locator. An
AppContaineryou pass to code (or read once at the composition root to build a factory) is fine — that's still injection. The same container becomes a service locator the moment classes reach into a global to pull from it. The object can be identical; what makes it a locator is the direction of the arrow.Over-widening scope. Putting a short-lived object in
AppContainerbecause it's convenient is how you leak memory and hold stale references. Default to the narrowest scope that still lets the object be shared where it needs to be.Assuming manual DI is "too primitive" for real apps. It isn't — plenty of production apps use exactly this. Frameworks buy you automation and verification, not correctness you couldn't otherwise achieve. Understanding that keeps you from treating Hilt as a magic requirement rather than a labor-saving tool.
2.8 What's Ahead
You've now wired a real object graph by hand, met the composition root, built scoped containers, and seen — by building it — why the service locator's convenience is a false economy. You've also collected a list of pains that a framework promises to remove.
Before we meet those frameworks, Chapter 3 pauses to name what we've been doing. Every problem in this chapter has a proper term: the object graph, bindings, scopes and lifetimes, qualifiers, and the framework-instantiation seam we hit in §2.3. Once you have that vocabulary, the Hilt and Koin documentation stops reading like a foreign language — because you'll recognize every concept as something you already built with your own hands.
Exercise. Extend AppContainer to add a second feature to Verdant: a CareLogRepository that depends on the same PlantDatabase (reuse the existing instance — don't build a second one) and a new LocalCareLogSource. Then write a CareLogViewModel that takes the repository, and the factory to construct it. When you're done, count the lines you had to write purely to plumb the dependency through — no logic, just wiring. That number is the tax Chapter 4 starts paying down.