StackPractices
intermediate By Mathias Paulenko

Dependency Injection Pattern

Supply dependencies from outside rather than creating them internally. An architectural pattern for decoupled, testable code.

Topics: design

Overview

A class that builds its own collaborators is a class you can’t test without dragging the real world into the test. The Dependency Injection Pattern is an architectural pattern where a class receives its dependencies from the outside instead of creating them internally — the class declares what it needs, and the caller hands it over.

That inversion of control is what makes the code loosely coupled and testable: swap StripeProcessor for MockProcessor and the service under test never knows the difference. Once the graph gets big, a framework or DI container in TypeScript can automate the wiring — but the pattern works fine without one.

When to Use

Use Dependency Injection when:

  • Classes depend on other classes and you want to avoid tight coupling.
  • You need to substitute implementations for testing (mocks, stubs, fakes).
  • Behavior should be configurable at deployment or runtime — payment provider, storage backend, feature flags.
  • You’re building a plugin or modular architecture where modules register their capabilities.
  • You want to follow the Dependency Inversion Principle — see SOLID principles.

Skip it when a class has one small, stable dependency and no test need to fake it — a new call is simpler than three layers of indirection. If your object needs to create instances rather than receive them, look at Factory Pattern instead.

Solution

Python

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def charge(self, amount: float) -> bool:
        pass

class StripeProcessor(PaymentProcessor):
    def charge(self, amount: float) -> bool:
        print(f"Charging ${amount} via Stripe")
        return True

class PayPalProcessor(PaymentProcessor):
    def charge(self, amount: float) -> bool:
        print(f"Charging ${amount} via PayPal")
        return True

class OrderService:
    def __init__(self, processor: PaymentProcessor):
        # Dependency injected via constructor
        self.processor = processor

    def checkout(self, amount: float) -> bool:
        return self.processor.charge(amount)

# Usage: swap implementations easily
stripe_service = OrderService(StripeProcessor())
stripe_service.checkout(100.0)

# Testing: inject a mock
class MockProcessor(PaymentProcessor):
    def charge(self, amount: float) -> bool:
        return True

test_service = OrderService(MockProcessor())
assert test_service.checkout(1.0)

JavaScript

class StripeProcessor {
  charge(amount) {
    console.log(`Charging $${amount} via Stripe`);
    return true;
  }
}

class PayPalProcessor {
  charge(amount) {
    console.log(`Charging $${amount} via PayPal`);
    return true;
  }
}

class OrderService {
  constructor(processor) {
    this.processor = processor;
  }

  checkout(amount) {
    return this.processor.charge(amount);
  }
}

// Usage
const stripeService = new OrderService(new StripeProcessor());
stripeService.checkout(100.0);

// Testing with mock
class MockProcessor {
  charge(amount) { return true; }
}
const testService = new OrderService(new MockProcessor());
console.assert(testService.checkout(1.0));

Java

public interface PaymentProcessor {
    boolean charge(double amount);
}

public class StripeProcessor implements PaymentProcessor {
    public boolean charge(double amount) {
        System.out.println("Charging $" + amount + " via Stripe");
        return true;
    }
}

public class PayPalProcessor implements PaymentProcessor {
    public boolean charge(double amount) {
        System.out.println("Charging $" + amount + " via PayPal");
        return true;
    }
}

public class OrderService {
    private final PaymentProcessor processor;

    // Constructor injection
    public OrderService(PaymentProcessor processor) {
        this.processor = processor;
    }

    public boolean checkout(double amount) {
        return processor.charge(amount);
    }
}

// Usage
OrderService stripeService = new OrderService(new StripeProcessor());
stripeService.checkout(100.0);

Explanation

Dependency Injection has three common forms:

  • Constructor Injection — dependencies passed via the constructor. The most common form: the object can’t exist in an incomplete state, and its needs are visible in the signature.
  • Setter Injection — dependencies set after construction. Flexible for optional dependencies, but the object may run in a half-configured state.
  • Interface Injection — dependencies delivered through an interface method on the class. Less common; mostly a framework mechanism.

The core idea is Inversion of Control: instead of a class creating its own dependencies, they’re supplied externally — usually by a single place called the composition root, at the application entry point.

flowchart diagram: Composition Root

The trade-offs are real. Every injected dependency is one more constructor parameter — services that take five or six dependencies are a smell, not a feature, and usually mean the class has too many responsibilities. DI also moves wiring errors from compile time to runtime once a container is involved: a missing registration only blows up when that code path runs.

Variants

