Skip to content
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

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Dependency Injection Pattern

Overview

The Dependency Injection Pattern is an architectural pattern where dependencies are supplied to a class from the outside rather than being created internally. This inverts control: the class declares what it needs, and an external mechanism provides it. The result is loosely coupled, highly testable code.

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)
  • You want to configure behavior at runtime or deployment time
  • You are building a plugin or modular architecture
  • You want to follow the Dependency Inversion Principle (SOLID)

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 (most common, ensures the object is always fully initialized)
  • Setter Injection — dependencies set via setter methods after construction (flexible, but object may be in incomplete state)
  • Interface Injection — dependencies provided through an interface method (less common, used in frameworks)

The core idea is Inversion of Control: instead of a class creating its own dependencies, they are supplied externally.

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 ContainerFramework resolves and injects dependencies automaticallyLarge applications (Spring, Angular, .NET Core)

What Works

  • Prefer constructor injection for required dependencies; it makes the class’s needs explicit
  • Use interfaces or abstractions as dependency types, not concrete classes
  • Avoid service locators when possible; they hide dependencies and make testing harder
  • Keep DI configuration separate from business logic (use modules, config files, or annotations)
  • Respect the Law of Demeter — don’t inject the container itself, only the specific dependencies needed

Common Mistakes

  • Injecting the DI container itself instead of specific dependencies, creating a service locator anti-pattern
  • Using setter injection for required dependencies, allowing objects to exist in an incomplete state
  • Over-engineering with a DI container for small projects where manual wiring is simpler
  • Allowing circular dependencies between injected services, causing initialization failures
  • Forgetting to register all dependencies in the container, leading to runtime resolution errors

Frequently Asked Questions

Q: Is DI the same as Inversion of Control? A: DI is a specific form of IoC. IoC is the broader principle of delegating control to external code. DI achieves IoC by injecting dependencies from outside.

Q: Do I need a DI framework? A: No. For small projects, manual constructor injection is sufficient. See DI Container in TypeScript for a lightweight implementation. DI frameworks like Spring, Angular’s injector, or InversifyJS shine in large applications with many interdependent services.

Q: How does DI help with testing? A: By depending on abstractions (interfaces), you can inject mock or stub implementations during tests. See unit testing for testing patterns. This isolates the class under test from its real collaborators.

Is this pattern suitable for small projects?

For small projects with few components, this pattern may add unnecessary complexity. Start simple and introduce the pattern when you feel the pain it solves.

How does this pattern compare to alternatives?

Each pattern makes different trade-offs. Review the variants table above and consider your specific constraints: team size, performance requirements, and future scaling plans.

Can I partially apply this pattern?

Yes. Many teams adopt patterns incrementally. Start with the core idea and add sophistication as needed. The pattern is a guide, not a strict blueprint.

Advanced Topics

Scenario: DI for Notification Service

// DI pattern: inject dependencies instead of creating them
// Without DI (coupled)
class BadNotificationService {
  private emailProvider = new SendGridProvider();  // coupled
  private logger = new ConsoleLogger();             // coupled
  async notify(email: string, msg: string) {
    await this.emailProvider.send(email, msg);
    this.logger.log(`Sent to ${email}`);
  }
}

// With DI (decoupled)
interface EmailProvider { send(to: string, body: string): Promise<void>; }
interface Logger { log(msg: string): void; }

class GoodNotificationService {
  constructor(
    private emailProvider: EmailProvider,
    private logger: Logger
  ) {}

  async notify(email: string, msg: string) {
    await this.emailProvider.send(email, msg);
    this.logger.log(`Sent to ${email}`);
  }
}

// Composition: choose implementations at construction
const service = new GoodNotificationService(
  new SendGridProvider(),   // or new SESProvider(), or new MockProvider()
  new WinstonLogger()       // or new ConsoleLogger(), or new SilentLogger()
);

// In tests: inject mocks
const mockEmail: EmailProvider = { send: async (to, body) => { console.log(`Mock send to ${to}`); } };
const mockLogger: Logger = { log: (msg) => { /* spy */ } };
const testService = new GoodNotificationService(mockEmail, mockLogger);

// Injection types
  | Type | Example | Advantages | Disadvantages |
  |------|---------|------------|---------------|
  | Constructor | constructor(db: DB) | Mandatory deps | Long params |
  | Setter | setDB(db: DB) | Optional, flexible | Deps can be missing |
  | Interface | @Injectable() | Metadata, DI container | Requires framework |
  | Property | @Inject() | Concise | Hidden deps |
  | Method | process(db: DB, data) | Per call | Repetitive |

Lessons:

  • DI decouples: the service does not create its dependencies
  • Constructor injection is preferred: mandatory and explicit deps
  • In tests, inject mocks: do not touch real services
  • DI container automates composition (tsyringe, InversifyJS)
  • Without DI container: manual composition at entry point
  • DI vs Service Locator: DI is explicit, SL is implicit and hidden

### DI vs Service Locator: which do I use?

Use DI: dependencies are passed to the constructor, visible and mandatory. Use Service Locator only in legacy: the service asks a global registry for dependencies. DI is explicit: you see what the service needs. SL is implicit: the service fetches dependencies internally, hiding coupling. DI is testable; SL is hard to test. Prefer DI always.




End of document. Review and update quarterly.