Dependency Injection for Serverless Applications

A brief introduction to DI and Serverless with example setup using node.js, TypeScript and AWS

Thor Chen
JavaScript in Plain English

--

Image generated by MidJourney

What is Dependency Injection?

Dependency Injection (DI) is a software design pattern that addresses how components obtain their dependencies. In this context, the term “dependency” often refers to an object used to handle some logic that is not the responsibility of the client code where it is used.

It is probably easier to explain with an example.

Imagine that we have a class called MyService, and when it is trying to doSomething, it will send an email using EmailService:

class MyService {
doSomething() {
const emailService = new EmailService();
emailService.sendMail();
}
}

As shown above, we are instantiating EmailService within the doSomething method.

So, what would be the version with Dependency Injection? Here it is:

class MyService {
private readonly emailService: EmailService;

constructor(dependencies: { emailService: EmailService }) {
this.emailService = dependencies.emailService;
}

doSomething() {
this.emailService.sendMail();
}
}

That is, we no longer instantiate EmailService inside MyService, but instead retrieve it from the dependencies passed in the constructor.

FYI, because the dependencies are passed in rather than being instantiated or created inside MyService, we say that dependencies are injected :)

What are the benefits of using Dependency Injection?

1 ) It makes the dependency very explicit

MyService is a class that depends on EmailService, and this dependency is clearly stated in MyService's constructor.

This is important because we can immediately see that EmailService is required to use MyService properly. The declaration of this dependency is a key component of MyService's design, and it helps us understand how the class works. This is especially helpful when reading the code or debugging issues that may arise.

2) It encourages separate concerns even further

Before using Dependency Injection, MyService had to know how to obtain a proper instance of EmailService. This leaked the process and responsibility of creating and maintaining EmailService instances.

After using Dependency Injection, the responsibility of creating and maintaining EmailService instances is abstracted out. This is no longer a concern of MyService, and it should never have been a concern of this class in the first place.

Furthermore, Dependency Injection empowers us to write code based on contracts rather than concrete classes. That is to say, EmailService does not need to be a class; instead, it can be an interface, which is actually better from an abstraction point of view. We may have different implementations of EmailService depending on different scenarios, and this is not a concern of MyService.

A typical example would be:

  • We would like to send real emails on the production server.
  • We would like to just log the email sending action instead of sending it out on the local development server.
  • We would like to completely omit the email sending process in unit tests.

3) It simplifies unit testing setup

Before using Dependency Injection, we had to rely on the mocking mechanism provided by the testing framework we chose to mock the EmailService when testing MyService. Different testing frameworks/libraries may have different ways to mock things, and mocking can sometimes be tricky to set up.

After using Dependency Injection, we can easily provide a mock implementation, regardless of which testing framework or library we choose. That is, as long as we can detect that the code is running in “test” mode, we can inject a mocked implementation of the EmailService interface. This means that MyService will always access the mocked implementation without any setup hassles.

What is Serverless?

Serverless is a software design pattern in which a third-party service provider hosts the application, completely abstracting the server layer. This model allows developers to write and deploy code without managing the underlying infrastructure.

Serverless applications are event-driven, meaning that the cloud provider runs the server and dynamically manages the allocation of machine resources. The code is executed only when specific events or functions are called, and the cloud provider charges based on actual resource consumption, not pre-purchased capacity.

The primary benefits of serverless applications include:

  1. Reduced operational concerns: With serverless applications, we no longer need to worry about server operation, maintenance, or capacity provisioning. Instead, we can focus on the application logic while the cloud service provider handles the rest.
  2. Cost-effectiveness: Serverless applications can be more cost-efficient because we only pay for the compute time we consume. If our code isn’t running, we’re not being charged.
  3. Scalability: Serverless applications can scale automatically. The cloud provider can handle spikes in traffic and demand automatically, adjusting resources as needed.

Some examples of popular serverless providers are AWS Lambda, Google Cloud Functions, and Azure Functions. Additionally, there are coding libraries like the Serverless Framework that can help manage and develop serverless applications.

Why do Serverless applications need dependency injection?

Serverless applications are designed to run on the cloud and use cloud services by default. While there are many benefits to adopting Serverless, there are challenges in running these applications locally and writing tests for them.

I have seen Serverless project code that spreads if-else statements with access to process.env everywhere to allow some stuff to work among the cloud, local, and test runtime. I have also struggled with mocking some code modules in tests that require finding implicit dependencies and applying tricks to set up. These experiences not only make maintaining the project much harder, but are also unpleasant.

Introducing Dependency Injection can simplify the process by providing a way to swap the implementation (or configuration) for individual pieces of code to adapt to different runtime environments.

It’s also important to note that a “Serverless application” doesn’t necessarily mean that the codebase would be extremely small. It could still have thousands of lines or more in the repository. By adopting Dependency Injection, the project can be structured in a modular manner while always keeping the concept of “separating concerns” in mind. This can significantly benefit project maintenance.

An example setup

While there are many dependency injection frameworks and libraries, my favourite is Awilix.

  • It is easy to understand because everything is explicit and obvious.
  • It does not rely on the “reflection” feature of TypeScript.
  • It works in the AWS Lambda environment.

Assuming we already have a project set up with Serverless Framework, the first step to introduce is to install the package.

npm install awilix

Then we could create a file in src/services/serviceContainer.ts:

