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
PersonService
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
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:
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:
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
.
LoggerService as Singleton
Let's change the LoggerService to a singleton dependency and take a look at the logs.
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
.
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:
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:
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.
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.