Step-by-step guide to implementing scoped-like dependencies using AsyncLocalStorage with fastify | Pure DI in TypeScript Node.js
Vadim Orekhov
Posted on January 19, 2023
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
.
Posted on January 19, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.