Dependency Injection & IoC Explained

Introduction
In 2004, Martin Fowler published Inversion of Control Containers and the Dependency Injection pattern — an article that became the definitive reference for how modern frameworks wire components together. If you've ever used Spring, NestJS, Angular, or FastAPI, you've used concepts from this article.
The core question Fowler addresses: How do you assemble an application from loosely coupled components? How does a class get the collaborators it needs without knowing their concrete implementations?
Two patterns emerged as answers: Dependency Injection and Service Locator. This post breaks down both, compares them, and shows you when to use each.
What You'll Learn
✅ Why Inversion of Control matters for real-world applications
✅ The three forms of Dependency Injection: Constructor, Setter, Interface
✅ How the Service Locator pattern works as an alternative
✅ When to choose DI over Service Locator (and vice versa)
✅ Constructor vs Setter Injection trade-offs
✅ The one principle that matters more than either pattern
Prerequisites
- Familiar with SOLID Principles, especially Dependency Inversion
- Comfortable with Polymorphism and Interfaces
The Problem: Plugin Assembly
Imagine you're building a movie recommendation app. You have a MovieLister class that finds movies by director:
interface MovieFinder {
findAll(): Movie[];
}
class MovieLister {
private finder: MovieFinder;
constructor() {
// ❌ Hardcoded dependency
this.finder = new CSVMovieFinder("movies.csv");
}
moviesDirectedBy(director: string): Movie[] {
return this.finder.findAll().filter(m => m.director === director);
}
}This works — until you need different MovieFinder implementations:
| Environment | MovieFinder Implementation |
|---|---|
| Development | CSVMovieFinder (reads local CSV) |
| Production | DatabaseMovieFinder (queries PostgreSQL) |
| Testing | InMemoryMovieFinder (hardcoded test data) |
| Partner API | RemoteMovieFinder (calls external service) |
The MovieLister should work with any implementation. But the new CSVMovieFinder() call inside the constructor makes it impossible to swap.
This is the plugin problem: how do you assemble components so that MovieLister works with any MovieFinder, decided at deployment time rather than compile time?
The question mark is the key: who decides which implementation to use, and how does it get wired in?
Inversion of Control: The Core Idea
The term "Inversion of Control" (IoC) describes a shift in who controls the flow:
| Traditional Control | Inverted Control |
|---|---|
| Your code calls library functions | Framework calls your code |
| Your class creates its dependencies | Something else provides dependencies |
| You control the assembly | An external assembler controls wiring |
IoC is a broad principle — event-driven programming, template methods, and plugin architectures all use it. But for component assembly specifically, Fowler coined a more precise term: Dependency Injection.
Dependency Injection is a pattern where an external assembler provides (injects) a component's dependencies, rather than the component creating them itself.
The key insight: separate the configuration of a dependency from its use.
The Three Forms of Dependency Injection
Fowler identifies three ways to inject dependencies. All three solve the same problem — they differ in the mechanism.
Form 1: Constructor Injection
The dependency is provided through the constructor. The object cannot be created without its dependencies.
// The interface
interface MovieFinder {
findAll(): Movie[];
}
// The component — receives its dependency via constructor
class MovieLister {
private finder: MovieFinder;
constructor(finder: MovieFinder) {
this.finder = finder;
}
moviesDirectedBy(director: string): Movie[] {
return this.finder.findAll().filter(m => m.director === director);
}
}
// Different implementations
class CSVMovieFinder implements MovieFinder {
private filename: string;
constructor(filename: string) {
this.filename = filename;
}
findAll(): Movie[] {
// Read movies from CSV file
return parseCSV(this.filename);
}
}
class DatabaseMovieFinder implements MovieFinder {
constructor(private connectionString: string) {}
findAll(): Movie[] {
// Query database
return queryDB(this.connectionString);
}
}The assembler (container) wires everything together:
// The assembler — knows about concrete types
class Container {
private lister: MovieLister;
private finder: MovieFinder;
constructor() {
// Configuration happens HERE, not inside MovieLister
this.finder = new CSVMovieFinder("movies.csv");
this.lister = new MovieLister(this.finder);
}
getMovieLister(): MovieLister {
return this.lister;
}
}
// Usage
const container = new Container();
const lister = container.getMovieLister();
const kubrickMovies = lister.moviesDirectedBy("Kubrick");Advantages:
- Object is fully initialized at creation — no half-built state
- Dependencies are clearly visible in the constructor signature
- Fields can be
readonly/ immutable - Can't forget to set a dependency
This is the most common form. Frameworks like NestJS, Angular, and Spring use constructor injection as their primary mechanism.
Form 2: Setter Injection
Dependencies are provided through setter methods after construction:
class MovieLister {
private finder!: MovieFinder;
// Setter for injection
setFinder(finder: MovieFinder): void {
this.finder = finder;
}
moviesDirectedBy(director: string): Movie[] {
return this.finder.findAll().filter(m => m.director === director);
}
}
// Assembler uses setters
class Container {
private lister: MovieLister;
constructor() {
this.lister = new MovieLister();
const finder = new CSVMovieFinder("movies.csv");
this.lister.setFinder(finder);
}
getMovieLister(): MovieLister {
return this.lister;
}
}In frameworks like Spring, this was historically configured via XML:
<bean id="movieFinder" class="CSVMovieFinder">
<property name="filename" value="movies.csv" />
</bean>
<bean id="movieLister" class="MovieLister">
<property name="finder" ref="movieFinder" />
</bean>Advantages:
- Named setters make clear what each dependency is
- Works better when a class has many optional dependencies
- Simpler with deep inheritance hierarchies (no super constructor chains)
Disadvantage:
- Object can exist in an incomplete state (finder not yet set)
- Harder to enforce required dependencies
Form 3: Interface Injection
The component implements a specific injection interface, making the dependency requirement explicit:
// Injection interface — "I need a MovieFinder"
interface InjectMovieFinder {
injectMovieFinder(finder: MovieFinder): void;
}
// Component implements the injection interface
class MovieLister implements InjectMovieFinder {
private finder!: MovieFinder;
injectMovieFinder(finder: MovieFinder): void {
this.finder = finder;
}
moviesDirectedBy(director: string): Movie[] {
return this.finder.findAll().filter(m => m.director === director);
}
}
// Assembler checks what injection interfaces a component implements
// and calls the appropriate injection methodsThis is the least common form. It requires more ceremony (extra interfaces) for little benefit over constructor injection. You'll rarely see it in modern frameworks.
Comparing the Three Forms
| Aspect | Constructor | Setter | Interface |
|---|---|---|---|
| Object completeness | Always fully initialized | May be incomplete | May be incomplete |
| Immutability | Supports readonly fields | Mutable by nature | Mutable by nature |
| Many dependencies | Long constructor signature | Clean named setters | Verbose (one interface per dep) |
| Framework adoption | NestJS, Angular, Spring (modern) | Spring (legacy XML) | Rare |
| Fowler's recommendation | Start here | Switch if constructor gets unwieldy | Avoid |
The Alternative: Service Locator
Dependency Injection isn't the only solution. The Service Locator pattern takes a different approach: instead of having dependencies pushed to you, you pull them from a central registry.
Basic Service Locator
// The service locator — a central registry
class ServiceLocator {
private static services = new Map<string, unknown>();
static register(key: string, service: unknown): void {
this.services.set(key, service);
}
static get<T>(key: string): T {
const service = this.services.get(key);
if (!service) throw new Error(`Service not found: ${key}`);
return service as T;
}
}
// Configuration — register implementations
ServiceLocator.register("movieFinder", new CSVMovieFinder("movies.csv"));
// Component pulls its own dependencies
class MovieLister {
private finder: MovieFinder;
constructor() {
this.finder = ServiceLocator.get<MovieFinder>("movieFinder");
}
moviesDirectedBy(director: string): Movie[] {
return this.finder.findAll().filter(m => m.director === director);
}
}Type-Safe Service Locator
A better approach uses typed methods instead of string keys:
class ServiceLocator {
private static movieFinder: MovieFinder;
static registerMovieFinder(finder: MovieFinder): void {
this.movieFinder = finder;
}
static getMovieFinder(): MovieFinder {
return this.movieFinder;
}
}
// Usage
ServiceLocator.registerMovieFinder(new CSVMovieFinder("movies.csv"));
class MovieLister {
private finder: MovieFinder;
constructor() {
this.finder = ServiceLocator.getMovieFinder();
}
}Segregated Interface Locator
For large applications, split the locator into focused interfaces:
interface MovieServices {
getMovieFinder(): MovieFinder;
}
interface UserServices {
getUserRepository(): UserRepository;
}
// MovieLister only sees MovieServices, not the entire locator
class MovieLister {
private finder: MovieFinder;
constructor(locator: MovieServices) {
this.finder = locator.getMovieFinder();
}
}This limits what each component can access — a component that only needs MovieFinder can't reach into UserRepository.
Service Locator vs Dependency Injection
This is the central comparison in Fowler's article. Both patterns achieve the same goal — decoupling a component from the concrete implementation of its dependencies. The difference is in the mechanism:
Dependency Injection — Container pushes dependency into the component:
Service Locator — Component pulls dependency from a registry:
| Aspect | Service Locator | Dependency Injection |
|---|---|---|
| Direction | Component pulls (asks for dependency) | Container pushes (provides dependency) |
| Dependency on pattern | Component depends on the Locator | Component has no framework dependency |
| Visibility | Explicit — you see the lookup in code | Implicit — dependency "magically" appears |
| Testability | Swap locator registration in tests | Pass mock via constructor in tests |
| Reusability | Component tied to locator API | Component works anywhere |
| Debugging | Follow the get() call | Check container configuration |
When to Use Which?
Fowler's key insight: the choice depends on who will use the component.
Use Service Locator when:
- You control all the code using the component
- The component is used within a single application
- You want explicit, visible dependency resolution
- You prefer simpler mental model (just look it up)
Use Dependency Injection when:
- The component will be used by others (library, framework, package)
- You don't control the applications using your component
- You want zero coupling to any container or locator
- The component needs to work in diverse deployment contexts
"The key difference is that with a Service Locator every user of a service has a dependency to the locator. The locator can hide dependencies to other implementations, but you do need to see the locator. So the decision between locator and injector depends on whether that dependency is a problem." — Martin Fowler
Constructor vs Setter Injection: The Practical Decision
Even after choosing Dependency Injection, you face a second decision: how to inject?
Prefer Constructor Injection When:
// ✅ Clear contract — you NEED these to function
class OrderProcessor {
constructor(
private readonly paymentGateway: PaymentGateway,
private readonly inventory: InventoryService,
private readonly notifier: NotificationService
) {}
processOrder(order: Order): void {
this.inventory.reserve(order.items);
this.paymentGateway.charge(order.total);
this.notifier.sendConfirmation(order);
}
}- Dependencies are required — the object makes no sense without them
- You want immutability (
readonlyfields) - You want to enforce valid state at creation time
- Constructor has a reasonable number of parameters (3-5)
Consider Setter Injection When:
// ✅ Many optional configuration points
class ReportGenerator {
private formatter: Formatter = new DefaultFormatter();
private exporter: Exporter = new PDFExporter();
private locale: string = "en";
setFormatter(formatter: Formatter): void { this.formatter = formatter; }
setExporter(exporter: Exporter): void { this.exporter = exporter; }
setLocale(locale: string): void { this.locale = locale; }
generate(data: ReportData): Report {
const formatted = this.formatter.format(data, this.locale);
return this.exporter.export(formatted);
}
}- Dependencies are optional with sensible defaults
- There are many configuration points (10+ constructor params is a code smell)
- Deep inheritance hierarchies make constructor chaining painful
Fowler's Recommendation
Start with constructor injection. Switch to setter injection when constructor parameters become unwieldy.
Most modern frameworks agree — constructor injection is the default, setter injection is the escape hatch.
Configuration: Code vs Files
A final consideration from the article: how do you configure the wiring?
Programmatic Configuration (Code)
// config.ts — all wiring in code
function createContainer(): Container {
const finder = new DatabaseMovieFinder(process.env.DB_URL!);
const lister = new MovieLister(finder);
return { lister };
}Pros: Type-safe, refactorable, IDE support, conditional logic easy.
Declarative Configuration (Files)
{
"services": {
"movieFinder": {
"class": "DatabaseMovieFinder",
"args": ["${DB_URL}"]
},
"movieLister": {
"class": "MovieLister",
"inject": ["movieFinder"]
}
}
}Pros: Change behavior without recompilation, non-developers can modify.
Fowler's Advice
Provide a programmatic API first. Add configuration files as an optional convenience. If your configuration is getting complex enough to need conditionals and loops, you've outgrown configuration files — use a proper programming language.
Most modern frameworks follow this advice: NestJS uses decorators (code), Spring Boot migrated from XML to annotations (code), and FastAPI uses Python functions (code).
DI in the Wild: Real Framework Implementations
Fowler's article didn't just describe patterns — it became the blueprint that the entire industry adopted. PicoContainer and Spring were the two containers Fowler analyzed in the original 2004 piece. A DI meeting at ThoughtWorks in December 2003 between PicoContainer developers, Spring's Rod Johnson, and Fowler (electronically) is what produced the article and coined the term "Dependency Injection."
Here's how major frameworks implement DI today — and which forms from Fowler's article they chose:
Frameworks That Directly Cite Fowler's Article
| Framework | Language | DI Forms | Notes |
|---|---|---|---|
| Spring | Java | Constructor + Setter | Spring docs cite Fowler's article directly. One of the two original containers analyzed |
| PicoContainer | Java | Constructor (primary) | The other container in the article. Pioneered constructor injection |
| Yii2 | PHP | Constructor + Setter + Property | Explicitly links Fowler's article. Also implements Service Locator on top of DI container |
| ASP.NET Core | C# | Constructor (primary) | Microsoft docs cite "Inversion of Control Containers and the Dependency Injection Pattern" |
| Symfony | PHP | Constructor + Setter | Creator Fabien Potencier wrote a DI series referencing Fowler |
| Spring.NET | C# | Constructor + Setter | .NET port of Spring, directly references Fowler |
Frameworks Built on the Same Ideas
| Framework | Language | DI Form | How It Works |
|---|---|---|---|
| Angular | TypeScript | Constructor | Decorator-based (@Injectable), hierarchical injector tree |
| NestJS | TypeScript | Constructor | Modeled after Angular's DI system, uses @Injectable decorators |
| Laravel | PHP | Constructor | Service Container with auto-resolution. Also uses Service Locator via app() helper |
| FastAPI | Python | Function-based | Python's take — Depends() injects via function parameters |
| Google Guice | Java | Constructor | Lightweight alternative to Spring, annotation-based |
The Pattern
Nearly every framework follows Fowler's recommendation: constructor injection as the primary mechanism, with setter injection as fallback. The article standardized the vocabulary (constructor injection, setter injection, service locator) that the entire industry now uses.
Interestingly, Yii2 is one of the few frameworks that explicitly implements both DI Container and Service Locator side by side — with the Service Locator built on top of the DI container. Most other frameworks picked one (usually DI) and ran with it, though Laravel blurs the line with its app() facade acting as a service locator.
The Principle That Matters Most
After all the comparisons, Fowler arrives at the most important takeaway — and it's not about picking DI over Service Locator:
"The important thing is to separate service configuration from use. These patterns are a natural consequence of this separation."
Whether you use constructor injection, setter injection, or a service locator, the fundamental principle is the same: the code that uses a dependency should not be the code that chooses which concrete implementation to use.
// ❌ Use and configuration tangled together
class MovieLister {
private finder = new CSVMovieFinder("movies.csv"); // Configuration
moviesDirectedBy(director: string): Movie[] { // Use
return this.finder.findAll().filter(m => m.director === director);
}
}
// ✅ Configuration separated from use (via any pattern)
class MovieLister {
constructor(private finder: MovieFinder) {} // Use only
moviesDirectedBy(director: string): Movie[] {
return this.finder.findAll().filter(m => m.director === director);
}
}
// Configuration happens elsewhere — in a container, locator, or factoryThis separation enables:
- Testing: Swap implementations in test setup
- Deployment flexibility: Different implementations per environment
- Team autonomy: Teams own their implementations behind interfaces
- Evolution: Replace implementations without touching consumers
Quick Reference
Decision Flowchart
Pattern Summary
| Pattern | Mechanism | Best For |
|---|---|---|
| Constructor Injection | Dependencies passed at creation | Required dependencies, immutable objects |
| Setter Injection | Dependencies set after creation | Optional dependencies, many config points |
| Interface Injection | Component implements injection interface | Rarely used in modern code |
| Service Locator | Component looks up dependencies | Application-internal components |
Key Takeaways
-
The core problem is assembling components with different implementations across deployments — the "plugin problem"
-
Dependency Injection has the container push dependencies into the component. Service Locator has the component pull dependencies from a registry. Both decouple configuration from use
-
Constructor injection is the default choice — it creates fully initialized, immutable objects with clear dependency contracts
-
Setter injection is the fallback when constructors become unwieldy (many optional dependencies, deep inheritance)
-
Service Locator is simpler but creates a dependency on the locator itself. Use it within your own application; avoid it in reusable libraries
-
The principle that transcends both patterns: Separate the configuration of a service from where it's used. This is more important than which specific pattern you choose
Further Reading
- Martin Fowler's Original Article — The source material for this post
- SOLID Principles Explained — The Dependency Inversion Principle is the "why" behind DI
- Dependency Injection in FastAPI — DI applied in a real Python framework
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.