Dependency Injection
A technique where objects receive their dependencies from external sources rather than creating them internally.
Overview
Dependency Injection (DI) is a form of Inversion of Control where the responsibility for creating and providing dependencies is moved outside the class that uses them. Instead of a class instantiating its collaborators directly, they’re “injected” from the outside—typically through a constructor, method, or property.
DI makes code more flexible, testable, and maintainable. Components become loosely coupled because they depend on abstractions rather than concrete implementations, and those implementations can be swapped without changing the component’s code.
Key Principles
Constructor Injection (Preferred)
Dependencies are provided through the constructor. This makes dependencies explicit, ensures the object is fully initialized, and makes it impossible to forget to provide a dependency.
class OrderService {
constructor(
private readonly repository: OrderRepository,
private readonly paymentProcessor: PaymentProcessor,
private readonly notifier: NotificationService
) {}
async processOrder(order: Order): Promise<void> {
await this.paymentProcessor.charge(order.total);
await this.repository.save(order);
await this.notifier.sendConfirmation(order.customerId);
}
}
// Usage: dependencies are injected when creating the service
const orderService = new OrderService(
new PostgresOrderRepository(db),
new StripePaymentProcessor(stripeKey),
new EmailNotificationService(emailClient)
);Depend on Abstractions
Injected dependencies should be interfaces or abstract types, not concrete implementations. This allows different implementations to be provided in different contexts (production vs. testing, different environments, etc.).
// Abstraction
interface PaymentProcessor {
charge(amount: number): Promise<PaymentResult>;
}
// Production implementation
class StripePaymentProcessor implements PaymentProcessor {
async charge(amount: number): Promise<PaymentResult> {
return this.stripe.charges.create({ amount });
}
}
// Test implementation
class MockPaymentProcessor implements PaymentProcessor {
async charge(amount: number): Promise<PaymentResult> {
return { success: true, transactionId: 'mock-123' };
}
}Composition Root
Dependencies should be composed at the application’s entry point (the “composition root”), not scattered throughout the codebase. This creates a single place where the object graph is constructed.
// composition-root.ts - Application entry point
function createApplication(): Application {
// Create infrastructure
const db = new PostgresConnection(config.database);
const emailClient = new SendGridClient(config.sendgrid);
// Create repositories
const orderRepository = new PostgresOrderRepository(db);
const userRepository = new PostgresUserRepository(db);
// Create services
const paymentProcessor = new StripePaymentProcessor(config.stripe);
const notifier = new EmailNotificationService(emailClient);
const orderService = new OrderService(orderRepository, paymentProcessor, notifier);
// Create controllers
const orderController = new OrderController(orderService);
return new Application([orderController]);
}Example
Without DI, a class creates its own dependencies:
// Tightly coupled - hard to test, inflexible
class OrderService {
private repository = new PostgresOrderRepository();
private payment = new StripePaymentProcessor();
async processOrder(order: Order): Promise<void> {
await this.payment.charge(order.total);
await this.repository.save(order);
}
}Problems:
- Can’t test without real database and Stripe
- Can’t switch to different database or payment provider
- Dependencies are hidden—hard to know what OrderService needs
With DI:
// Loosely coupled - testable, flexible
class OrderService {
constructor(
private readonly repository: OrderRepository,
private readonly payment: PaymentProcessor
) {}
async processOrder(order: Order): Promise<void> {
await this.payment.charge(order.total);
await this.repository.save(order);
}
}
// In tests: inject mocks
const service = new OrderService(new InMemoryOrderRepository(), new MockPaymentProcessor());
// In production: inject real implementations
const service = new OrderService(
new PostgresOrderRepository(db),
new StripePaymentProcessor(stripeKey)
);Common Mistakes
Injecting too many dependencies - If a class needs many dependencies, it might have too many responsibilities. Consider splitting it.
Using a service locator instead of DI - Service locators hide dependencies and make code harder to understand. Prefer explicit constructor injection.
// Anti-pattern: Service Locator
class OrderService {
processOrder(order: Order) {
const repo = ServiceLocator.get<OrderRepository>(); // Hidden dependency
// ...
}
}Injecting the container - Injecting the DI container itself defeats the purpose. Classes should receive their specific dependencies, not a way to get any dependency.
Not using interfaces - Injecting concrete classes instead of interfaces reduces flexibility and testability.
Related Concepts
- SOLID Principles - DI implements the Dependency Inversion Principle
- Coupling and Cohesion - DI promotes loose coupling
- Separation of Concerns - DI helps separate construction from use
References
- Martin, R. C. (2017). Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Chapter 11.
- Seemann, M., & van Deursen, S. (2019). Dependency Injection Principles, Practices, and Patterns.