Happy Employees == Happy ClientsCAREERS AT DEPT®
DEPT® Engineering BlogPlatforms

Dependency Injection for Type Script AWS Lambdas

We talk about Microsoft's dependency injection package TSyringe and its different dependency scopes. We explain how to use resolution scoped dependencies over transient, singleton, and container scoped dependencies to limit the scope of a dependency to a single lambda invocation.

In this article, we talk about Microsoft's dependency injection package called TSyringe and its different dependency scopes. We explain how to use resolution scoped dependencies to limit the scope of a dependency to a single lambda invocation.  Along the way, we talk about how this task would be hard to accomplish with transient, singleton, and container scoped dependencies.

TSyringe

TSyringe is an open-source TypeScript package maintained by Microsoft that allows us to use dependency injection within our TypeScript code. The code and documentation can be found on the TSyringe github page.

Dependency injection allows us to resolve all of the dependencies without having to instantiate each dependency and pass it to the dependent's constructor.

So, we can simplify this:

const loggerService = new LoggerService();
const emailService = new EmailService(loggerService);
const personRepository = new PersonRepository(loggerService);

const personService = new PersonService(loggerService, emailService, personRepository);

To this:

const personService = diContainer.resolve(PersonService);

And all of the dependencies of the PersonService get resolved automatically, as well as any of the chained dependencies, such as the LoggerService that gets injected into the EmailService and PersonRepository.

Scenario

Let's take the following scenario: We have a lambda that can get a person with a given id.  The handler will use a PersonService, which takes care of getting the person, and the handler and PersonService will use a LoggerService to format our logs and log them to the console.

LoggerService

import { injectable } from "tsyringe";

@injectable()
export class LoggerService {
  private lambdaName: string;
  private personId: string;
  private logsLogged: number = 0;

  public setContext(lambdaName: string, personId: string) {
    this.lambdaName = lambdaName;
    this.personId = personId;
  }

  public log(message: string) {
    this.logsLogged++;
    
    console.log(JSON.stringify({
      lambdaName: this.lambdaName,
      personId: this.personId,
      logsLogged: this.logsLogged,
      message: message
    }));
  }
}
logger.service.ts

PersonService

import { injectable } from "tsyringe";
import { LoggerService } from "./logger.service";

interface Person {
  personId: string;
}

@injectable()
export class PersonService {
  constructor(private logger: LoggerService) {}

  public async getPerson(personId: string): Promise<Person> {
    this.logger.log("PersonService.getPerson: Getting person.");

    await new Promise(resolve => setTimeout(resolve, 1000));

    this.logger.log("PersonService.getPerson: Got person.");

    return {
      personId: personId
    }
  }
}
person.service.ts

We use the @injectable decorator to denote which classes the DI container can resolve as dependencies.  Both the LoggerService and PersonService will be resolved through the DI container.

The PersonService's constructor has a typed logger parameter, which tells the DI container to resolve a LoggerService for that dependency.

You can see us resolving the PersonService from the DI container in our lambda handler below.

Lambda Handler

import "reflect-metadata";
import { APIGatewayProxyEventV2, APIGatewayProxyResult } from "aws-lambda";
import { container } from "tsyringe";
import { PersonService } from "./person.service";

export const getPerson = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResult> => {
  const service = container.resolve(PersonService);
  const personId = event.queryStringParameters.id;

  const result = await service.getPerson(personId);
  
  return {
    statusCode: 200,
    body: JSON.stringify(result)
  };
};
person-controller.ts

When calling container.resolve, the DI container sees that the PersonService has a dependency on the LoggerService and will inject an instance of the LoggerService into the constructor of the PersonService while instantiating it.

LoggerSerivce State

The logger service has a couple properties that should be part of the output of each log:

  • lambdaName: The name of the lambda handler that is being executed.
  • personId: The personId that is being requested.
  • logsLogged: The number of logs that have been output during the lambda execution.

We will want to add this context to the LoggerService via its setContext function before any logs are logged, and we want to do that in the controller so that we can pass it the correct lambdaName.  Our handler should now look like this:

export const getPerson = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResult> => {
  const loggerService = container.resolve(LoggerService);
  const service = container.resolve(PersonService);
  const personId = event.queryStringParameters.id;

  loggerService.setContext('getPerson', personId);

  loggerService.log("Controller.getPerson: Handler invoked.")

  const result = await service.getPerson(personId);
  
  return {
    statusCode: 200,
    body: JSON.stringify(result)
  };
};
person-controller.ts

LoggerService Scopes

