Step-by-step guide to implementing scoped-like dependencies using AsyncLocalStorage with fastify | Pure DI in TypeScript Node.js
Tags: tutorial, node, typescript, webdev
Published: 2023-01-19 04:45:38

In the previous [article](https://dev.to/vad3x/typesafe-almost-zero-cost-dependency-injection-in-typescript-112), 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](https://github.com/vad3x/typesafe-dependency-injection-in-typescript-samples/tree/main/samples/part1-typesafe-dependency-injection) - `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](https://nodejs.org/api/async_context.html#class-asynclocalstorage) fits perfectly. `AsyncLocalStorage` is used to associate a state and propagate it throughout callbacks and promise chains.

From the official docs:

```js
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](https://github.com/vad3x/typesafe-dependency-injection-in-typescript-samples/tree/main/samples/part1-typesafe-dependency-injection).

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

```ts
// 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:

```ts
// 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`.

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

And add the implementation of `RunWithRequestId` to our `RequestIdStore`.

```ts
// 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:

```ts
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.

```ts
// 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](https://github.com/fastify/fastify-plugin).

Finally, changing the `fastify-server` registry file:

```ts
// 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](https://github.com/vad3x/typesafe-dependency-injection-in-typescript-samples/blob/main/samples/part2-request-id)

## 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`.