← Back to books

Chapter 1 — The Case for Dependency Injection

You've shipped features. Your app compiles, runs, and does what the ticket asked. By every measure that reaches a user, the code works.

And yet something is off in how it's assembled. You feel it the moment you try to write a unit test and discover you can't instantiate a class without a live network connection. You feel it when a class's constructor takes no arguments but somehow reaches out and touches a database, a Retrofit client, and three singletons on the way to doing its job. You feel it when a "small" change — swap the data source, add a demo build, fake a response — turns into an afternoon of surgery.

None of these are correctness bugs. They're structure bugs. And nearly all of them trace back to a single habit that feels completely natural: letting a class create the things it depends on.

This chapter is about feeling that pain precisely enough to name it, and then watching it dissolve. We won't reach for a framework — not Hilt, not Koin, not anything. By the end you'll have done real dependency injection with nothing but a constructor, and you'll understand exactly what problem the rest of this book is here to automate.

We'll build the whole book around one app: Verdant, a plant-care companion. It reminds you when to water your plants, identifies them from a photo, and keeps a care log. I picked a plant-care app on purpose — it naturally needs every kind of dependency a DI book wants to show off: a remote API, a local database, repositories and use cases you write yourself, ViewModels the framework instantiates, and background workers for watering reminders. We'll wire that same graph two different ways later, once with Hilt and once with Koin. For now, we only need a small corner of it.


1.1 A Feature That Works (and Fights You)

Here's the corner. When a user taps a plant, Verdant shows a detail screen with care instructions. To fill that screen we fetch the plant — from a local cache if we have it, otherwise from the network, caching the result on the way back.

A first, honest attempt looks like this:

data class PlantId(val value: String)

data class Plant(
    val id: PlantId,
    val commonName: String,
    val wateringIntervalDays: Int,
)

class PlantRepository {

    private val api = PlantApi.create()
    private val cache = PlantCache(PlantDatabase.getInstance())

    suspend fun getPlant(id: PlantId): Plant {
        val cached = cache.get(id)
        if (cached != null) return cached

        val fetched = api.fetchPlant(id)
        cache.put(fetched)
        return fetched
    }
}

And the ViewModel that drives the screen:

class PlantDetailViewModel : ViewModel() {

    private val repository = PlantRepository()

    private val _state = MutableStateFlow<PlantUiState>(PlantUiState.Loading)
    val state: StateFlow<PlantUiState> = _state.asStateFlow()

    fun load(id: PlantId) {
        viewModelScope.launch {
            _state.value = runCatching { repository.getPlant(id) }
                .fold(
                    onSuccess = { PlantUiState.Ready(it) },
                    onFailure = { PlantUiState.Error(it.message) },
                )
        }
    }
}

Read it with fresh eyes. There's nothing clever here, nothing a reviewer would flag in a hurry. The caching logic is correct. The ViewModel exposes state the way you'd expect. This is the shape of code that ships in real apps every day.

Now let's try to do three completely ordinary things with it.

Thing one: write a unit test

You want to verify a simple rule — if the plant is in the cache, we don't hit the network. A pure logic check. So you write:

@Test
fun `returns cached plant`() = runTest {
    val repository = PlantRepository()   // <- and here we stop
    // ...
}

That single line already defeats you. Constructing PlantRepository runs PlantApi.create(), which spins up an OkHttp client, and PlantDatabase.getInstance(), which wants an Android Context and a real SQLite file. Your "unit" test now needs a network stack and a device or emulator to even reach the first assertion. You can't seed the cache, because the cache is created privately inside the class where your test can't see it. You can't stop the network call, because the API is created in there too.

The test you wanted to write — fast, isolated, deterministic — is impossible without changing the class.

Thing two: reuse the code with a different data source

Marketing wants a demo build that runs entirely on canned data, no backend required. QA wants a variant pointed at a staging server. An offline-first experiment wants to skip the network entirely.

Every one of those is a different implementation of "where plants come from." But PlantRepository doesn't have a seam for that. The data source isn't a choice the class accepts — it's a decision baked into its body. To get a demo variant you'd copy the class, or thread a boolean flag through it, or start if (BuildConfig.DEBUG)-ing inside the fetch logic. Each option makes the class worse.