TSyringe has several scopes available to use, with transient scope being its default. To have a transient scoped dependency means that you will get a new instance from the DI container every time that dependency is resolved.  Here are the scopes that TSyringe provides, directly from their documentation, which is similar to most other DI containers.

Transient: The default registration scope, a new instance will be created with each resolve
Singleton: Each resolve will return the same instance (including resolves from child containers)
ResolutionScoped: The same instance will be resolved for each resolution of this dependency during a single resolution chain
ContainerScoped: The dependency container will return the same instance each time a resolution for this dependency is requested. This is similar to being a singleton, however if a child container is made, that child container will resolve an instance unique to it.

Let's see how applying different scopes to the LoggerService will affect the logs.

LoggerService as Transient

From the class definition above, you can see that the LoggerService has the @injectable decorator.  This means that it will be a transient dependency.  When we run the lambda, this is the output from CloudWatch:

{"lambdaName":"getPerson","personId":"AF1234","logsLogged":1,"message":"Controller.getPerson: Handler invoked."}
{"logsLogged":1,"message":"PersonService.getPerson: Getting person."}
{"logsLogged":2,"message":"PersonService.getPerson: Got person."}
transient-logs.txt

We are not quite getting what we expect.  For the duration of the handler invocation, we got three logs.  The first log, from the handler, has the proper lambdaName and personId, but the next two logs from the PersonService, do not.  Likewise, you can see that the logsLogged starts over when we start logging from the PersonService.

Why aren't we getting what we expect? Because with a transient dependency, we get a new instance each time it is resolved.  Therefore, the LoggerService instance that we resolved in the handler, is not the same instance that is resolved for the PersonService.

const loggerService = container.resolve(LoggerService); // LoggerService is resolved directly from the container
const service = container.resolve(PersonService); // LoggerService is resolved again during the PersonService resolution.
person-controller.ts

LoggerService as Singleton

Let's change the LoggerService to a singleton dependency and take a look at the logs.

@singleton()
export class LoggerService {
logger.service.ts
{"lambdaName":"getPerson","personId":"AF1234","logsLogged":1,"message":"Controller.getPerson: Handler invoked."}
{"lambdaName":"getPerson","personId":"AF1234","logsLogged":2,"message":"PersonService.getPerson: Getting person."}
{"lambdaName":"getPerson","personId":"AF1234","logsLogged":3,"message":"PersonService.getPerson: Got person."}
singleton-logs-1.txt

That looks like what we expect, each log has the lambdaName and personId, and the logsLogged reflects the number of logs that were logged during the lambda execution.

Why does this work? Because with a singleton dependency, the same instance of the dependency is returned from the container with each resolution.

const loggerService = container.resolve(LoggerService); // LoggerService is resolved for the first time
const service = container.resolve(PersonService); // Same instance of the LoggerService from the line above is resolved for the PersonService

BUT! Let's see what happens when executing the lambda twice, for two different personId, DI1234 and DI4321.

{"lambdaName":"getPerson","personId":"DI1234","logsLogged":1,"message":"Controller.getPerson: Handler invoked."}
{"lambdaName":"getPerson","personId":"DI1234","logsLogged":2,"message":"PersonService.getPerson: Getting person."}
{"lambdaName":"getPerson","personId":"DI1234","logsLogged":3,"message":"PersonService.getPerson: Got person."}

{"lambdaName":"getPerson","personId":"DI4321","logsLogged":4,"message":"Controller.getPerson: Handler invoked."}
{"lambdaName":"getPerson","personId":"DI4321","logsLogged":5,"message":"PersonService.getPerson: Getting person."}
{"lambdaName":"getPerson","personId":"DI4321","logsLogged":6,"message":"PersonService.getPerson: Got person."}
singleton-logs-2.txt

It looks like we got the correct lambdaName and personId through each log as expected, and the personId changed between requests to reflect the new personId.  Good, but what happened to logsLogged? It is not only reflecting the number of logs that were logged during a single lambda invocation but both lambda invocations.

Why is the logsLogged state being maintained across lambda invocations? Because the DI container is loaded outside of the handler as part of the lambda instance's code.  AWS will keep your lambda instance alive as long as it keeps getting invoked.  It will eventually terminate the instance when lambda code is updated or the lambda remains idle for around 45-60 mins.  So, for as long as AWS keeps that lambda instance running, the same instance of the LoggerService will be returned for each resolution.

So, let's take a look at the two other scopes.

LoggerService as ContainerScoped

Container scoped classes will return the same instance of the dependency every time you resolve the dependency from a container.  If you are using multiple containers, there will be a single instance per container.

For the way our code is written, a container scoped LoggerService will function much like the singleton.  This is because we are using the default container that is loaded by TSyringe.  This single container will last through many lambda invocations and return the same instance of LoggerService for each invocation.

Let's try it anyway:

@scoped(Lifecycle.ContainerScoped)
export class LoggerService {
logger.service.ts
{"lambdaName":"getPerson","personId":"DI6789","logsLogged":1,"message":"Controller.getPerson: Handler invoked."}
{"lambdaName":"getPerson","personId":"DI6789","logsLogged":2,"message":"PersonService.getPerson: Getting person."}
{"lambdaName":"getPerson","personId":"DI6789","logsLogged":3,"message":"PersonService.getPerson: Got person."}

{"lambdaName":"getPerson","personId":"DI9876","logsLogged":4,"message":"Controller.getPerson: Handler invoked."}
{"lambdaName":"getPerson","personId":"DI9876","logsLogged":5,"message":"PersonService.getPerson: Getting person."}
{"lambdaName":"getPerson","personId":"DI9876","logsLogged":6,"message":"PersonService.getPerson: Got person."}
container-scoped-logs.txt

Yep... same result. The state of logsLogged is being persisted through each lambda invocation.

LoggerService as ResolutionScoped

Resolution scoped classes will have the same instance of the class resolved for each resolution in a single resolution chain.  You can think of a resolution chain as anytime the container is called to resolve a dependency.  Take the code in the controller for example:

const loggerService = container.resolve(LoggerService);
const service = container.resolve(PersonService);
person-controller.ts

There are two resolution chains above.  So if LoggerService was a resolution scoped dependency, one instance would be returned for the controller, and another instance would be returned as the PersonService dependency.  These two instances would result with the same logs as when LoggerService was a transient dependency, so the logs from the PersonService would not contain the needed lambdaName and personId.

So what can we do?  We can split the handler code into a handler function and a controller class and resolve the PersonService and LoggerService in a single resolution chain.

@scoped(Lifecycle.ResolutionScoped)
export class LoggerService {
logger.service.ts
@injectable()
class PersonController {
  constructor(private personService: PersonService, private loggerService: LoggerService) {}

