6 min read

Easy and fun dependency injection with TypeScript: Creating order from chaos

“In the beginning, there was only Chaos… then out of the void, appeared [the dependency inversion principle].”

I found myself quoting these very words recently after refactoring a project of mine that I hadn’t touched in months. After a while of receiving and then promptly ignoring user requests for new features, I had decided to finally revisit it and give the people what they wanted. Unfortunately though, what I came back to was an impenetrable rat’s nest of imports and function calls, written by someone who presumably did not give a metaphorical “rat’s ass” about SOLID principles or testability.

Here’s a small sample of what I was up against. In this function, we are sending one of those “magic link” emails that lets someone login to their account with a single click. It’ll also create someone’s account for them if we receive an email address we don’t have in our system yet. How convenient!

import config from "@server/config";
import { buildLoginEmail } from "@server/emails/log-in";
import mailer from "@server/services/mailer";
import { getOrCreateUserFromEmail } from "./accounts";
import { User } from "@prisma/client";

export async function sendLogInEmail(email: string) {
  const user = await getOrCreateUserFromEmail(email)
  const token = await createAuthToken(user.id)

  // Build login url
  const params = new URLSearchParams({
    token: encodeURIComponent(token)
  })
  const loginUrl = new URL('/api/auth/email',
    config.get('appUrl')).toString() + '?' + params.toString()

  const loginEmail = buildLoginEmail({loginUrl})
  return mailer.sendMail({
    ...loginEmail,
    from: 'Wordler <no-reply@wordler.xyz>',
    to: user.email
  })
}

To be fair, this block of code maybe isn’t as hellish as I originally remembered. But it does pose a few challenges in terms of reusability and testability, and thus will serve just fine for the purpose of this write-up.

Firstly, it’s doing too much. This function is called “send log in email”, but it also has a fun side-effect of potentially creating a new user account. This makes it difficult to test the email generation logic on its own without first needing to setup a database. And if we decided we wanted to send a log in email for an account that we’ve already loaded from the database somewhere else? Nah, you’re querying for it again, whether you like it or not.

Secondly, it’s importing its own dependencies, via what are essentially global variables assigned by the import keyboard. This will make testing this function even more difficult, as we would have to ensure that those dependencies are mocked before this function uses them, or somehow mess with the import system in some nasty way to get it to point to mock versions.

So, before I began adding new functionality, I wanted to make sure that I wouldn’t be consumed by the chaos void created from this tight coupling of components that I would need to re-use, and I wanted to add some tests for the existing functionality to make sure I wasn’t breaking things as I worked. In short, I needed to:

  • Refactor the existing code to make it more reusable for the new stuff I would be building.
  • Make the code testable, so that I could actually write tests for it.

It turns out that these two objectives go hand-in-hand: well structured code is  testable, and testable code is well structured. It also turns out these two things are what dependency injection was basically born to do.

Why DI

At its essence, I think of dependency injection as the practice of programming against some interface, and then injecting an implementation of that interface when needed. There’s a lot more to it than that of course- if you’re interested in diving deeper into theory, I highly recommend you check out this article that has been aptly titled Inversion of Control Containers and the Dependency Injection pattern by our favorite Java Head, Martin Fowler.

But we like to be as practical as possible here on coquito.io, so let’s dig into some code. We're going to see how we can make that send log in email function more modular using dependency injection, using my favorite package for handling DI in TypeScript, TypeDI. Let's start by breaking up our concerns into separate "service" classes:

import { Service } from 'typedi'
import { Account } from '@prisma/client'

// type Account = { email: string; id: string; }

@Service()
class AccountService {
	public async function findOrCreateWithEmail(email: string): Promise<Account> {}
}

@Service()
class AuthService {
	public async function sendLogInEmail(account: Account): Promise<void> {}
}

We've identified two areas of concern: managing user accounts, and managing authentication. You may have also noticed those little @Service() decorators applied to each class: these tell TypeDI that we want these service classes to be auto-injected when we specify any of them as a dependency- more on that in a bit.

These services have dependencies of their own: a database connection, and a way to send emails. Let's add services for those now:

import { Service } from 'typedi'
import { PrismaClient } from 'prisma'
import { Transporter, createTransport } from 'nodemailer'

@Service()
class DatabaseService {
	protected _connection: PrismaClient

