Dependency injection with Node.js, Express.js and TypeScript

9 September 2020

Web frameworks like Spring and ASP.NET Core have dependency injection solutions built in. Unfortunately, that’s not the case for most Node.js web frameworks, including Express.js. Let’s find out what exactly is dependency injection, what are its benefits and how to build a simple but effective dependency injection solution in Express.js with the help of TypeScript.

What is dependency injection?

Dependency injection is a solution to implement Inversion of Control, which is one of the SOLID principles (D: Dependency inversion). Very roughly, the Dependency inversion principle states that functions or classes shouldn’t be concerned with building / instantiating their dependencies. Instead, they should receive their dependencies as arguments and let other code deal with setting up the dependency.

Dependency injection is the group of techniques used to enable a class or function to specify what dependencies they need without needing to worry about the details on how to get or create each dependency.

Why use it?

Dependency injection decouples your code which makes your application more adaptable to changing requirements. It also has the added benefit of making your code easier to test. We’ll use a realistic, but hypothetical scenario to see the benefits of dependency injection:

You’re building a REST API that’s written with Node/Express and TypeScript. It’s been running for a few months, gaining users, and now the business wants to enable customers to upgrade to a premium account. Your next task in the backlog is to integrate with a payment gateway. Your Product Manager has added a note that we should first integrate with the Stripe payment gateway but it’s likely the business will change to another payment gateway in a few months (for the record, I ❤️ Stripe, this is just a hypothetical 🙂). You also want to make sure that you don’t accidentally cause a real payment transaction when you are running code on your local machine.

Example without dependency injection

Our Express application has a root app.ts which creates an instance of the Express server and connects our routes to HTTP requests:

/* --- app.ts --- */
import express from "express";
import { upgradeAccountRoute } from "./routes/accounts";

const app = express();

// ... authentication middleware etc.

app.post("/account/upgrade", upgradeAccountRoute);

// ...

Our routes are in separate files within a routes/ directory. We will create a new route for users to upgrade their account to a paid plan.

/* --- routes/accounts.ts --- */
import { Request, Response } from "express";

export function upgradeAccountRoute(req: Request, res: Response) {
	// TODO: Charge user monthly via a payment gateway
	res.json({ ok: true });
}

In this route we want to use the Stripe payment gateway to charge the customer. To keep things simple, we won’t worry about the actual Stripe code, we’ll pretend it’s already written in a file at services/stripe.ts. We need to use the Stripe code in our upgrade route to charge the customer once a month:

/* --- routes/accounts.ts --- */
import { Request, Response } from "express";
// vv: note a direct reference to the Stripe code 
import * as stripe from "../services/stripe";



export async function upgradeAccountRoute(req: Request, res: Response) {
	// ...
	const currentUser = getCurrentUser(req);
	const premiumPlanCost = 9.0;

	const result = await stripe.chargeUserMonthly(
		currentUser.id, 
		premiumPlanCost
	);

	// ...

	res.json({ success: true });
}

Again, to try and keep things simple, assume getCurrentUser is an existing function that returns a user object determined by some state in the request (such as JWT token or API key) and premiumPlanCost wouldn’t normally be hard-coded in the route.

While this implementation works, there are few issues with it:

Tight coupling 👎

In our scenario the business intends to move from Stripe to a different payment gateway in a few months. However, upgradeAccountRoute is tightly coupled to the Stripe module. In other words, if we change or remove the Stripe module we also need to change upgradeAccountRoute. It’s not a huge issue if the Stripe module is only used in one location. However, in our hypothetical it would be used in many other locations as well, including:

  • in user settings to update credit card details
  • canceling recurring billing if the user downgrades their plan
  • admin panels for reporting
  • dealing with failed transactions
  • Etc.

Now changing or removing the Stripe module becomes difficult and error prone as you need to alter many files. You would also need to change all these files when working locally to avoid calling Stripe accidentally.

Harder to test 👎

Any tests on code involving payments will need to mock the entire services/stripe.ts module because the functions import the Stripe module directly. While this is possible, it’s more difficult and error prone than other options we’ll look at later.

Example with dependency injection

