Dependency Injection Explained
Dependency Injection solves the problem of creating objects with complex dependencies.
When I was interviewed for a company, they asked me whether I knew what Dependency Injection was. I had no idea what it was and could not answer the question in a meaningful way.
I always look at interviews as a learning opportunity to see which areas I lack the most. That is why I searched the web and tried to figure out what the hell is Dependency Injection.
There are several links at the end of the post for the curious ones who want to go deep into the topic. As I am just writing this post for myself to understand the topic better, the technical level might be low. Also, the Dependency Injection pattern is mostly for object-oriented programming languages. However, since I am not much familiar with them, I tried to write examples in Typescript.
Let’s say that you are writing a logger for a server application. You use this logger if something goes wrong on the server. You can implement a code like this:
const eventLogger: void = (message: string) => {
// log the event...
}class Logger {
action = null notify(message: string) {
if (this.action === null) {
this.action = eventLogger
}
this.action(message)
}
}const logger = new Logger()
logger.notify('stuff') // logs the event
There is nothing wrong with this code. However, what if we also want to send emails for several errors? We cannot change the this.action
to another function.
Also, we use eventLogger
inside our class. We are now not only dependent on our Logger
class but also the eventLogger
function.
Another important problem is that writing tests for the Logger
class got harder. When we write a test for the Logger
class, we should consider and cover the eventLogger
function as well.
To solve these problems, we will use the dependency injection technique. Rather than communicating with our functions directly, we will abstract them, and our class will be aware of only the interface of these functions.
Dependency injection pattern gives us several advantages:
1- We can change the dependencies without changing the class that uses them.
2- Separation of creation of a function and use of it.
3- We can mock the dependencies and use them to test the class.
Modify the functions
First, we will create an interface called INotificationAction. This is a function interface and takes one string input and returns nothing.
interface INotificationAction {
(message: string): void
}
Our functions will use this interface.
const emailSender: INotificationAction = (message: string) => {
// send email
}const SMSSender: INotificationAction = (message: string) => {
// send SMS
}
There are three ways where we can inject our dependencies into our class.
1- Constructor Injection
In class creation, we inject our dependencies. This method is great when we know that the class will use the same function for the entire time.
class Logger {
action: INotificationAction constructor(action: INotificationAction) {
this.action = action
} notify(message: string) {
this.action(message)
}
}const logger = new Logger(emailSender)
logger.notify('stuff') // sends email
2- Method Injection
In this method, we pass our dependencies in each method call. If we want to send SMS rather than email, we simply replace the action argument.
class Logger {
notify(action: INotificationAction, message: string) {
action(message)
}
}const logger = new Logger()
logger.notify(emailSender, 'stuff') // sends email
logger.notify(SMSSender, 'stuff') // sends SMS
3- Property Injection
In this approach, we pass our action method to our class via a setter property.
This approach is great when we do not want to pass the action method each time but want the ability to change the function at any point.
class Logger {
action: INotificationAction set action(action: INotificationAction) {
this.action = action
} notify(message: string) {
this.action(message)
}
}const logger = new Logger()
logger.action = emailSender
logger.notify('stuff') // sends email
logger.action = SMSSender
logger.notify('stuff') // sends SMS