	constructor() {
    		this._connection = new PrismaClient()
  	}
  
	public get connection() {
  		return this._connection
  	}
}

@Service()
export class MailerService {
	private _transport: Transporter

	constructor() {
        this._transport = createTransport({
            host: "smtp.sendgrid.net",
            port: 465,
            secure: true,
            auth: {
            user: "apikey",
            pass: process.env.SENDGRID_API_KEY,
            },
        })
    }
    
    public get transport() {
		return this._transport
    }
}

Let's use these new services to implement our account and auth services:

import { Service } from 'typedi'
import { Account } from '@prisma/client'

import { DatabaseService} from './database-service'
import { MailerService } from './mailer-service'

@Service()
class AccountService {
	constructor(private databaseService: DatabaseService) {}

	public async function findOrCreateWithEmail(email: string): Promise<Account> {
    		const existingAccount = this.databaseService.connection.account.findFirst({ /* ... */ })
            // Return existing account or create a new one
    }
}

@Service()
class AuthService {
	constructor(private mailerService: MailerService) {}

	public async function sendLogInEmail(account: Account): Promise<void> {
            // TODO: Generate auth token and login url... 
            	await this.mailerService.sendEmail({ email: account.email, /* ... */ })
    }
}

Wow! Look look at that separation of concerns. Now the account service is only worried about managing accounts, and the authentication service is only worried about handling authentication. Let's see how this all comes together in an Express handler:

import { Container } from 'typedi'
import { AccountService, AuthService } from './services.ts' 

const authenticateUser = (req, res) => {
	const accountService = Container.get(AccountService)
    const authService = Container.get(AuthService)
    
    const account = await accountService.findOrCreateWithEmail(req.body.email)
    await authService.sendLogInEmail(account)
    
    res.json({ sent: true })
}

Here, we've defined our handler as a plain old function, and are retrieving instances of our services with TypeDI's Container API.

TypeDI works by registering anything you've decorated with @Service() into a global "container". These services are treated as singletons, and are only instantiated the first time you request them using Container.get(ServiceClass) . TypeDI will also auto-inject any services that are specified as a dependency in another service's constructor.

This makes managing all of our dependencies a breeze, as it means we don't need to manually construct our services and their dependencies, along with the dependencies of those dependencies. This is happening here for AccountService and AuthService , where each automatically received an instance of DatabaseService and MailerService.

You can of course instantiate your own services manually, since they are just classes. TypeDI also allows you to replace a registered service with a different implementation, via Container.set(ServiceClass, NewImplementation). This is particularly useful for testing.

Better testing

Now it's time to take a look at how this pattern is useful for testing. A common task in test code is swapping or mocking dependencies that talk to external resources, like a database or an email transport. Because our services are only relying on the interfaces of other services and not directly importing their own implementations, this is fairly straightforward. Here's an example, with Jest:

import { AuthService, MailerService } from 'typedi'
import { Account } from '@prisma/client'
import { describe, it, expect, jest } from '@jest/globals'

describe('AuthService.sendLoginEmail', () => {
	it('should send an email', async () => {
    		// Arrange
        const account: Account = {
        	id: 'test_id'
        	email: 'test@test.net'
        }
        const mailerService = Container.get(MailerService)
        const sendMailSpy = jest.spyOn(mailerService.transport, 'sendMail').mockResolvedValueOnce({})
        const authService = new AuthService(MailerService)
        
        // Act
        await authService.sendLoginEmail(account)
        
        // Assert
        expect(sendMailSpy).toHaveBeenCalledTimes(1)
        // TODO: Assert the email body is correct and whatnot
        
    })
})

Wonderful. Powerful, even.

Alternative approaches

For political reasons I should probably mention some alternative packages besides TypeDI. In my experience TypeDI is the most straightforward to work with, but you should shop around and decide for yourself:

And also, the example in this post is using classes, but this concept can also be applied using functions, where they would accept their dependencies as arguments. I am personally very satisfied with this class-based approach though, so I'll let you figure out the intricacies of using functions instead on your own :).

Summing up

I hope I’ve convinced you of the magic of dependency injection, and of how powerful a tool it can be for helping you write code that is well structured, composable, and easy to test.

If you’re struggling to tame your own personal rat’s nest of imports and coupled functions, I highly recommend giving dependency injection a shot (ha!)