VariantDescriptionBest For
Constructor InjectionDependencies passed at creationMandatory dependencies; immutable services
Setter InjectionDependencies set after creationOptional dependencies; reconfiguration at runtime
Interface InjectionDependencies via interface methodFramework-managed lifecycle
Service LocatorClass asks a registry for dependenciesLegacy systems; avoid in new code
DI ContainerLightweight TypeScript container that resolves and injects dependencies automaticallyLarger apps without a heavyweight framework

DI vs Service Locator

They solve the same problem from opposite directions. With DI, dependencies arrive through the constructor — visible and mandatory, so you can see what a service needs from its signature. With Service Locator, the service pulls dependencies from a global registry internally — the coupling is hidden and tests can’t substitute the dependency without knowing the locator exists. Prefer DI; keep the locator for legacy seams.

What Works

  • Prefer constructor injection for required dependencies; the signature becomes the contract.
  • Depend on interfaces or abstractions, not concrete classes — that’s what makes substitution possible.
  • Avoid service locators in new code; they hide dependencies and make testing harder.
  • Keep the composition root separate from business logic — one place that knows about concretes, everything else sees interfaces.
  • Don’t inject the container itself — that turns DI back into service location. Inject only the specific dependencies a class needs.

Common Mistakes

  • Injecting the DI container instead of specific dependencies — you’ve reinvented the service locator anti-pattern.
  • Using setter injection for required dependencies, so objects can exist in an incomplete state.
  • Reaching for a DI framework on a small project where manual wiring at the entry point is simpler.
  • Allowing circular dependencies between services — A needs B, B needs A, and nothing initializes.
  • Forgetting to register a dependency in the container — with manual wiring the compiler catches it; with a container it explodes at runtime.

Troubleshooting

  • No registration for type X at runtime: the container is missing a binding. Register it at the composition root, or add eager validation on startup so missing registrations fail fast instead of deep in a request.
  • Circular dependency errors: A injects B which injects A. Break the cycle — usually one side should listen to events instead of calling the other directly, or the shared logic belongs in a third service.
  • Constructor taking six or more parameters: the class has too many responsibilities. Split it, or group cohesive dependencies behind a facade interface.
  • Tests still hit real services: the dependency isn’t actually injected — there’s a new Concrete() hiding inside the class. Search the codebase for constructor calls on interfaces’ implementations.
  • Everything got injected as singleton and state leaks between requests: check scopes. Per-request dependencies (like a DB session) registered as singletons share mutable state across users.

Key Takeaways

  • DI inverts control: the class declares its dependencies, the composition root supplies them.
  • Constructor injection is the default — it makes required dependencies visible and objects always fully initialized.
  • The payoff is testability and configurability: swap real collaborators for mocks without touching the class.
  • A DI container helps at scale but isn’t required — manual wiring beats a framework for small apps.

Common Production Pitfalls

  • Registering everything as singletons and leaking per-request state between users — a DB session or request context held by a shared service is a classic incident.
  • Constructor injection spiraling into six-plus parameters nobody questions, because the container hides the smell.
  • Circular dependencies that pass local tests and deadlock or stack-overflow under container resolution in production.
  • Letting the container leak into business classes via container.resolve() — the service locator anti-pattern wearing a DI costume.
  • Missing registrations that only fail on the code path nobody hits in staging — add a startup validation pass that resolves the whole graph.
  • Over-abstracting: an interface plus DI registration for something that will never have a second implementation.

Frequently Asked Questions

Is DI the same as Inversion of Control?

DI is a specific form of IoC. IoC is the broader principle of handing control to external code — frameworks calling you instead of the reverse. DI gets there by injecting dependencies from outside rather than letting classes build their own.

Do I need a DI framework?

No. For small projects, manual constructor injection at the entry point is enough — see DI Container in TypeScript for a lightweight implementation once you outgrow it. Spring, Angular's injector, or InversifyJS start paying off when you have dozens of interdependent services.

How does DI help with testing?

By depending on abstractions, you can inject mocks or stubs in tests — see unit testing for patterns. The class under test never touches real databases, payment gateways, or clocks.

What happens when a constructor takes too many dependencies?

That's constructor explosion — a signal the class owns too much behavior. Split it by responsibility, or group related dependencies behind a higher-level interface. DI doesn't remove coupling; it just makes it visible.

How do I handle optional dependencies?

Setter or method injection fits better than a nullable constructor parameter — an optional collaborator shouldn't be mandatory to construct the object. In containers, register the dependency conditionally so absence is explicit, not a null someone forgets to check.