Dependency Injection

/
63

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 = 'hello@l422y.com'

    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: "jsmith@testmail.com",
    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 = "alerts@testmail.com"

    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 = "default@testmail.com"

    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: "jsmith@testmail.com",
    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 :)


TOY MODE
π