It’s often joked that good developers are ‘lazy’ because they will try to write code that enables them to do less work in the future. In the example, we are definitely not being ‘lazy’. When the time comes to remove Stripe for another payment gateway we would need to change every single file that deals with payments.

Ideally we could implement the payment gateway code in a way that is de-coupled. That is, with no direct reference to the payment gateway code in other parts of our application. That’s what dependency injection does for us. It will allow us to swap out the Stripe code with another payment gateway while only changing a couple of files. To implement dependency injection into our app we will need to use a few language features and techniques. They include:

  • TypeScript interfaces
  • Function composition
  • Dynamic default parameters

TypeScript interfaces

Interfaces in TypeScript are very similar to other languages such as Java and C#. An object that implements an interface promises to implement the properties and functions defined in that interface. This allows us to safely pass different objects to a function as long as they all implement the same interface.

In our scenario we want a way to easily change between using Stripe or another payment gateway without modifying all the code that deals with payments. We can do this by creating a PaymentService interface:

interface PaymentService {
	// ...
	chargeUserMonthly: (userId: number, monthlyAmount: number) => Promise<boolean>
}

Now our routes can assume they will receive PaymentService object. It won’t matter if the object interacts with Stripe or another payment gateway so long as they both implement the PaymentService interface correctly. Like this basic function:

