Step-by-step guide of implementing scoped-like dependencies using AsyncLocalStorage with fastify | Pure DI in TypeScript Node.js

In the previous article, I wrote about implementing Dependency Injection using the Registry approach. Today, I'm going to focus on the problem of how to deal with scoped-like dependencies within a static application state.

The Problem

Let's say we want to add a new feature to the previous sample app - RequestId.

RequestId represents an identifier that identifies HTTP requests a user made. The value can be also passed as part of the HTTP request headers:

GET /orders/123/receipt HTTP/1.1
Host: localhost:3000
Request-Id: 3558f928-e87b-4240-ac56-b2e4106a6da8

If the Request-Id is not passed as part of HTTP request headers, it must be generated internally.

AsyncLocalStorage

This is the example where AsyncLocalStorage fits perfectly. AsyncLocalStorage is used to associate a state and propagate it throughout callbacks and promise chains.

From the official docs:

import http from "node:http";
import { AsyncLocalStorage } from "node:async_hooks";

const asyncLocalStorage = new AsyncLocalStorage();

function logWithId(msg) {
  const id = asyncLocalStorage.getStore();
  console.log(`${id !== undefined ? id : "-"}:`, msg);
}

let idSeq = 0;
http
  .createServer((req, res) => {
    asyncLocalStorage.run(idSeq++, () => {
      logWithId("start");
      // Imagine any chain of async operations here
      setImmediate(() => {
        logWithId("finish");
        res.end();
      });
    });
  })
  .listen(8080);

http.get("http://localhost:8080");
http.get("http://localhost:8080");
// Prints:
//   0: start
//   1: start
//   0: finish
//   1: finish

Let's implement a similar idea for the previous sample app.

First of all, let's define the interfaces for the feature:

// get-request-id.ts
type RequestId = string;

type GetRequestId = () => RequestId | undefined; // <-- can be undefined if called outside of actual HTTP request

// stub-order-service.ts
class StubOrderService {
  constructor(private readonly getRequestId: GetRequestId) {}

  findOrderById: FindOrderById = (orderId) => {
    const requestId = this.getRequestId();

    console.log(
      `calling findOrderById with orderId: ${orderId}, requestId: ${requestId}`
    );

    return Promise.resolve({
      id: orderId,
    });
  };
}

// registry/stub-order-service.ts
function stubOrderService() {
  return ({ getRequestId }: { getRequestId: GetRequestId }) => {
    const { findOrderById } = new StubOrderService(getRequestId);

    return {
      findOrderById,
    };
  };
}

Now, we need to implement RequestIdStore service:

// request-id-store.ts
import { AsyncLocalStorage } from "node:async_hooks";

class RequestIdStore {
  private readonly requestIdAls = new AsyncLocalStorage<RequestId>();

  getRequestId: GetRequestId = () => {
    return this.requestIdAls.getStore();
  };
}

// registry/request-id-store.ts
function requestIdStore() {
  return () => {
    const { getRequestId } = new RequestIdStore();

    return {
      getRequestId,
    };
  };
}

RequestIdProvider has 1 function now: it can fetch RequestId from AsyncLocalStorage. The magic comes from the AsyncLocalStorage, which will propagate the state throughout callbacks and promise chains.

Now let's define a new function type to set the state: RunWithRequestId.

type RunWithRequestId = <R>(
  requestId: RequestId,
  callback: (...args: unknown[]) => R
) => R;

And add the implementation of RunWithRequestId to our RequestIdStore.

// request-id-store.ts
class RequestIdStore {
  // ...

  runWithRequestId: RunWithRequestId = (requestId, callback) => {
    return this.invocationInfoAls.run(requestId, callback);
  };
}

// registry/request-id-store.ts
function requestIdStore() {
  return () => {
    const { getRequestId, runWithRequestId } = new RequestIdStore();

    return {
      getRequestId,
      runWithRequestId,
    };
  };
}

Essentially, we just incapsulating AsyncLocalStorage into our RequestIdStore.

We also should update our createAppRegistry function:

export function createAppRegistry() {
  return new RegistryComposer()
    .add(requestIdStore())
    .add(stubOrderService())
    .add(stubOrderReceiptGenerator())
    .add(fastifyServer())
    .compose();
}

Cool, let's run the app and see the output:

Request:

GET /orders/123/receipt HTTP/1.1
Host: localhost:3000
Request-Id: 3558f928-e87b-4240-ac56-b2e4106a6da8

Output:

INFO: calling findOrderById with orderId: 123, requestId: undefined

As we can see, the requestId is undefined now, since we never called our RunWithRequestId function.

So, because we have fastify app, we can add a new middleware plugin then and call RunWithRequestId there. It will be the perfect place since we also need to populate the RequestId according to the requirements above.

// run-with-request-id-plugin.ts
import * as uuid from "uuid";
import fp from "fastify-plugin";

const REQUEST_ID_HEADER_NAME = "Request-Id";

export function runWithRequestIdPlugin(deps: {
  runWithRequestId: RunWithRequestId;
}): FastifyPluginCallback {
  const plugin: FastifyPluginCallback = (fastify, _, next) => {
    fastify.addHook("onRequest", (request, _reply, callback) => {
      const requestId =
        request.headers[REQUEST_ID_HEADER_NAME]?.toString() ?? uuid.v4();

      deps.runWithRequestId(requestId, callback);
    });

    next();
  };

  return fp(plugin); // we need fp here to make our plugin to be global
}

Note: You can read more about fastify-plugin here.

Finally, changing the fastify-server registry file:

// registry/fastify-server.ts
export function fastifyServer() {
  return (
    deps: Parameters<typeof runWithRequestIdPlugin>[0] &
      Parameters<typeof ordersRoutes>[0]
  ) => {
    const server = fastify({});

    server.register(runWithRequestIdPlugin(deps));
    server.register(ordersRoutes(deps));

    return {
      fastifyServer: server,
    };
  };
}

If we run the app again, we will see the following output:

Request:

GET /orders/123/receipt HTTP/1.1
Host: localhost:3000
Request-Id: 3558f928-e87b-4240-ac56-b2e4106a6da8

Output:

INFO: calling findOrderById with orderId: 123, requestId: 3558f928-e87b-4240-ac56-b2e4106a6da8

Request without Request-ID header:

GET /orders/123/receipt HTTP/1.1
Host: localhost:3000

Output:

INFO: calling findOrderById with orderId: 123, requestId: <random-guid>

Full example source code can be found here

Conclusion

In this tutorial, we learned how to implement Scoped-like dependencies such as RequestId using AsyncLocalStorage in fastify application. To implement the same approach in Express.js or ApolloServer, simply implement the middleware. The rest of the code will stay the same. In the next article, I'm going to show how to implement a Logger with metadata using AsyncLocalStorage.