Resolving Dependencies Once per HTTP Request with InversifyJS Scoped Containers

Resolving Dependencies Once per HTTP Request with InversifyJS Scoped Containers

Learn how to resolve dependencies once per HTTP request with InversifyJS, while still maintaining application-wide singleton dependencies.

Hey there! Do you need to resolve dependencies once per HTTP request, but still want to have some application-wide singleton dependencies? In this post, I'll show you how to use InversifyJS to achieve this.

You may have heard of InversifyJS's inRequestScope() function, but did you know that it doesn't work for the container-per-HTTP-request case? Well, fear not, because I've got a solution for you!

I'll walk you through the steps to create a custom ScopedContainer class that allows you to resolve dependencies once per HTTP request, with the option to still have some application-wide singleton dependencies.

⚡ Using the code

Before we dive into writing the ScopedContainer class, let's take a look at how it will be used in the context of optimizing HTTP requests with efficient dependency injection in InversifyJS. This will help you better evaluate if this solution meets your needs.

Registering application-wide singletons

First, it is crucial to keep the ability to register application-wide singletons, and you can do so in your dependency registration file. With the following code template, you can make sure that your singletons are always available to all containers that use the ScopedContainer class.

ScopedContainer.registerGlobalDependencies((container) => {
  container
    .bind<SomeSingletonDep>(TOKENS.SomeSingletonDep)
    .to(SomeSingletonDep)
    .inSingletonScope();
})();

Registering scoped dependencies

Next, we need a way to register dependencies that will only be created once per HTTP request. With the Scoped Container, you still register these dependencies as singletons, but the Scoped Container ensures that they are scoped to the specific request.

This means that every time the Scoped Container is used to resolve a dependency, it will only create a single instance of that dependency for the current HTTP request or whatever scope you've defined.

ScopedContainer.registerScopedDependencies((container) => {
  container
    .bind<RequestSpecificDep>(TOKENS.RequestSpecificDep)
    .to(RequestSpecificDep)
    .inSingletonScope(); // this is right; use the singleton scope
});

So, keep in mind that as long as you register your dependencies inside the registerScopedDependencies function using the inSingletonScope method, they will be tied to the specific HTTP request. This is because the ScopedContainer class ensures that these dependencies are only created once per request, even though they're registered as singletons.

Resolving dependencies in your HTTP request

// lambda-handler.ts
import "register-scoped-dependencies";

handler = (event, context) => {
  const requestId = event.requestContext.requestId;
  const container = ScopedContainer.for(requestId);

  try {
    // This will be the same for every request
    const singletonDep = container.get(TOKENS.SomeSingletonDep);

    // And this will be a new instance for every request
    const requestSpecificDep = container.get(TOKENS.RequestSpecificDep);
  }
  finally {
    ScopedContainer.remove(requestId);
  }
}

This is an example usage in a lambda handler function.

TL;DR:

  1. Create a new Scoped Container using the request ID

  2. Resolve dependencies

  3. Remove the Scoped Container for the request ID

In the beginning of the request, we create a new container for each HTTP request using the ScopedContainer.for method, passing in a unique scope ID for that request. This allows you to efficiently manage dependencies that are specific to each request.

Inside the try block, you can see two examples of how to resolve dependencies. The singletonDep is registered as a singleton in the global container and will be the same for every request. The requestSpecificDep is registered using the inSingletonScope method and will be a new instance for every request, but it is guaranteed to be the same instance for the entire request.

With the finally block, we remove the container for that specific scope ID, so that it can be garbage collected and does not interfere with any future requests.

✨ Creating the ScopedContainer class

The ScopedContainer class provides a way to create and manage separate containers for each scope ID, allowing you to resolve dependencies once per scope ID.

This is particularly useful in the context of HTTP requests, where you can use the Scoped Container to ensure that each request has its own container, allowing you to efficiently manage dependencies that are specific to each request.

import { Container, interfaces } from "inversify";

const DEFAULT_SCOPE_ID = "__default__";

type ScopedContainerCache = {
  [id: string]: Container;
};

export type RegisterScopedDependenciesAction = (container: Container) => void;

export class ScopedContainer {
  private static _dependencyRegistrations: RegisterScopedDependenciesAction[] = [];
  private static _globalContainer: Container;
  private static readonly _containerInstances: ScopedContainerCache = {};

  /**
   * Options object to use when creating a new container for a
   * scope ID.
   */
  static scopedContainerOptions: interfaces.ContainerOptions;

  /**
   * A function to register global dependencies.
   * This creates a global container instance, which enables truly
   * singleton instances when using a scoped container. All scoped
   * containers reference the global container as parent.
   * 
   * @example
   * ScopedContainer.registerGlobalDependencies((container) => {
   *   container
   *     .bind<SomeSingletonDep>(TOKENS.SomeSingletonDep)
   *     .to(SomeSingletonDep)
   *     .inSingletonScope();
   * })();
   */
  static registerGlobalDependencies: (container: Container) => void;

  /**
   * Returns a @see Container that is unique to the specified scope.
   * If this is the first time getting the container for the scope, then a
   * new container will be created using the provided factory. Any post configure
   * actions will also be applied to the new container instance.
   * @param scopeId Any string to identify the scope (e.g. current request ID).
   * @returns A @see Container that is unique to the specified scope.
   */
  static for(scopeId = DEFAULT_SCOPE_ID): Container {
    let container = this._containerInstances[scopeId];
    if (!container) {
      container = this.makeNewContainer();
      this._containerInstances[scopeId] = container;
    }
    return container;
  }

  /**
   * Registers dependencies that should be tied to a scope,
   * e.g. HTTP request.
   * @remarks
   * ✔️ DO use `inSingletonScope` to register scoped dependencies.
   * ❌ DO NOT use `inRequestScope`, as this will create a new instance for
   * separate calls to `get` even within the same scope.
   * @see https://stackoverflow.com/a/71180025
   * @param fn A function that registers scoped dependencies.
   * @returns The @see ScopedContainer itself, to allow chaining.
   * @example
   * ScopedContainer.registerScopedDependencies((container) => {
   *   container
   *     .bind<SomeScopedDep>(TOKENS.SomeScopedDep)
   *     .to(SomeScopedDep)
   *     .inSingletonScope(); // this is right; use the singleton scope
   * })();
   */
  static registerScopedDependencies(fn: RegisterScopedDependenciesAction): ScopedContainer {
    this._dependencyRegistrations.push(fn);
    return this;
  }

  private static makeNewContainer(): Container {
    const container = this.ensureGlobalContainer().createChild(this.scopedContainerOptions);
    this._dependencyRegistrations.forEach((action) => action(container));
    return container;
  }

  private static ensureGlobalContainer(): Container {
    if (!this._globalContainer) {
      const container = new Container(this.scopedContainerOptions);
      this.registerGlobalDependencies?.(container);
      this._globalContainer = container;
    }
    return this._globalContainer;
  }
}

Just to give you the full story, the inRequestScope() is no good in for the container-per-http-request case because InversifyJS's request scope is actually tied to a single call to get, that is, each call to get is considered a Request (aka Composition Root), and it will only work as intended for an HTTP context if you only have a single call to get per request.

Conclusion

With the ScopedContainer class, you can now resolve dependencies once per request, ensuring that each request has its own container with dependencies specific to that request, while still allowing for application-wide singleton dependencies.

I hope this post has helped you better understand how to use the ScopedContainer class and apply it to your own projects.

If you have any questions or comments, please let me know below. Happy coding!