Back to blog

Dependency Injection & IoC Explained

oopdesign-patternsdependency-injectiontypescriptsoftware-architecture
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


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:

EnvironmentMovieFinder Implementation
DevelopmentCSVMovieFinder (reads local CSV)
ProductionDatabaseMovieFinder (queries PostgreSQL)
TestingInMemoryMovieFinder (hardcoded test data)
Partner APIRemoteMovieFinder (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 ControlInverted Control
Your code calls library functionsFramework calls your code
Your class creates its dependenciesSomething else provides dependencies
You control the assemblyAn 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 methods

This 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

AspectConstructorSetterInterface
Object completenessAlways fully initializedMay be incompleteMay be incomplete
ImmutabilitySupports readonly fieldsMutable by natureMutable by nature
Many dependenciesLong constructor signatureClean named settersVerbose (one interface per dep)
Framework adoptionNestJS, Angular, Spring (modern)Spring (legacy XML)Rare
Fowler's recommendationStart hereSwitch if constructor gets unwieldyAvoid

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:

AspectService LocatorDependency Injection
DirectionComponent pulls (asks for dependency)Container pushes (provides dependency)
Dependency on patternComponent depends on the LocatorComponent has no framework dependency
VisibilityExplicit — you see the lookup in codeImplicit — dependency "magically" appears
TestabilitySwap locator registration in testsPass mock via constructor in tests
ReusabilityComponent tied to locator APIComponent works anywhere
DebuggingFollow the get() callCheck 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 (readonly fields)
  • 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

FrameworkLanguageDI FormsNotes
SpringJavaConstructor + SetterSpring docs cite Fowler's article directly. One of the two original containers analyzed
PicoContainerJavaConstructor (primary)The other container in the article. Pioneered constructor injection
Yii2PHPConstructor + Setter + PropertyExplicitly links Fowler's article. Also implements Service Locator on top of DI container
ASP.NET CoreC#Constructor (primary)Microsoft docs cite "Inversion of Control Containers and the Dependency Injection Pattern"
SymfonyPHPConstructor + SetterCreator Fabien Potencier wrote a DI series referencing Fowler
Spring.NETC#Constructor + Setter.NET port of Spring, directly references Fowler

Frameworks Built on the Same Ideas

FrameworkLanguageDI FormHow It Works
AngularTypeScriptConstructorDecorator-based (@Injectable), hierarchical injector tree
NestJSTypeScriptConstructorModeled after Angular's DI system, uses @Injectable decorators
LaravelPHPConstructorService Container with auto-resolution. Also uses Service Locator via app() helper
FastAPIPythonFunction-basedPython's take — Depends() injects via function parameters
Google GuiceJavaConstructorLightweight 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 factory

This 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

PatternMechanismBest For
Constructor InjectionDependencies passed at creationRequired dependencies, immutable objects
Setter InjectionDependencies set after creationOptional dependencies, many config points
Interface InjectionComponent implements injection interfaceRarely used in modern code
Service LocatorComponent looks up dependenciesApplication-internal components

Key Takeaways

  1. The core problem is assembling components with different implementations across deployments — the "plugin problem"

  2. 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

  3. Constructor injection is the default choice — it creates fully initialized, immutable objects with clear dependency contracts

  4. Setter injection is the fallback when constructors become unwieldy (many optional dependencies, deep inheritance)

  5. Service Locator is simpler but creates a dependency on the locator itself. Use it within your own application; avoid it in reusable libraries

  6. 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

📬 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.