Dependency Injection & Inversion of Control
Three things that get merged under one name — the dependency inversion principle, injection as a technique, and IoC containers as a tool — plus why constructor injection wins, what a container actually costs, and why service location hides what injection reveals.
Three separate things share this name, and conflating them is why the topic generates so much heat:
- Dependency inversion (DIP) — a design principle about which way dependencies point between modules.
- Dependency injection (DI) — a technique: an object receives its collaborators instead of constructing or locating them.
- An IoC container — a tool that wires object graphs automatically.
You can have (1) with no framework at all, (2) with three lines of plain code, and (3) without achieving either. "We use Spring, so our design is decoupled" does not follow.
The technique, without any framework #
class OrderService {
private final RateSource rates; // depends on an abstraction it owns
private final Clock clock;
OrderService(RateSource rates, Clock clock) { // that is the whole of DI
this.rates = rates;
this.clock = clock;
}
}Compare with the version that constructs its own collaborators:
class OrderService {
private final RateSource rates = new PostgresRateSource(Config.get("db.url"));
private final Clock clock = Clock.systemUTC();
}The second version has a hidden dependency on Postgres, on a config file, and on the real wall clock. It cannot be tested without a database, its time-dependent behaviour cannot be tested at all, and nothing in its signature says any of this. The first version states its requirements in the constructor, which is the actual payoff — dependencies become part of the type.
Constructor injection, not the alternatives #
| Constructor | Setter / property | Field (reflection) | |
|---|---|---|---|
| Object always valid | ✅ | ❌ two-phase construction | ❌ |
Fields can be final/readonly |
✅ | ❌ | ❌ |
| Dependencies visible in the API | ✅ | ⚠️ partially | ❌ invisible |
| Testable without a container | ✅ | ⚠️ | ❌ needs reflection or a container |
| Detects a missing dependency | at construction | at first use — possibly in production | at first use |
Field injection (@Autowired on a private field) is the worst of the three precisely because it is the most convenient: the class cannot be constructed by ordinary code, so every test needs the container, the fields cannot be final, and the class can grow dependencies without its signature changing — which removes the one natural pressure against it.
Service locator is the near-miss #
class OrderService {
void place(Order o) {
RateSource rates = ServiceLocator.get(RateSource.class); // hidden dependency
}
}This looks like decoupling — the concrete type is not named — and it gives up the property that mattered. The dependency is invisible in the signature, so a caller cannot know what this object needs, a test must populate a global registry, and a missing registration fails at run time, deep inside a call. Fowler's 2004 article separated the two patterns for this reason: injection makes dependencies explicit, location makes them ambient. Anything that reaches out to fetch a collaborator — a static singleton, a thread-local, a global context — is service location wearing a different name.
What a container buys and costs #
A container automates the wiring of a large object graph and manages lifetimes (singleton, per-request, per-session). At a few hundred components that is real work saved. The costs are equally real:
- Runtime failures instead of compile-time ones. A missing binding, an ambiguous one, or a circular reference surfaces at startup — or at first request, for lazily-resolved scopes.
- Reflection and startup cost, which is why compile-time DI exists: Dagger, Micronaut, Quarkus and Spring AOT generate the wiring code, moving the errors back to compile time and cutting startup dramatically — the difference that makes JVM services viable on serverless.
- Scope bugs. Injecting a request-scoped object into a singleton captures the first request's instance and serves it to everyone. This is a data-leak-grade bug and containers cannot generally detect it.
- Opacity. "Where does this instance come from?" stops being answerable by reading code.
Languages without a container culture do fine: Go and Rust wire graphs by hand in main (or with a code generator like Wire), and the result is a plain function whose behaviour is obvious.
The composition root #
The one structural rule that keeps DI honest: construct the object graph in exactly one place, at the entry point, and nowhere else.
main / Startup / the container config ← the only place that knows concrete types
└── OrderService(PostgresRateSource(pool), Clock.systemUTC())
└── used by handlers, which receive it
Everything below the composition root receives what it needs and constructs nothing global. That is what makes an alternate composition — an in-memory store and a fixed clock for tests, a stub for a local run — a matter of calling a different builder rather than of overriding framework behaviour.