What is Dependency Injection?
On this page
Dependency injection is a design pattern used in software development that helps manage the dependencies between different components of an application. It allows developers to create loosely coupled code by passing dependencies to a class or function from an external source.
In simpler terms, instead of creating dependencies inside a component, dependency injection enables us to provide them from the outside, making code more flexible and easier to test and maintain. By using dependency injection, developers can easily swap out dependencies or change their behavior without modifying the component's code directly.
Illustrating the Problem
Let's assume we have a Mailer
service which depends upon the functionality provided by a Logger
service.
ts
export classLogger {log (message : string): void {console .log (message )}}export classMailer {logger = newLogger ()sendMail (address : string,message : string): void {this.logger .log (`Sending the message ${message } to ${address }`)}}
ts
export classLogger {log (message : string): void {console .log (message )}}export classMailer {logger = newLogger ()sendMail (address : string,message : string): void {this.logger .log (`Sending the message ${message } to ${address }`)}}
In the code above, we directly construct a Logger
within our Mailer
service. This tight coupling between the Logger
and Mailer
services introduces several problems:
- Developers using the
Mailer
service have no control over how theLogger
is constructed - Alternate implementations of the
Logger
service (e.g. for testing) cannot be provided - Changes to the
Logger
service may necessitate changes to theMailer
service
However, by making use of dependency injection we can decouple our Mailer
and Logger
services and solve the problems outlined above.
Decoupling the Dependency Graph
The first step to solving the problems outlined above is to decouple the construction of the Mailer
and Logger
services from one another.
ts
export classLogger {log (message : string): void {console .log (message )}}export classMailer {constructor(readonlylogger :Logger ) {}sendMail (address : string,message : string): void {this.logger .log (`Sending the message ${message } to ${address }`)}}constlogger = newLogger ()constmailer = newMailer (logger )
ts
export classLogger {log (message : string): void {console .log (message )}}export classMailer {constructor(readonlylogger :Logger ) {}sendMail (address : string,message : string): void {this.logger .log (`Sending the message ${message } to ${address }`)}}constlogger = newLogger ()constmailer = newMailer (logger )
Now instead of constructing the Logger
service within the Mailer
service, we construct the Logger
externally and pass it to the constructor of the Mailer
service.
This pattern of inverting control of a service to the user is commonly known in software engineering as Inversion of Control.
This gives the developer much more control over how the dependencies within their application are constructed and composed together into a dependency graph.
Using Service Interfaces
Though we have improved the coupling between our Mailer
and Logger
services in the example above, there is still a problem with our code - we can only have a single implementation of our Mailer
and Logger
services.
But what if we want to provide a different implementation of Logger
to the Mailer
service when we are running our application's test suite?
We can take things a step further and allow for multiple implementations of our services by decoupling the interface of our services from the implementation of the service.
Define the interface of our services
First, we define the interface, or behavior, that our services should expose:
ts
export interfaceLogger {log (message : string): void}export interfaceMailer {sendMail (address : string,message : string): void}
ts
export interfaceLogger {log (message : string): void}export interfaceMailer {sendMail (address : string,message : string): void}
Create concrete service implementations
Then, we bind the actual implementation of these services to the interfaces we have defined:
ts
classConsoleLogger implementsLogger {log (message : string): void {console .log (message )}}classConsoleMailer implementsMailer {constructor(readonlylogger :Logger ) {}sendMail (address : string,message : string): void {this.logger .log (`Sending the message ${message } to ${address }`)}}constlogger = newConsoleLogger ()constmailer = newConsoleMailer (logger )
ts
classConsoleLogger implementsLogger {log (message : string): void {console .log (message )}}classConsoleMailer implementsMailer {constructor(readonlylogger :Logger ) {}sendMail (address : string,message : string): void {this.logger .log (`Sending the message ${message } to ${address }`)}}constlogger = newConsoleLogger ()constmailer = newConsoleMailer (logger )
Providing other service implementations
Now, as long as we adhere to the interface specified by our services, we can easily create alternate implementations.
For example, we can create mock implementations of the Logger
and Mailer
services to use in our tests which internally track all messages logged and sent by the services:
ts
classMockLogger implementsLogger {readonlymessages :Array <string> = []log (message : string): void {this.messages .push (message )}}classMockMailer implementsMailer {readonlysentMail :Array <string> = []constructor(readonlylogger :Logger ) {}sendMail (address : string,message : string): void {constmessage } to ${address }`this.logger .log (this.sentMail .push (}}constmockLogger = newMockLogger ()constmockMailer = newMockMailer (mockLogger )
ts
classMockLogger implementsLogger {readonlymessages :Array <string> = []log (message : string): void {this.messages .push (message )}}classMockMailer implementsMailer {readonlysentMail :Array <string> = []constructor(readonlylogger :Logger ) {}sendMail (address : string,message : string): void {constmessage } to ${address }`this.logger .log (this.sentMail .push (}}constmockLogger = newMockLogger ()constmockMailer = newMockMailer (mockLogger )