import * as awilix from "awilix";
import { SqsService } from "./sqs/SqsService";
import { SqsServiceCloud } from "./sqs/SqsServiceCloud";
import { SqsServiceLocal } from "./sqs/SqsServiceLocal";
import { SqsServiceMock } from "./sqs/SqsServiceMock";

export type ServiceContainerCradle = {
sqsService: SqsService;
};

const serviceContainer = awilix.createContainer<ServiceContainerCradle>();

serviceContainer.register({ sqsService: awilix.asClass(SqsServiceCloud) });

const isLocal = Boolean(process.env.IS_LOCAL) || Boolean(process.env.IS_OFFLINE);
if (isLocal) {
serviceContainer.register({ sqsService: awilix.asClass(SqsServiceLocal) });
}

const isTest = Boolean(process.env.JEST_WORKER_ID);
if (isTest) {
serviceContainer.register({ sqsService: awilix.asClass(SqsServiceMock) });
}

export { serviceContainer };

In this setup, we use SqsService as an example because I find the use of AWS SQS to be very common across my Serverless projects. Therefore, this example would feel more "real".

AWS SQS, which stands for Amazon Web Services Simple Queue Service, is a fully managed message queuing service.

At the beginning of this file, we defined a type called ServiceContainerCradle after all the "imports." This type declaration specifies the services that the container can offer. For the sake of simplicity, we only offer the SqsService in this example.

export type ServiceContainerCradle = {
sqsService: SqsService;
};

Then we create the service container via awilix.createContainer(), which is the object that holds all our services.

const serviceContainer = awilix.createContainer<ServiceContainerCradle>();

After that, we register different implementations based on different scenarios:

serviceContainer.register({ sqsService: awilix.asClass(SqsServiceCloud) });

const isLocal = Boolean(process.env.IS_LOCAL) || Boolean(process.env.IS_OFFLINE);
if (isLocal) {
serviceContainer.register({ sqsService: awilix.asClass(SqsServiceLocal) });
}

const isTest = Boolean(process.env.JEST_WORKER_ID);
if (isTest) {
serviceContainer.register({ sqsService: awilix.asClass(SqsServiceMock) });
}

To explain it in more detail:

1 ) SqsService is an interface that provides the contract for the features that each concrete class should implement:

export interface SqsService {
sendMessage(args: { queueUrl: string; messageBody: string }): Promise<{ messageId: string }>;
}

2 ) SqsServiceCloud is the concrete implementation that handles the scenario when running on the AWS cloud:

import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
import { SqsService } from "./SqsService";

export class SqsServiceCloud implements SqsService {
public async sendMessage(args: { queueUrl: string; messageBody: string }): Promise<{ messageId: string }> {
const { queueUrl, messageBody } = args;

const sqsClient = this.getSqsClient();
const command = new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: messageBody });
const { MessageId: messageId } = await sqsClient.send(command);
if (!messageId) {
throw new Error(`Fail to message to queue, queueUrl: ${queueUrl}`);
}

return { messageId };
}

protected getSqsClient(): SQSClient {
return new SQSClient({});
}
}

3 ) SqsServiceLocal is responsible for emulating AWS SQS locally. Specifically, we are using serverless-offline-sqs with ElasticMQ, so the configuration differs from the cloud version.

However, the logic of sendMessage is identical to the cloud version, so we only need to override getSqsClient().

import { SQSClient } from "@aws-sdk/client-sqs";
import { SqsServiceCloud } from "./SqsServiceCloud";

export class SqsServiceLocal extends SqsServiceCloud {
/**
* @override
*/
protected getSqsClient(): SQSClient {
return new SQSClient({
endpoint: `http://127.0.0.1:9324`,
region: "elasticmq",
credentials: {
accessKeyId: "root",
secretAccessKey: "root",
},
});
}
}

4 ) SqsServiceMock is the version used when we test other services that rely on SqsService. We want to bypass the actual message sending process but return a result that follows the known rules for checking in tests. Here, we generate the messageId using an md5 hash for the argument object.

import { createHash } from "node:crypto";
import { SqsService } from "./SqsService";

export class SqsServiceMock implements SqsService {
public async sendMessage(args: { queueUrl: string; messageBody: string }): Promise<{ messageId: string }> {
console.debug("Send message via mocked SQS: ", args);
const messageId = createHash("md5").update(JSON.stringify(args)).digest("hex");
return { messageId };
}
}

Imagine we have a MessageQueueService that has SqsService as an injected dependency. Then, we can test that service by writing something like this:

it("should add correct message to queue", async () => {
const messageQueueService = new MessageQueueServiceCloud(serviceContainer.cradle);

const someId = nanoid(10);

const { messageId } = await messageQueueService.addSomethingToQueue(someId);
const expectedMessageId = generateHash(
JSON.stringify({
queueUrl: QUEUE_URL,
messageBody: JSON.stringify({ someId }),
})
);

expect(messageId).toEqual(expectedMessageId);
});

Conclusion

In summary, Dependency Injection (DI) plays a vital role in building robust Serverless applications. It offers benefits such as clear dependency management, effective separation of concerns, and simplified testing. DI significantly improves the adaptability and maintainability of these applications by allowing efficient configuration for different runtime environments, such as cloud, local, or testing. Libraries such as Awilix can help achieve this, as shown in the example above.

More content at PlainEnglish.io.

Sign up for our free weekly newsletter. Follow us on Twitter, LinkedIn, YouTube, and Discord.

--

--