Dependency Injection as a Design Technique
What This Concept Is
Dependency Injection (DI) is a design technique: a class receives the collaborators it needs through its constructor (or, less commonly, through setters or method parameters) instead of constructing them itself. The technique is independent of any framework or container.
Three common styles:
- Constructor injection (default choice): dependencies are arguments to the constructor and are stored as final fields. Required dependencies are impossible to forget.
- Setter injection: dependencies are set after construction. Useful for genuinely optional collaborators, but creates partially-initialized objects.
- Method injection: a dependency is passed to a specific method. Useful when the dependency's lifecycle is shorter than the object's.
The inverse of DI is "new inside a class." A service that constructs its own HTTP client, database connection, or clock is tightly coupled to concrete implementations and therefore to external resources.
Why It Matters Here
DI is the connective tissue behind many of this module's patterns:
- Factory Method is how subclasses provide products; DI is how any collaborator gets in.
- Adapter, Facade, Decorator, and Proxy are only composable if their components are injectable.
- Strategy and Command (Module 3) are usually passed in via DI.
- Testability -- specifically, replacing real collaborators with fakes or stubs -- is nearly impossible without DI.
DI is also the cheapest design decision in this module: it adds no extra code at the consumer, just discipline.
Concrete Example
Before (tight coupling, untestable):
class OrderService {
private readonly payments = new StripeClient(process.env.STRIPE_KEY!);
private readonly clock = new SystemClock();
async place(cmd: PlaceOrder) {
await this.payments.charge(cmd.amountCents, cmd.token);
return { placedAt: this.clock.now() };
}
}
After (constructor injection):
interface PaymentGateway { charge(amount: number, token: string): Promise<void>; }
interface Clock { now(): Date; }
class OrderService {
constructor(
private readonly payments: PaymentGateway,
private readonly clock: Clock,
) {}
async place(cmd: PlaceOrder) {
await this.payments.charge(cmd.amountCents, cmd.token);
return { placedAt: this.clock.now() };
}
}
// Wired at the composition root:
const service = new OrderService(
new StripePaymentGateway(new StripeClient(config.stripeKey)),
new SystemClock(),
);
The refactor moves no logic. It just separates what OrderService does from what it depends on. Tests inject a FakePaymentGateway and a FixedClock; production injects the real ones.
Structural sketch:
Composition Root --- new --> ConcreteDependency
| ^
| new |
v |
OrderService (holds it as field) --+
Common Confusion / Misconception
- DI vs DI container. The technique does not require a container. Plain constructor parameters are DI. Containers (Spring, Guice, Dagger, Pinject, tsyringe) are one way to automate wiring at scale; they are not the concept itself.
- DI vs Service Locator. A service locator is a registry from which code pulls dependencies by name or type (
Locator.get(PaymentGateway)). It hides dependencies instead of naming them in the constructor. DI inverts that: dependencies are obvious at construction time and hidden dependencies cannot hide. - DI vs "new is evil."
newis fine at the composition root and for value objects or domain entities. The smell isnewfor services and external resources inside other services. - A constructor with more than a handful of parameters is often a smell, but the smell is too many responsibilities, not DI itself. Split the class; do not hide the dependencies.
How To Use It
- Identify external or swappable collaborators: HTTP clients, databases, clocks, randomness, file systems, queues, caches, email services, crypto.
- Extract their interfaces from the consumer's point of view (not the library's).
- Accept the interfaces as constructor parameters; store them as
final/readonlyfields. - Do not instantiate those collaborators inside the class.
- Wire at the composition root (see the next concept).
Check Yourself
- Why does constructor injection catch missing dependencies earlier than setter injection?
- What is the crucial difference between DI and a service locator?
- When is
newinside a class still acceptable?
Mini Drill or Application
Take a class in an existing codebase that is hard to test. Do three things:
- List every collaborator it constructs internally.
- Extract an interface for each external one and add them as constructor parameters.
- Write one unit test that injects fakes and verifies one behavior. Keep the test minimal; DI should make it easy.