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 class Logger {
log(message: string): void {
console.log(message)
}
}
 
export class Mailer {
logger = new Logger()
sendMail(address: string, message: string): void {
this.logger.log(`Sending the message ${message} to ${address}`)
}
}
ts
export class Logger {
log(message: string): void {
console.log(message)
}
}
 
export class Mailer {
logger = new Logger()
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:

  1. Developers using the Mailer service have no control over how the Logger is constructed
  2. Alternate implementations of the Logger service (e.g. for testing) cannot be provided
  3. Changes to the Logger service may necessitate changes to the Mailer 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 class Logger {
log(message: string): void {
console.log(message)
}
}
 
export class Mailer {
constructor(readonly logger: Logger) {}
sendMail(address: string, message: string): void {
this.logger.log(`Sending the message ${message} to ${address}`)
}
}
 
const logger = new Logger()
const mailer = new Mailer(logger)
ts
export class Logger {
log(message: string): void {
console.log(message)
}
}
 
export class Mailer {
constructor(readonly logger: Logger) {}
sendMail(address: string, message: string): void {
this.logger.log(`Sending the message ${message} to ${address}`)
}
}
 
const logger = new Logger()
const mailer = new Mailer(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 interface Logger {
log(message: string): void
}
 
export interface Mailer {
sendMail(address: string, message: string): void
}
ts
export interface Logger {
log(message: string): void
}
 
export interface Mailer {
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
class ConsoleLogger implements Logger {
log(message: string): void {
console.log(message)
}
}
 
class ConsoleMailer implements Mailer {
constructor(readonly logger: Logger) {}
sendMail(address: string, message: string): void {
this.logger.log(`Sending the message ${message} to ${address}`)
}
}
 
const logger = new ConsoleLogger()
const mailer = new ConsoleMailer(logger)
ts
class ConsoleLogger implements Logger {
log(message: string): void {
console.log(message)
}
}
 
class ConsoleMailer implements Mailer {
constructor(readonly logger: Logger) {}
sendMail(address: string, message: string): void {
this.logger.log(`Sending the message ${message} to ${address}`)
}
}
 
const logger = new ConsoleLogger()
const mailer = new ConsoleMailer(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
class MockLogger implements Logger {
readonly messages: Array<string> = []
log(message: string): void {
this.messages.push(message)
}
}
 
class MockMailer implements Mailer {
readonly sentMail: Array<string> = []
constructor(readonly logger: Logger) {}
sendMail(address: string, message: string): void {
const email = `Sending the message ${message} to ${address}`
this.logger.log(email)
this.sentMail.push(email)
}
}
 
const mockLogger = new MockLogger()
const mockMailer = new MockMailer(mockLogger)
ts
class MockLogger implements Logger {
readonly messages: Array<string> = []
log(message: string): void {
this.messages.push(message)
}
}
 
class MockMailer implements Mailer {
readonly sentMail: Array<string> = []
constructor(readonly logger: Logger) {}
sendMail(address: string, message: string): void {
const email = `Sending the message ${message} to ${address}`
this.logger.log(email)
this.sentMail.push(email)
}
}
 
const mockLogger = new MockLogger()
const mockMailer = new MockMailer(mockLogger)