Loading learning content...
Dependency Injection is discussed in two distinct but related contexts. Sometimes it refers to a concrete technique—passing dependencies through constructors or setters. Other times it refers to a design philosophy—a principled approach to managing dependencies and structuring software.
This dual nature creates confusion. Developers may implement the technique without understanding the principle, leading to mechanical DI that misses deeper benefits. Conversely, understanding the principle without mastering the technique leaves engineers unable to apply DI effectively.
This page clarifies both perspectives, showing how they complement each other and when each viewpoint applies.
By the end of this page, you will understand DI as both a mechanical technique and a guiding principle. You'll know when to apply each perspective and how they combine to create genuinely flexible, maintainable software architectures.
At its most concrete, Dependency Injection is a technique for supplying dependencies to objects. The technique has specific mechanical characteristics:
Definition (Technique View):
Dependency Injection is a technique whereby one object (the injector) supplies the dependencies of another object (the dependent). The dependent does not create or locate its dependencies; it receives them from an external source.
This definition focuses on the how: the mechanics of passing dependencies. It's implementation-focused and directly applicable to code.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// ═══════════════════════════════════════════// TECHNIQUE 1: Constructor Injection// Dependencies passed through constructor// ═══════════════════════════════════════════class OrderProcessor { private readonly paymentService: IPaymentService; private readonly inventoryService: IInventoryService; // Dependencies declared as constructor parameters constructor( paymentService: IPaymentService, inventoryService: IInventoryService ) { this.paymentService = paymentService; this.inventoryService = inventoryService; } process(order: Order): ProcessResult { // Use injected dependencies const paymentResult = this.paymentService.charge(order.payment); const inventoryResult = this.inventoryService.reserve(order.items); return { payment: paymentResult, inventory: inventoryResult }; }} // Usage: Inject at construction timeconst processor = new OrderProcessor( new StripePaymentService(), new WarehouseInventoryService()); // ═══════════════════════════════════════════// TECHNIQUE 2: Setter Injection// Dependencies passed through setter methods// ═══════════════════════════════════════════class NotificationService { private emailProvider?: IEmailProvider; private smsProvider?: ISmsProvider; // Dependencies set after construction setEmailProvider(provider: IEmailProvider): void { this.emailProvider = provider; } setSmsProvider(provider: ISmsProvider): void { this.smsProvider = provider; } notify(message: Message): void { this.emailProvider?.send(message); this.smsProvider?.send(message); }} // Usage: Inject via settersconst notifier = new NotificationService();notifier.setEmailProvider(new SendGridProvider());notifier.setSmsProvider(new TwilioProvider()); // ═══════════════════════════════════════════// TECHNIQUE 3: Interface Injection// Dependency implementing an injection interface// ═══════════════════════════════════════════interface ILoggerInjectable { setLogger(logger: ILogger): void;} class MetricsCollector implements ILoggerInjectable { private logger!: ILogger; // Injection via interface method setLogger(logger: ILogger): void { this.logger = logger; } recordMetric(name: string, value: number): void { this.logger.log(`Metric recorded: ${name} = ${value}`); // ... metric recording logic }} // Usage: Inject via interfaceconst collector = new MetricsCollector();(collector as ILoggerInjectable).setLogger(new ConsoleLogger());Characteristics of the Technique View:
When we treat DI as a technique, we focus on:
This view is practical and implementation-oriented. It answers: 'How do I inject this dependency?' and 'What injection style should I use here?'
Beyond the mechanics, Dependency Injection represents a broader philosophy about how software should be structured. This philosophical view encompasses design intent, architectural decisions, and principles that transcend any specific implementation technique.
Definition (Principle View):
Dependency Injection is a design approach that promotes loose coupling by ensuring that objects depend on abstractions rather than concrete implementations, and that the responsibility for managing dependencies is externalized from the objects that use them.
This definition focuses on the why and the what: the goals we're trying to achieve and the constraints we impose on our designs.
Principle Drives Technique:
The principle informs which techniques to apply and when. A developer who understands only the technique might mechanically inject every collaborator. A developer who understands the principle knows why DI matters and can make thoughtful decisions about where it applies.
Consider this contrast:
A key insight from the principled view: inject volatile dependencies (things that change, need testing flexibility, or vary across environments) but directly reference stable dependencies (standard library utilities, well-tested framework components, pure functions). Not everything needs injection.
DI as a principle doesn't exist in isolation. It's part of a constellation of design principles that together guide well-structured software. Understanding these relationships deepens appreciation for when and why DI matters.
| Principle | Relationship to DI | How DI Supports It |
|---|---|---|
| Single Responsibility (SRP) | Separation of concerns | DI separates 'using dependencies' from 'creating dependencies'—each class has one responsibility, not object assembly |
| Open-Closed (OCP) | Extension without modification | DI enables new behaviors by injecting different implementations—the consumer code never changes |
| Liskov Substitution (LSP) | Substitutability guarantee | DI depends on LSP—injected implementations must be substitutable for their abstractions |
| Interface Segregation (ISP) | Focused interfaces | DI works best with focused interfaces—injecting a minimal dependency surface |
| Dependency Inversion (DIP) | Foundation for DI | DI is the primary technique for achieving DIP—abstractions enable injection |
DI and Inversion of Control:
Dependency Injection is often discussed alongside Inversion of Control (IoC). The relationship is hierarchical:
Inversion of Control (broad principle)
│
├── Dependency Injection (one form of IoC)
│ ├── Constructor Injection
│ ├── Setter Injection
│ └── Interface Injection
│
├── Template Method Pattern (another form of IoC)
│
├── Event-driven Programming (another form of IoC)
│
└── Plugin Architectures (another form of IoC)
IoC means inverting the traditional control flow—instead of your code calling framework code, framework code calls your code. DI is IoC applied specifically to dependencies: instead of objects controlling their dependencies, an external entity controls dependency provision.
All DI is IoC, but not all IoC is DI. A Template Method inverts control (subclass provides steps) but isn't about dependencies. Understanding this hierarchy clarifies that DI is one powerful application of a broader principle.
DI is most powerful when combined with the other SOLID principles. SRP keeps classes focused. OCP enables extension. LSP ensures substitutability. ISP creates minimal interfaces. DIP provides the abstraction foundation. Together, they create systems where DI delivers maximum value.
Understanding DI only as a technique leads to problematic patterns. Developers mechanically apply injection without understanding why, resulting in code that's structurally DI-compliant but fails to achieve DI's goals.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// ═══════════════════════════════════════════// ANTI-PATTERN 1: Injecting Stable Dependencies// ═══════════════════════════════════════════class ReportGenerator { constructor( private readonly logger: ILogger, // Reasonable - might switch implementations private readonly formatter: IFormatter, // Reasonable - multiple formatters possible private readonly dateLib: IDateLibrary, // WHY? - Date libraries rarely change private readonly mathLib: IMathLibrary, // WHY? - Math doesn't have 'implementations' private readonly stringLib: IStringLib // WHY? - String utilities are stable ) {}} // The abstraction for Math() accomplishes nothing:interface IMathLibrary { add(a: number, b: number): number; multiply(a: number, b: number): number;} // This 'implementation' just wraps built-in operations:class StandardMathLibrary implements IMathLibrary { add(a: number, b: number) { return a + b; } multiply(a: number, b: number) { return a * b; }} // ═══════════════════════════════════════════// ANTI-PATTERN 2: Interface for Every Class// ═══════════════════════════════════════════// Even when only ONE implementation will ever exist:interface IUserNameFormatter { format(user: User): string;} // Only implementation, will never be substituted:class UserNameFormatter implements IUserNameFormatter { format(user: User): string { return `${user.firstName} ${user.lastName}`; }} // Now we must maintain two files that say the same thing.// Changes require updating both the interface and implementation.// The interface provides no value—it's pure ceremony. // ═══════════════════════════════════════════// ANTI-PATTERN 3: Container for Simple Applications// ═══════════════════════════════════════════// A 3-class CLI tool doesn't need:container.register<IConfigLoader>('ConfigLoader', ConfigLoader);container.register<IFileProcessor>('FileProcessor', FileProcessor);container.register<IOutputWriter>('OutputWriter', OutputWriter);container.register<IApplication>('Application', Application); // When this would suffice:const app = new Application( new ConfigLoader(), new FileProcessor(), new OutputWriter());When DI becomes bureaucracy, developers start resenting it. They see interfaces as obstacles, injection as ceremony, and containers as complexity for complexity's sake. This happens when technique is applied without principled understanding of why and when DI helps.
Understanding the principle without mastering the technique creates different problems. Developers may appreciate why DI matters but fail to implement it correctly, leading to half-measures that don't deliver promised benefits.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// ═══════════════════════════════════════════// ANTI-PATTERN 1: Interfaces but Still Tight Coupling// ═══════════════════════════════════════════interface IPaymentGateway { processPayment(amount: Money): PaymentResult;} class OrderService { private gateway: IPaymentGateway; constructor() { // Understands interfaces matter, but still creates internally this.gateway = new StripeGateway(); // Defeat the purpose! }} // Interface exists but provides zero substitutability// Testing still requires Stripe connection// Environment switching still requires code changes // ═══════════════════════════════════════════// ANTI-PATTERN 2: Factory that Hides the Problem// ═══════════════════════════════════════════class PaymentServiceFactory { createGateway(): IPaymentGateway { // Hardcoded decision still exists, just relocated return new StripeGateway(); }} class OrderService { private gateway: IPaymentGateway; constructor() { // Factory used but problem just moved this.gateway = new PaymentServiceFactory().createGateway(); }} // ═══════════════════════════════════════════// ANTI-PATTERN 3: Late Binding Without Injection// ═══════════════════════════════════════════class OrderService { private gateway?: IPaymentGateway; // No constructor injection constructor() {} // Client must remember to call this setGateway(gateway: IPaymentGateway): void { this.gateway = gateway; } processOrder(order: Order): void { // Potential null/undefined if setter not called if (!this.gateway) { throw new Error('Gateway not configured!'); } this.gateway.processPayment(order.total); }} // Easy to use incorrectly:const service = new OrderService();service.processOrder(order); // CRASH - forgot to set gatewayThese patterns show developers who understand that coupling is bad and interfaces are good. But without proper DI technique, they've created the appearance of flexibility without the reality. Tests still need real implementations, environments still need code changes.
The goal is integration: principled understanding guiding technical implementation. When technique and principle unite, DI delivers its full value—flexible, testable, maintainable systems without unnecessary complexity.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
// ═══════════════════════════════════════════// PRINCIPLE: Volatile Dependency → Inject// TECHNIQUE: Constructor Injection (mandatory)// ═══════════════════════════════════════════interface IPaymentGateway { processPayment(amount: Money): PaymentResult;} interface IInventoryService { reserve(items: OrderItem[]): ReservationResult;} interface INotificationService { notify(customer: Customer, message: string): void;} class OrderProcessor { // All dependencies declared, all injected via constructor constructor( private readonly paymentGateway: IPaymentGateway, private readonly inventoryService: IInventoryService, private readonly notificationService: INotificationService ) {} async process(order: Order): Promise<ProcessingResult> { // OrderProcessor focuses on orchestration // It doesn't know or care about implementations const reservation = await this.inventoryService.reserve(order.items); if (!reservation.success) { return ProcessingResult.failed('Inventory unavailable'); } const payment = await this.paymentGateway.processPayment(order.total); if (!payment.success) { await this.inventoryService.release(reservation.id); return ProcessingResult.failed('Payment declined'); } await this.notificationService.notify( order.customer, 'Your order has been confirmed!' ); return ProcessingResult.success(order.id); }} // ═══════════════════════════════════════════// PRINCIPLE: Stable Dependency → Direct Reference// TECHNIQUE: No injection needed// ═══════════════════════════════════════════class OrderProcessor { constructor(/* volatile deps only */) {} process(order: Order): ProcessingResult { // Date utilities: stable, no injection needed const timestamp = new Date().toISOString(); // UUID generation: stable, no injection needed const orderId = crypto.randomUUID(); // JSON operations: stable, no injection needed const serialized = JSON.stringify(order); // Logger: possibly volatile, judgment call // If environments need different loggers, inject // If console.log suffices everywhere, don't }} // ═══════════════════════════════════════════// COMPOSITION: Where Wiring Happens// ═══════════════════════════════════════════// In main.ts or composition root:function createOrderProcessor(): OrderProcessor { // Environment-specific construction const gateway = process.env.PAYMENT_MODE === 'stripe' ? new StripeGateway(process.env.STRIPE_KEY) : new SquareGateway(process.env.SQUARE_KEY); const inventory = new WarehouseInventoryService( new DatabaseConnection(process.env.DB_URL) ); const notifications = new TwilioNotificationService( process.env.TWILIO_SID, process.env.TWILIO_TOKEN ); return new OrderProcessor(gateway, inventory, notifications);} // In test files:function createTestOrderProcessor(): OrderProcessor { return new OrderProcessor( new MockPaymentGateway(), // Instant, no network new MockInventoryService(), // Instant, no database new MockNotificationService() // Instant, no SMS API );}Unifying technique and principle requires a decision framework. Not every collaborator needs injection. The principle helps us decide; the technique helps us implement.
When uncertain, don't inject. It's easier to add injection later (refactoring to receive a dependency) than to remove it (hunting down all injection points and container configurations). Err on the side of simplicity; add DI when pain points emerge.
Dependency Injection operates at two levels—and mastery requires understanding both:
As a Technique:
As a Principle:
Neither perspective alone suffices. Technique without principle becomes mechanical ceremony. Principle without technique becomes good intent without delivery.
You now understand Dependency Injection as both a concrete technique and a guiding principle. In the next page, we'll explore the full benefits of DI—flexibility, testability, and maintainability—with deeper technical examples.