Dependency Injection
To understand dependency injection (DI), it is important to first have an initial idea of why you would want to use it.
When implemented correctly, DI increases flexibility, albeit for testing or increasing code reusability, and enables clear separation of functionality/concerns. This is also a good example of adhering to the Single Responsibility design principle.
TL;DR
In lieu of using a monolithic class where you include all needed functionality, use modular providers (or services) and pass them as arguments when instantiating a (client) class.
Example: Monolithic Class
We use our Alerter
class to send alerts to our users, via email and SMS:
Alerter.ts
import nodemailer from 'nodemailer'
class Alerter {
public emailFrom: string = '[email protected]'
async send({user, subject, message}) {
const emailSuccess = await this.sendEmail({
to: user.email,
from: this.emailFrom,
subject,
message
})
const smsSuccess = await this.sendSMS({
to: user.phone,
message: `${subject}: ${message}`
})
return {email: emailSuccess, smsSuccess: sms}
}
async sendEmail({to, from, subject, message}) {
// lots of email logic
}
async sendSMS({to, message}) {
// lots of SMS logic
}
}
We would use this class like this:
let alerter: Alerter = new Alerter()
const user = {
name: "John Smith",
email: "[email protected]",
phone: "12345551212"
}
await Alerter.send({user, subject: "Test Alert", message: "Hello World"})
While there's not anything technically wrong with this implementation, we would have to duplicate functionality if we ever wanted to send an email or SMS for any other reason, we would also need to instantiate the Alerter class when running tests for the email or SMS functionality.
Example: Dependency Injection
In order to be able to reuse our messaging functionality, and have better testing of said functionality, we'll modify our monolithic example above to use dependency injection. We'll start by breaking out the messaging functionality into their own service providers.
class Alerter {
emailFrom: string = "[email protected]"
emailService: EmailService
SMSService: SMSService
constructor({emailService, SMSService}) {
this.emailService = emailService
this.SMSService = SMSService
}
async send({user, subject, message}) {
const emailSuccess = await this.emailService.send({
to: user.email,
from: this.emailFrom,
subject,
message
})
const smsSuccess = await this.SMSService.send({
to: user.phone,
message: `${subject}: ${message}`
})
return {email: emailSuccess, sms: smsSuccess}
}
}
import nodemailer from 'nodemailer'
class EmailService {
public emailFrom: string = "[email protected]"
async send({to, from, subject, message}) {
// lots of email logic
return true
}
}
class SMSService {
async send({to, message}) {
// lots of SMS logic
return true
}
}
We would use the new class like so:
const emailService: emailService = new emailService();
const SMSService: SMSService = new SMSService();
const alerter: Alerter = new Alerter({emailService, SMSService})
const user = {
name: "John Smith",
email: "[email protected]",
phone: "12345551212"
}
await Alerter.send({
user,
subject: "Test Alert",
message: "Hello World"
})
But why?
Why go through all the trouble of separating things like this? Well, it certainly depends on the use case and size of your project, it is possible smaller projects would not benefit from dependency injection, but almost any larger project will.
Benefits during testing
Now, instead of using our EmailService
and SMSService
classes, we can provide pseudo/fake services that will never
cause
our Alerter
class tests to fail because of issues with the service classes themselves, it also means we don't need to
load any dependencies for the services (i.e. nodemailer
or SMS API libraries) when testing, nor do we need to worry
about configuring our services at all when all we are testing Alerter
class fakeMessagingService {
async send({}) {
return true
}
}
import Alerter from 'Alerter'
import FakeMessagingService from 'FakeMessagingService'
const fakeMessagingService: FakeMessagingService = new FakeMessagingService()
const alerter: Alerter = new Alerter({emailService: fakeMessagingService, SMSService: fakeMessagingService})
test('send alerts via email and SMS', async () => {
expect(await alerter.send({
user,
subject: "Test Alert",
message: "Hello World"
})).toBe({email: true, sms: true});
});
Benefits for developers
Extracting the messaging functionality into their own classes also allows us to separate our concerns, which is highly beneficial for developers during development. It is much cleaner and easier to navigate single purpose classes than monolithic classes. We also don't need to concern ourselves with code we aren't interested in at the time.
For example, if we are updating the SMS provider to Twilio from Voxeo, we wouldn't even need to open the Alerter
or EmailService
classes.
Utilizing dependency injection also allows multiple developers to work on (and test!) these classes without the inherent issues of a monolithic class in a single file.
DISCLAIMER: This is all pseudocode, I typed it all in MarkDown and haven't tested any of it :)