Loading content...
The Dependency Inversion Principle (DIP) tells us what to achieve: high-level modules should not depend on low-level modules; both should depend on abstractions. But DIP is silent on how to actually wire abstractions to concrete implementations at runtime. This is where Dependency Injection (DI) enters the picture.
Dependency Injection is a technique—a precise mechanism—for providing objects with their dependencies from the outside rather than having objects create their dependencies internally. It is the practical realization of dependency inversion, transforming an architectural principle into executable code.
Without dependency injection, DIP remains theory. With it, DIP becomes a living architectural pattern that delivers testability, flexibility, and decoupling in production systems.
By the end of this page, you will understand the precise definition of Dependency Injection, how it differs from Dependency Inversion Principle, why external dependency provision is superior to internal creation, and the fundamental mechanics that make DI work. You'll see how a simple conceptual shift—from creating dependencies to receiving them—transforms code architecture.
Dependency Injection is a design technique in which an object receives its dependencies from external sources rather than creating them itself.
Let's break down this definition precisely:
Dependency: Any object, service, or resource that another object requires to perform its function. If class A needs an instance of class B to do its work, then B is a dependency of A.
Injection: The act of providing (or "injecting") the dependency from outside the dependent object. Rather than A creating B internally, something external creates B and gives it to A.
The term "injection" evokes the medical metaphor intentionally—something is introduced from outside rather than generated internally. Just as medicine is injected into a patient rather than the patient synthesizing it, dependencies are injected into objects rather than the objects manufacturing them.
Dependency Injection inverts control of dependency creation. The dependent object no longer controls which concrete implementation it uses—that decision is made externally. This inversion of control is so fundamental that DI is often discussed alongside the broader concept of Inversion of Control (IoC).
The fundamental shift:
Without DI:
Object A creates Object B internally → A controls B's lifecycle and concrete type
With DI:
Object A receives Object B from outside → External code controls B's lifecycle and concrete type
This simple inversion—from creating to receiving—has profound implications for software architecture. It enables:
A common source of confusion is conflating Dependency Injection (DI) with Dependency Inversion Principle (DIP). While closely related, they operate at different levels:
Dependency Inversion Principle (DIP): An architectural principle stating that high-level modules should depend on abstractions, not concrete low-level modules. DIP is about dependency direction—ensuring that dependencies point toward stable abstractions rather than volatile concretions.
Dependency Injection (DI): A technique for providing objects with their dependencies from external sources. DI is about dependency provision—the mechanics of how dependencies get into objects.
DIP tells you where dependencies should point. DI tells you how to get them there.
| Aspect | Dependency Inversion Principle (DIP) | Dependency Injection (DI) |
|---|---|---|
| Nature | Architectural principle | Implementation technique |
| Focus | Dependency direction (toward abstractions) | Dependency provision (from external sources) |
| Question Answered | What should depend on what? | How do dependencies reach their consumers? |
| Abstraction Level | Design-time architecture | Runtime wiring |
| Achievable Without Other | Yes—through factories, service locators | Yes—can inject concrete types directly |
| Optimal Combination | DIP + DI together = maximum flexibility | DI implementing DIP = ideal pattern |
Can you have DIP without DI?
Yes. You can follow DIP by depending on abstractions while using factories, service locators, or other patterns to obtain dependencies. However, these alternatives often introduce their own coupling or complexity.
Can you have DI without DIP?
Yes, and this is a common anti-pattern. You can inject concrete types directly:
// DI without DIP - dependency is injected but is a concrete type
class OrderProcessor {
constructor(private repository: MySQLOrderRepository) { }
}
Here, MySQLOrderRepository is injected (DI is used), but it's a concrete type (DIP is violated). The class still has a compile-time dependency on MySQL.
The ideal combination:
When DI is used to inject abstractions (interfaces), you achieve both principles:
// DI + DIP - dependency is injected AND is an abstraction
class OrderProcessor {
constructor(private repository: OrderRepository) { }
}
Now OrderProcessor depends only on the OrderRepository abstraction, and the concrete implementation is provided through injection. This is the gold standard.
DIP without DI requires alternative mechanisms (factories, locators) that introduce their own complexity. DI without DIP wastes the flexibility DI provides. The combination of DIP + DI delivers the full benefit: abstractions that are easy to implement, easy to test, and easy to swap.
To truly understand DI, we must understand the problem it solves: hard-coded dependencies.
Consider a typical class that creates its own dependencies:
12345678910111213141516171819202122232425262728
// ❌ Hard-coded dependency creationclass EmailNotificationService { private smtpClient: SmtpClient; private templateEngine: HandlebarsTemplateEngine; private logger: ConsoleLogger; constructor() { // Dependencies created internally this.smtpClient = new SmtpClient({ host: "smtp.company.com", port: 587, username: "service@company.com", password: process.env.SMTP_PASSWORD }); this.templateEngine = new HandlebarsTemplateEngine(); this.logger = new ConsoleLogger("EmailNotificationService"); } async sendOrderConfirmation(order: Order): Promise<void> { const html = this.templateEngine.render("order-confirmation", order); await this.smtpClient.send({ to: order.customerEmail, subject: "Order Confirmed", html }); this.logger.info(`Sent confirmation for order ${order.id}`); }}This code appears clean and functional, but it suffers from severe problems that compound over time:
EmailNotificationService requires an actual SMTP server. You cannot test the notification logic without also testing email delivery. Unit tests become integration tests.SmtpClient, HandlebarsTemplateEngine, and ConsoleLogger. Changing any of these requires changing this class.SmtpClient should be shared? What if Logger needs different configuration per environment?The testing problem in detail:
Imagine you want to write a unit test verifying that order confirmation emails include the correct order details:
// ❌ This test cannot verify behavior without sending real emails
test("order confirmation includes order total", () => {
const service = new EmailNotificationService();
const order = createTestOrder({ total: 99.99 });
// This actually sends an email!
// We have no way to verify what was sent.
// The test depends on SMTP server availability.
// We cannot check the email content.
await service.sendOrderConfirmation(order);
// What assertion goes here?
// We have no access to what was sent.
});
The core issue: when a class creates its dependencies, it creates all the problems those dependencies bring. Email sending, network calls, file I/O—all become inseparable from the logic you want to test.
Dependency Injection solves all the problems above by externalizing dependency creation. The class declares what it needs; external code provides it.
12345678910111213141516171819202122232425262728293031323334353637
// Abstractions (interfaces) define contractsinterface EmailClient { send(message: EmailMessage): Promise<void>;} interface TemplateEngine { render(templateName: string, data: unknown): string;} interface Logger { info(message: string): void; error(message: string, error?: Error): void;} // ✅ Dependencies are injected through constructorclass EmailNotificationService { constructor( private emailClient: EmailClient, private templateEngine: TemplateEngine, private logger: Logger ) { } async sendOrderConfirmation(order: Order): Promise<void> { try { const html = this.templateEngine.render("order-confirmation", order); await this.emailClient.send({ to: order.customerEmail, subject: "Order Confirmed", html }); this.logger.info(`Sent confirmation for order ${order.id}`); } catch (error) { this.logger.error(`Failed to send confirmation for ${order.id}`, error as Error); throw error; } }}Now examine how every problem is resolved:
SmtpEmailClient for SendGridEmailClient or SesEmailClient without touching EmailNotificationService.EmailClient is implemented.EmailClient instance can be shared across multiple services. Lifecycle is controlled externally.The testing solution in detail:
With DI, testing becomes trivial:
123456789101112131415161718192021222324252627282930313233343536373839404142
// ✅ Testing with injected mockstest("order confirmation includes order total in rendered template", async () => { // Arrange: create mock implementations const mockEmailClient: EmailClient = { send: jest.fn().mockResolvedValue(undefined) }; const mockTemplateEngine: TemplateEngine = { render: jest.fn().mockReturnValue("<html>Order: $99.99</html>") }; const mockLogger: Logger = { info: jest.fn(), error: jest.fn() }; // Inject mocks const service = new EmailNotificationService( mockEmailClient, mockTemplateEngine, mockLogger ); const order = createTestOrder({ id: "123", total: 99.99 }); // Act await service.sendOrderConfirmation(order); // Assert: verify exact behavior expect(mockTemplateEngine.render).toHaveBeenCalledWith( "order-confirmation", order ); expect(mockEmailClient.send).toHaveBeenCalledWith({ to: order.customerEmail, subject: "Order Confirmed", html: "<html>Order: $99.99</html>" }); expect(mockLogger.info).toHaveBeenCalledWith( "Sent confirmation for order 123" );});With DI, you have complete control over the test environment. No network calls, no external services, no flakiness. You verify exactly what the code does with its dependencies, isolated from how those dependencies actually work.
Every DI scenario involves three actors, each with a distinct role:
1. The Client (Dependent)
The object that needs dependencies to function. The client declares its dependencies (typically through constructor parameters) but doesn't create them. In our example, EmailNotificationService is the client.
The client's responsibility is to use dependencies, not to create them.
2. The Service (Dependency)
The object being depended upon. This provides capabilities the client needs. EmailClient, TemplateEngine, and Logger are services in our example.
Services implement interfaces (abstractions) that clients depend on. The client doesn't know which concrete service it receives—only that the service fulfills the interface contract.
3. The Injector (Assembler/Composer)
The entity that creates dependencies and provides them to clients. The injector knows which concrete implementations to use and wires the object graph together.
The injector might be:
123456789101112131415161718192021222324252627282930313233
// THE INJECTOR: wires dependencies together// This is the "composition root" where wiring decisions are centralized function createEmailNotificationService(): EmailNotificationService { // Create concrete services (dependencies) const emailClient = new SmtpEmailClient({ host: config.smtp.host, port: config.smtp.port, credentials: config.smtp.credentials }); const templateEngine = new HandlebarsTemplateEngine({ templatesPath: config.templates.path }); const logger = new WinstonLogger({ level: config.logging.level, transports: config.logging.transports }); // Inject services into client return new EmailNotificationService( emailClient, templateEngine, logger );} // In application startupconst notificationService = createEmailNotificationService(); // The CLIENT (EmailNotificationService) uses services// but doesn't know their concrete types| Actor | Responsibility | What It Knows | What It Doesn't Know |
|---|---|---|---|
| Client | Use dependencies to do work | Interface contracts | Concrete implementation types |
| Service | Fulfill the interface contract | Its own implementation details | Who is calling it |
| Injector | Create and wire object graph | All concrete types, configuration | Business logic details |
The power of DI comes from this separation of knowledge. The client knows only abstractions. The injector knows only wiring. Neither pollutes the other. This separation enables changing implementations, configurations, and even injectors without touching business logic.
Dependencies can be injected through several mechanisms, each with distinct characteristics:
Constructor Injection: Dependencies are passed through the constructor when the object is created. This is the most common and generally preferred approach. The object receives all dependencies at creation time and is immediately ready on use.
Setter Injection (Property Injection): Dependencies are provided through setter methods or assignable properties after construction. The object is created first, then dependencies are set individually.
Interface Injection: The dependent class implements an interface that defines a method for receiving the dependency. The injector calls this method to provide the dependency.
Each approach has specific use cases, advantages, and trade-offs. The following pages explore each in depth, but here's a quick comparison:
| Type | When Injected | Immutability | Required Dependencies | Optional Dependencies |
|---|---|---|---|---|
| Constructor | At creation time | Can be final/readonly | Excellent fit | Possible with overloads |
| Setter | After creation | Mutable by design | Requires validation | Natural fit |
| Interface | When injector calls method | Depends on implementation | Possible | Less common |
12345678910111213141516171819202122232425262728293031323334
// Constructor Injection - dependencies in constructorclass OrderService { constructor( private readonly repository: OrderRepository, private readonly logger: Logger ) { }} // Setter Injection - dependencies via settersclass OrderService { private repository!: OrderRepository; private logger!: Logger; setRepository(repo: OrderRepository): void { this.repository = repo; } setLogger(logger: Logger): void { this.logger = logger; }} // Interface Injection - dependency via interface contractinterface RepositoryInjectable { injectRepository(repository: OrderRepository): void;} class OrderService implements RepositoryInjectable { private repository!: OrderRepository; injectRepository(repository: OrderRepository): void { this.repository = repository; }}The next pages explore each injection type in exhaustive detail—mechanics, trade-offs, implementation patterns, error handling, and real-world applications. You'll develop the judgment to choose the right injection style for each situation.
A critical concept in DI is the Composition Root—the single location in an application where the object graph is composed. All wiring decisions happen here, and only here.
Definition: The Composition Root is the entry point of the application where dependency injection configuration is defined and the object graph is assembled. It is as close to the application's entry point as possible, and it is the only place that has knowledge of all concrete implementations.
The Composition Root is not:
The Composition Root is:
Main method, Startup class, or module initialization1234567891011121314151617181920212223242526272829303132333435363738394041
// ✅ COMPOSITION ROOT - main.ts or startup.ts// This is the only file that knows all concrete implementations import { SmtpEmailClient } from "../infrastructure/email/smtp-client";import { SendGridEmailClient } from "../infrastructure/email/sendgrid-client";import { PostgresOrderRepository } from "../infrastructure/persistence/postgres-order-repo";import { WinstonLogger } from "../infrastructure/logging/winston-logger";import { OrderProcessingService } from "../domain/order-processing-service";import { EmailNotificationService } from "../domain/email-notification-service";import { config } from "../config"; export function composeApplication(): Application { // 1. Create infrastructure services const logger = new WinstonLogger(config.logging); const emailClient = config.email.provider === "sendgrid" ? new SendGridEmailClient(config.email.sendgrid, logger) : new SmtpEmailClient(config.email.smtp, logger); const orderRepository = new PostgresOrderRepository(config.database, logger); // 2. Create domain services with dependencies const notificationService = new EmailNotificationService( emailClient, new HandlebarsTemplateEngine(config.templates), logger ); const orderService = new OrderProcessingService( orderRepository, notificationService, logger ); // 3. Create application facade return new Application(orderService, logger);} // In main.tsconst app = composeApplication();app.run();main() as possible, before any business logic runsWhy a single Composition Root matters:
If wiring decisions are scattered throughout the codebase, you lose the benefits of DI:
With a single Composition Root:
Avoid the temptation to create a global "container" that any class can query for dependencies. This "Service Locator" pattern reintroduces hidden dependencies—the opposite of what DI achieves. Classes should receive dependencies explicitly, not reach into a global bag to find them.
We've established the foundational understanding of Dependency Injection. Let's consolidate the essential concepts:
What's next:
Now that you understand what Dependency Injection is and why it matters, the following pages dive deep into each injection method:
Each page provides exhaustive coverage—mechanics, patterns, anti-patterns, testing strategies, and industry best practices.
You now understand Dependency Injection as a technique for providing objects with their dependencies from external sources. This foundational understanding prepares you to master the specific injection methods and develop the judgment to apply them effectively in production systems.