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

vad3x

Vadim Orekhov

Posted on January 19, 2023

Step-by-step guide to 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

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,
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Output:

INFO: calling findOrderById with orderId: 123, requestId: undefined
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Output:

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

Request without Request-ID header:

GET /orders/123/receipt HTTP/1.1
Host: localhost:3000
Enter fullscreen mode Exit fullscreen mode

Output:

INFO: calling findOrderById with orderId: 123, requestId: <random-guid>
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
vad3x
Vadim Orekhov

Posted on January 19, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related