async function chargeUser(paymentService: PaymentService) {
	await paymentService.chargeUserMonthly(//...);
}

chargeUser() doesn’t reference Stripe at all. It doesn’t know or care if the payment gateway is Stripe or another gateway. All that matters is that the object it receives has a function called chargeUserMonthly as defined in the PaymentService interface.

Now we have a generic interface to use for all payment gateways, ideally we would like to receive the paymentService object as an argument in our route function, just like the chargeUser() function above. E.g. something like this:

export async function upgradeAccountRoute(
	req: Request, res: Response, 
	paymentService: PaymentService
	) {
	// ...

	const result = await paymentService.chargeUserMonthly(
		currentUser.id,
		premiumPlanCost
	);

	// ...
}

Unfortunately, this will not work. If we look at app.ts we supply the upgradeAccountRoute function directly to Express’ post function:

/* --- app.ts --- */
// ...
const app = express();
// ...
app.post("/account/upgrade", upgradeAccountRoute);
// ...

However, express is only expecting a function with that has two arguments (Request and Response) with an optional third argument (NextFunction, a.k.a. next()). It isn’t expecting to provide a paymentService object and therefore it will throw an error. Luckily, there’s a solution to this problem called function composition.

Function composition

Function composition involves making functions that create other functions with customised behaviour. It’s easier to explain with a simple example.

Let’s say we wanted to make two functions, one that doubles a given number and one that triples a number. The use-case is trivial but let’s keep it simple to focus on the code:

function double(value: number): number {
	return value * 2;
}

function triple(value: number): number {
	return value * 3;
}

double(4); // 8
triple(4): // 12

It’s not obvious because this is a basic example, but there is quite a lot of code duplication with theses two functions. In fact, the only difference is the function name and the number literal used to multiply the input value. Another approach to keep things DRY is using function composition:

function createMultiplier(multiple: number): (value: number) => number {
	return (value: number) => value * multiple;
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

double(4); // 8
tripe(4); // 12

createMultiplier returns a function that’s behaviour is customised based on the multiple argument it receives. This allows us to create functions with custom logic based on shared code. The benefit is minor in such a trivial example, but hopefully, you can see the benefit. For example, let’s say we also wanted a function to square a value. With function composition you can reuse createMultipler again like so:

const square = (value: number) => createMultiplier(value)(value);
square(4); // 16

As a side note: because createMultiplier is a function that returns another function, it’s considered a Higher-order function.

Function composition and dependency injection

We can use function composition to pass our dependency (the PaymentService) as an argument to a higher-order function that in turn returns a custom Express.js route function:

/* --- routes/accounts.ts --- */
import { Request, Response, NextFunction } from "express";

type ExpressRouteFunc = (req: Request, res: Response, next?: NextFunction) => void | Promise<void>;

export function upgradeAccountRoute(paymentService: PaymentService): ExpressRouteFunc {
	return async function(req: Request, res: Response) {	
		// ...
		const result = paymentService.chargeUserMonthly(
			currentUser.id,
			premiumPlanCost
		);
		// ...
	}
}


/* --- app.ts --- */
import express from "express";
import { upgradeAccountRoute } from "./routes/accounts";
import * as stripePaymentService from "./services/payment-service/stripe";

const app = express();

// ...

app.post("/account/upgrade", upgradeAccountRoute(stripePaymentService));


Notice that the function parameters of the Express.js route function have not changed and instead paymentService has been ‘injected’ into the express route. Now if we want to change the payment service from, say, Stripe to another payment gateway, we only have to change the references in one file (app.ts) rather than every single file that uses the payment service. This is a big improvement, but it still means there would be logic in app.ts to decide which implementation of PaymentService to send to each route. Case in point, let’s say we want to make sure no credit card charges get triggered accidentally while we are developing on our local machine. So, we will only use a ‘real’ implementation if the value of a NODE_ENV environment variable is “production”. Otherwise, we will use a ‘fake’ payment service that just logs when it has been called.

/* --- app.ts ---  */
import fakePaymentService from "./services/payment-service/fake";
import stripePaymentService from "./services/payment-service/stripe";
// ...

const isProduction = process.env["NODE_ENV"] === "production";


const paymentService = isProduction ? stripePaymentService : fakePaymentService;

// ...

app.post("/account/upgrade", upgradeAccountRoute(paymentService));

Two steps forward, one step back. Now app.ts is breaking the Single-Responsibility Principle as it’s concerned with both linking routes to HTTP requests and deciding which payment service to send to the routes. We could improve this a little with a refactor and move the payment service decision logic into its own file. However, we would still need to pass the payment service to each route that needs it as an argument. Ideally we could abstract that out of app.ts too. Thankfully, that’s also possible with default parameters.

Default parameters

Default parameters in TypeScript and JavaScript allow us to set a default value for a function’s argument without the need to pass a value to the function each time:

type TimeOfDay = "morning" | "afternoon" | "evening";

function greetUser(timeOfDay: TimeOfDay, name = "Guest"): string {
	// ^ The type of `name` gets inferred as `string` from its default value
	return `Good ${timeOfDay} ${name}`;
}

greetUser("morning"); // "Good morning Guest"
greetUser("morning", "Samantha"); // "Good morning Samantha"

This is handy but what’s interesting is that default parameters can also be functions that get called to retrieve the default value when needed. This enables the default value to be dynamic:

type TimeOfDay = "morning" | "afternoon" | "evening";

function getCurrentTimeOfDay(): TimeOfDay {
	const currentHour = new Date().getHours();
	if(currentHour > 0 && currentHour < 12) {
		return "morning";
	}
	if(currentHour >= 12 && currentHour < 18) {
		return "afternoon";
	}
	return "evening";
}

function greetUser(timeOfDay = getCurrentTimeOfDay(), name = "Guest"): string {
	// ^ get a dynamic TimeOfDay injected from the default parameter function
	return `Good ${timeOfDay} ${name}`;
}

greetUser(); // "Good afternoon Guest" (assuming it's currently the afternoon)
greetUser("morning"); // "Good morning Guest"
greetUser("evening", "Samantha"); // "Good evening Samantha"

Taking advantage of dynamic default parameters will allow us to clean up our dependency injection code even further.

Combining default parameters with dependency injection

To recap, we have an interface called PaymentService which has just one function that we’re concerned with:

interface PaymentService {
	chargeUserMonthly: (userId: number, monthlyAmount: number) => Promise<boolean>
}

We want to use an implementation of PaymentService without explicitly passing it to the Express.js route functions each time. There are two modules that implement PaymentService, one that uses Stripe (stripePaymentService) and another for development purposes that doesn’t actually do anything but log when it’s used (fakePaymentService). We’re currently injecting this service to the routes via function composition, but we still have to set the payment service argument for each route in app.ts.

Let’s start by moving the logic to select the right PaymentService out of app.ts and into a new file named service-injection.ts:

/* --- services/service-injection.ts --- */
import * as fakePaymentService from "./payment-service/fake";
import * as stripePaymentService from "./payment-service/stripe";

const isProduction = process.env["NODE_ENV"] === "production";

export function getPaymentService(): PaymentService {	
	return isProduction ? stripePaymentService : fakePaymentService;	
}

You might have noticed that getPaymentService looks a lot like a Factory Pattern and very broadly that’s correct. The Factory Pattern has a bad reputation in some developer circles which I believe comes from its overuse and complex implementations in enterprise Java/C# applications. However, the Factory Pattern has its place and can help keep code modular when used sparingly and in its simplest form (i.e. no AbstractFactory and FactoryImpl classes here!).

The getPaymentService function can now be used as a default parameter in our routes for paymentService:

/* --- routes/accounts.ts --- */

import { Request, Response, NextFunction } from "express";
import { getPaymentService } from "../services/service-injection";

// ...

// `paymentService` is inferred as `PaymentService` from its default parameter
export function upgradeAccountRoute(paymentService = getPaymentService()) {
	return async function(req: Request, res: Response) {
		// ... 
	const result = await paymentService.chargeUserMonthly(
		userId,
		premiumPlanCost
	);
}

De-coupled code 👍

With the use of default parameters in our route functions we no longer need to explicitly send the payment service as an argument. This means there’s no longer any reference to the payment service in app.ts:

/* --- app.ts --- */
import express from "express";
import { upgradeAccountRoute } from "./routes/accounts";

const app = express();

// ...
app.post("/account/upgrade", upgradeAccountRoute());

In other words, the payment gateway code is decoupled from the rest of the application. When we change the payment gateway code to use another vendor we don’t need to change every file that interacts with payments, just service-injection.ts

Easier testing 👍

Dependency injection has also made it easier to test our code as an added bonus. I’ve used the Jest in the examples but it applies to all testing frameworks.

Testing route functions

Testing routes directly is now straightforward as we don’t need to do any mocking of modules. All we need to do pass in an argument for paymentService that is a fake implementation of PaymentService (such as a mock or fake). A test for the upgradeAccountRoute would look something like this:

/* --- routes/accounts.test.ts --- */
import { upgradeAccountRoute } from "./accounts";

const fakePaymentService: PaymentService = {
	chargeUserMonthly: jest.fn()
};

describe("upgradeAccountRoute", () => {
	const mockRequest = jest.fn();
	// ... setup the mock request to fake authentication
	const mockResponse = jest.fn();

	describe("when valid data is in the request", () => {
		before(() => {
			const route = upgradeUserRoute(fakePaymentService);
 			route(mockRequest, mockResponse);
		});


		it("sets the response status to 200 OK", () => {
			// ...
		});

		it("sets up charging the user $9 each month", () => {
			expect(fakePaymentService.chargeUserMonthly)
				.toHaveBeenCalledWith(1, 9.0);
		})
	});
});

Integration testing

When it comes to integration testing of the API, we still need to mock a module, however now we can just override the getPaymentService in service-injection.ts. It then returns a fake or mock object that implements the PaymentService interface:

/* --- __integration-tests__/accounts.test.ts --- */

const fakePaymentService: PaymentService = {
	chargeUserMonthly: jest.fn()
};

jest.mock("../services/service-injection", () => ({
	getPaymentService: () => fakePaymentService
}));

describe("/accounts API endpoints", () => {
	describe("POST /accounts/upgrade with valid data", () => {
		before(() => {
			// Send a POST HTTP request to /accounts/upgrade with valid data
			// ...
		});

		it("returns a 200 OK", () => {
			// ...
		});

		it("starts recurring payments", () => {
			expect(fakePaymentService.chargeUserMonthly).toHaveBeenCalled();
		});
	});
});

Now all routes and functions that use our dependency injection will get the mocked PaymentService automatically.

I hope you now have a better understanding of the benefits of dependency injection / inversion of control and how it makes your code both modular and easier to test. Using function composition allows us to implement a form of dependency injection in TypeScript and JavaScript while using default parameters keeps our code DRY.