  public async getPerson(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResult> {
    const personId = event.queryStringParameters.id;

    this.loggerService.setContext('getPerson', personId);

    this.loggerService.log("Controller.getPerson: Handler invoked.")

    const result = await this.personService.getPerson(personId);
    
    return {
      statusCode: 200,
      body: JSON.stringify(result)
    };
  }
}

export const getPerson = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResult> => {
  const controller = container.resolve(PersonController);
  return controller.getPerson(event);
};
person-controller.ts

We moved the actual business logic to an injectable class called PersonController, which has two dependencies that will be injected into its constructor, PersonService and LoggerService.  Now the logic in the handler is very simple.  There is a single resolution chain to resolve the PersonController dependency, which means the same instance of the LoggerService will be resolved for the PersonController and the PersonService.  This resolution chain will be different for each lambda invocation because the handler code gets executed each invocation.

{"lambdaName":"getPerson","personId":"DI4567","logsLogged":1,"message":"Controller.getPerson: Handler invoked."}
{"lambdaName":"getPerson","personId":"DI4567","logsLogged":2,"message":"PersonService.getPerson: Getting person."}
{"lambdaName":"getPerson","personId":"DI4567","logsLogged":3,"message":"PersonService.getPerson: Got person."}

{"lambdaName":"getPerson","personId":"DI7654","logsLogged":1,"message":"Controller.getPerson: Handler invoked."}
{"lambdaName":"getPerson","personId":"DI7654","logsLogged":2,"message":"PersonService.getPerson: Getting person."}
{"lambdaName":"getPerson","personId":"DI7654","logsLogged":3,"message":"PersonService.getPerson: Got person."}

There we go! This is what we expect.  The lambdaName and personId are showing up in each log, and the logsLogged is recording the number of logs that have been logged throughout a single lambda invocation and not carrying over into other invocations.

Conclusion

Dependency injection makes it easy for us to separate out our code into controllers, services, repos, and APIs.  This creates clean code with clear responsibilities for each class.

We can create a single resolution chain per lambda invocation if we house our handler logic in a controller class, and resolve that controller in the simple handler.  This will create lambda invocation scoped dependencies that we can use to hold state for a single lambda invocation without the scope leaking into other lambda invocations.