Phillippe Santana
PhillCode

PhillCode

Per HTTP request Container with InversifyJS

Per HTTP request Container with InversifyJS

How to resolve dependencies once per HTTP request, with the option to still have some application-wide singleton dependencies.

Phillippe Santana's photo
Phillippe Santana
·Feb 19, 2022·

3 min read

InversifyJS has had the inRequestScope() for some time now, but it doesn't help much with 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.

But, sometimes, you need more than one call to get in the same HTTP request.

For instance, from within a middleware, I needed to pull the current user from the request and inject that information into a CurrentUser class, so that information could later be accessed by other services down the line.

For this, I needed at least two calls to get<CurrentUser>: one in the middleware and another for instantiating the controller/handler for the request.

So, inRequestScope is not a viable solution, and inSingletonScope or inTransientScope are also out of the question.

I ended up creating the ScopedContainer class you can see down below.

First, this is how you would use it:

// register-global-dependencies.ts
ScopedContainer.globalContainer = (() => {
  const container = new Container();
  container
    .bind<SomeSingletonDep>(TOKENS.SomeSingletonDep)
    .to(SomeSingletonDep)
    .inSingletonScope();
  return container;
})();

☝️ This allows you to still have Singleton dependencies.

// register-scoped-dependencies.ts
import "register-global-dependencies";

ScopedContainer.postConfigure((container) => {
  container
    .bind<RequestSpecificDep>(TOKENS.RequestSpecificDep)
    .to(RequestSpecificDep)
    .inSingletonScope();
});

☝️ This controls which dependencies should be resolved once per 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 the ScopedContainer class:

import { Container, interfaces } from "inversify";

const DEFAULT_SCOPE_ID = "__default__";

type PostConfigureAction = (container: Container) => void;
type ScopedContainerCache = {
  [id: string]: Container;
};

class ScopedContainer {
  private static _postConfigureActions: PostConfigureAction[] = [];
  private static readonly _instances: ScopedContainerCache = {};

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

  /**
   * A global container instance, which enables truly
   * singleton instances when using a scoped container. All scoped
   * containers reference the global container as parent.
   */
  static globalContainer: Container;

  /**
   * 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._instances[scopeId];
    if (!container) {
      container = this.makeNewContainer();
      this._instances[scopeId] = container;
    }
    return container;
  }

  /**
   * Unbinds the @see Container (i.e. container.unbindAll()) and removes
   * it from the cache.
   * @param scopeId
   */
  static remove(scopeId = DEFAULT_SCOPE_ID): void {
    let container = this._instances[scopeId];
    if (!container) return;
    container.unbindAll();
    delete this._instances[scopeId];
  }

  /**
   * Runs the @method remove method on all instances.
   */
  static removeAll(): void {
    Object.keys(this._instances).forEach((key) => this.remove(key));
  }

  /**
   * Adds a post configure action.
   * @param fn A function that will be run everytime a new @see Container is created.
   * @returns The @see ScopedContainer itself, to allow chaining.
   */
  static postConfigure(fn: PostConfigureAction): ScopedContainer {
    this._postConfigureActions.push(fn);
    return this;
  }

  /**
   * Removes any post configure actions.
   */
  static resetPostConfigureActions(): void {
    this._postConfigureActions = [];
  }

  private static makeNewContainer(): Container {
    const container =
      this.globalContainer?.createChild(this.containerOptions) ??
      new Container(this.containerOptions);
    this._postConfigureActions.forEach((action) => action(container));
    return container;
  }
}

export default ScopedContainer;

See what other developers think in this GitHub issue.

 
Share this