Thing three: understand what the class needs

Look at the constructor:

PlantRepository()

It takes nothing. It advertises no dependencies. And it is lying to you — this class needs a network client and a database to function. That information exists, but it's hidden in the body, discoverable only by reading the whole implementation. Multiply that across a codebase and you get a system where you can't reason about what any component requires without opening it up. Dependencies that don't appear in a signature are dependencies you'll rediscover at the worst possible moment, usually in a stack trace.

Three ordinary tasks, three walls. And they're the same wall seen from three angles.


1.2 Naming the Disease: Tight Coupling

The common cause is right there in two lines:

private val api = PlantApi.create()
private val cache = PlantCache(PlantDatabase.getInstance())

PlantRepository doesn't merely use an API and a cache. It creates them — which means it also decides which concrete types to use, and when they come to life. It's not a consumer of these dependencies; it owns them. That ownership is what we call tight coupling: the class is welded to specific implementations, at a specific moment, with no way to intervene from outside.

A quick vocabulary anchor, because we'll use these words for 300 pages:

A dependency is anything a class needs in order to do its job — another object, a service, a client. PlantRepository depends on an API and a cache.

Tight coupling isn't the fact that PlantRepository has dependencies. Every useful class has dependencies. The problem is that it reaches out and constructs them itself. That single act is what makes the class untestable (you can't substitute the real thing), inflexible (you can't vary the implementation), and dishonest (the need never appears in its signature).

Once you learn to see it, you'll spot the tell everywhere: a new in Java, or in Kotlin a constructor call, a .getInstance(), a .create(), an object reference — appearing inside a class that has better things to do than assemble its own collaborators.


1.3 Two Moves to Untangle It

We fix this with two moves. They're independent — each is useful alone — but together they're the whole idea.

Move 1 — Depend on abstractions

Right now PlantRepository depends on the concrete PlantApi and PlantCache. It has no reason to. It doesn't care whether plants arrive over Retrofit, from a JSON file, or from a hand-built fake in a test — it just needs something it can ask for a plant. So let's say exactly that, with interfaces:

interface RemotePlantSource {
    suspend fun fetchPlant(id: PlantId): Plant
}

interface LocalPlantSource {
    suspend fun get(id: PlantId): Plant?
    suspend fun put(plant: Plant)
}

The Retrofit-backed API becomes one implementation of RemotePlantSource. The Room-backed cache becomes one implementation of LocalPlantSource. The repository stops naming the concrete types and starts naming the capabilities it requires.

This is the Dependency Inversion Principle — the "D" in SOLID — and it has two halves worth stating in full, because most people only remember the first:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

The "high-level module" here is PlantRepository, the thing with the caching policy — the logic you actually care about. The "low-level modules" are the network and database mechanics. Before, the policy depended on the mechanics. After, both depend on an interface that sits between them. The arrow of dependency has been inverted: the details now point at the abstraction instead of the other way around. That inversion is the whole name.

Move 2 — Receive dependencies, don't construct them

Abstractions alone aren't enough. If the repository still did RemotePlantSource construction internally — picking the concrete class itself — we'd be right back where we started, just with an extra interface. The second move is to stop constructing and start receiving:

class PlantRepository(
    private val remote: RemotePlantSource,
    private val local: LocalPlantSource,
) {
    suspend fun getPlant(id: PlantId): Plant {
        val cached = local.get(id)
        if (cached != null) return cached

        val fetched = remote.fetchPlant(id)
        local.put(fetched)
        return fetched
    }
}

Look at what changed and what didn't. The getPlant logic — the actual policy, the reason this class exists — is byte-for-byte the same. What changed is the top: the dependencies now arrive through the constructor instead of being manufactured inside it. The class no longer decides which remote source or which local source. It just uses whatever it's handed.

This is Inversion of Control. The class has given up control over the construction of its collaborators, handing that responsibility to whoever creates the class. The old slogan for IoC is "Don't call us, we'll call you" — the object stops calling out to build its world and instead waits to be furnished with it.

And the constructor now tells the truth. PlantRepository(remote, local) states plainly: I need a remote source and a local source, and I will not work without them. The dependencies are visible, required, and substitutable.


1.4 The Payoff: The Three Walls Come Down

Go back to the three ordinary things that hurt. Watch them stop hurting.

The unit test you couldn't write is now trivial. Because the repository accepts its sources, a test can hand it fakes:

class FakeLocalSource(seeded: Plant? = null) : LocalPlantSource {
    private val store = mutableMapOf<PlantId, Plant>()
    init { seeded?.let { store[it.id] = it } }
    override suspend fun get(id: PlantId) = store[id]
    override suspend fun put(plant: Plant) { store[plant.id] = plant }
}

class ExplodingRemoteSource : RemotePlantSource {
    override suspend fun fetchPlant(id: PlantId): Plant =
        error("network should not have been called")
}

@Test
fun `returns cached plant without touching the network`() = runTest {
    val fern = Plant(PlantId("fern-1"), "Boston Fern", wateringIntervalDays = 3)
    val repository = PlantRepository(
        remote = ExplodingRemoteSource(),
        local = FakeLocalSource(seeded = fern),
    )

    val result = repository.getPlant(fern.id)

    assertEquals(fern, result)   // and the exploding remote was never called
}

No emulator. No SQLite file. No OkHttp. The test runs in milliseconds and asserts exactly the rule we cared about — including, via the exploding fake, the negative rule that the network is left alone on a cache hit. That kind of assertion was flat-out unavailable before.

The demo build is now a construction-time choice, not a code change. Pointing Verdant at canned data means supplying a different RemotePlantSource; the repository doesn't know or care:

class CannedRemoteSource : RemotePlantSource {
    override suspend fun fetchPlant(id: PlantId) =
        Plant(id, commonName = "Demo Plant", wateringIntervalDays = 7)
}

And the honesty problem is gone by definition. The constructor is now the class's résumé: everything it depends on is listed, in the open, enforced by the compiler.

That's it. That's dependency injection. You just did it, and you didn't import a thing.


1.5 So What Is Dependency Injection, Really?

The term is heavier than the idea. Strip away the syllables and here is the whole of it:

Dependency injection means giving an object its dependencies from the outside, instead of letting it create them itself.

The "injection" is nothing more exotic than passing values in. When we changed PlantRepository to take remote and local as constructor parameters, we were injecting. There is no step two.

It's worth separating three terms that get blurred together, because we'll keep them straight for the rest of the book:

Term What it is In our example
Dependency Inversion Principle (DIP) A design principle about the shape of your types: depend on abstractions, not concretions. PlantRepository depends on RemotePlantSource, not the Retrofit class.
Inversion of Control (IoC) A broad principle where something other than the object controls construction or flow. "Don't call us, we'll call you." The repository no longer constructs its sources; its creator does.
Dependency Injection (DI) A specific technique that achieves IoC for dependencies: pass them in. PlantRepository(remote, local).

The nesting is: DI is one way to achieve IoC, and it pairs naturally with DIP. You can practice DI without a framework (we just did). You'll see the frameworks in Parts 2 and 3 don't change what DI is — they only automate the wiring when there are hundreds of these classes instead of one.


1.6 The Three Flavors of Injection

We used constructor injection. It's not the only way to get a dependency from the outside, though it is the one you should reach for by default. The three forms:

Form How it looks Use it when
Constructor injection Dependencies are parameters of the constructor. Almost always. It makes dependencies required and visible, and leaves the object fully formed the instant it exists.
Field / property injection Dependencies are assigned to properties after construction. You can't control the constructor — the framework builds the object for you (Android Activity, Fragment).
Method injection A dependency is passed into the specific method that needs it. A collaborator is needed for one operation, not for the object's whole lifetime.

Constructor injection wins by default for a reason that's easy to state: an object built through its constructor is never in a half-initialized state. If the type system says it needs a RemotePlantSource, you cannot create one without providing it. The dependency is required, and requiredness is enforced by the compiler rather than by your memory.

Field injection exists mostly to work around a constraint we're about to run into — and it's a hint about why the frameworks in this book exist at all. Keep it in the back of your mind.


1.7 The Question That Makes All of This Hard

We should be honest about something. We didn't make the work of building an API and a database and a cache disappear. Somebody still has to write:

val repository = PlantRepository(
    remote = RetrofitPlantSource(okHttpClient, baseUrl),
    local = RoomPlantSource(plantDatabase.plantDao()),
)

We moved that work out of the repository. But out to where, exactly? Something, somewhere, has to create the real OkHttpClient, build the real PlantDatabase, wrap them in the real source implementations, assemble the PlantRepository, and then hand that repository to the PlantDetailViewModel — which in turn gets handed to the screen. Each object needs its dependencies constructed first, and those may need their own dependencies constructed first. What you have is a tree — an object graph — and someone has to assemble it from the leaves up.

For one repository this is a two-line inconvenience. For a real app, that graph has hundreds of nodes, shared instances that must be created exactly once, and objects whose lifetimes are tied to screens or to the whole app. Assembling it by hand is possible — we'll do exactly that in Chapter 2, on purpose, so you never treat the frameworks as magic — but it grows tiresome and error-prone fast.

There's a second, sharper problem hiding here, and it's specific to Android. Look again at the ViewModel:

class PlantDetailViewModel : ViewModel() {
    // we want to inject a PlantRepository... but who calls this constructor?
}

You don't instantiate PlantDetailViewModel. The Android framework does, through a factory, at a time of its choosing. The same is true of Activity, Fragment, and the Worker that will drive Verdant's watering reminders. Constructor injection assumes you're the one calling the constructor — but for the most important objects in an Android app, you aren't. So how do you inject a dependency into an object you never build yourself?

That single question is the reason dependency injection on Android is a book and not a paragraph. Hold onto it.


1.8 Pitfalls & Misconceptions

A few traps worth naming now, before they calcify into habits:

  • "Dependency injection means a DI framework." It doesn't. You performed dependency injection in this chapter with a constructor and zero libraries. Hilt and Koin are tools for automating DI at scale; they are not DI itself. Confusing the technique with the tool leads people to think DI is heavy and annotation-laden when its core is a constructor parameter.

  • "Injection means an @Inject annotation or setting a field." Constructor injection — the purest and most common form — needs no annotations and touches no fields. If your mental image of DI is @Inject lateinit var, you've learned one framework's dialect and mistaken it for the language.

  • DI is not the Service Locator pattern. They're easy to conflate because both hand a class its dependencies from somewhere else. But with injection, dependencies are pushed in and visible in the signature; with a service locator, the class pulls them out of a global registry, hiding the dependency again. That distinction matters enough that we'll build both in Chapter 2 and see why one is preferred.

  • More interfaces is not automatically better. An abstraction earns its place when you actually need substitution — a fake for tests, an alternate implementation, a genuine seam. Wrapping every single class in an interface "just in case" adds indirection with no payoff. Introduce abstractions where variation is real, not reflexively.

  • DI doesn't eliminate coupling — it relocates it. Something still has to know that RetrofitPlantSource is the real RemotePlantSource. DI's win is that this knowledge collects in one dedicated place instead of being smeared across every class. That place has a name — the composition root — and it's where Chapter 2 begins.


1.9 What's Ahead

You now have the one idea the entire book rests on: a class should receive its dependencies, not create them. Everything else is consequence and automation.

Here's the path from here:

  • Chapter 2 — Dependency Injection by Hand. We assemble Verdant's object graph ourselves, with no framework, and meet the composition root — the single place where the real implementations get chosen and wired. We'll also build a service locator and see precisely why true injection is the better default. This chapter earns the frameworks: once you've wired a graph by hand, you'll know exactly what Hilt and Koin are doing for you.

  • Chapter 3 — The Concepts Frameworks Formalize. Object graphs, scopes, lifecycles, and qualifiers as shared vocabulary — plus a real answer to the Android wrinkle from §1.7: how you inject into Activities, Fragments, ViewModels, and Workers that the framework, not you, brings to life.

  • Part 2 (Hilt) and Part 3 (Koin) then wire this same Verdant graph two different ways, so you can compare them on identical ground rather than on two unrelated toy apps.

Before moving on, make sure the core move feels obvious in your hands, not just on the page. Take the original PlantRepository from §1.1 — the one that builds its own API and cache — and refactor it to constructor injection without looking back at §1.3. Then write one test that proves a cache hit skips the network. When that feels routine, you're ready for Chapter 2, where we answer the question this chapter left hanging: if classes don't build their own dependencies, who does?