Skip to content

Commit cd1eb86

Browse files
Merge pull request #2507 from Papooch/docs/async-local-storage-recipe
docs(recipes): add async local storage recipe
2 parents 080eec5 + 933067e commit cd1eb86

File tree

4 files changed

+306
-0
lines changed

4 files changed

+306
-0
lines changed
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
### Async Local Storage
2+
3+
`AsyncLocalStorage` is a [Node.js API](https://nodejs.org/api/async_context.html#async_context_class_asynclocalstorage) (based on the `async_hooks` API) that provides an alternative way of propagating local state through the application without the need to explicitly pass it as a function parameter. It is similar to a thread-local storage in other languages.
4+
5+
The main idea of Async Local Storage is that we can _wrap_ some function call with the `AsyncLocalStorage#run` call. All code that is invoked within the wrapped call gets access to the same `store`, which will be unique to each call chain.
6+
7+
In the context of NestJS, that means if we can find a place within the request's lifecycle where we can wrap the rest of the request's code, we will be able to access and modify state visible only to that request, which may serve as an alternative to REQUEST-scoped providers and some of their limitations.
8+
9+
Alternatively, we can use ALS to propagate context for only a part of the system (for example the _transaction_ object) without passing it around explicitly across services, which can increase isolation and encapsulation.
10+
11+
#### Custom implementation
12+
13+
NestJS itself does not provide any built-in abstraction for `AsyncLocalStorage`, so let's walk through how we could implement it ourselves for the simplest HTTP case to get a better understanding of the whole concept:
14+
15+
> info **info** For a ready-made [dedicated package](recipes/async-local-storage#nestjs-cls), continue reading below.
16+
17+
1. First, create a new instance of the `AsyncLocalStorage` in some shared source file. Since we're using NestJS, let's also turn it into a module with a custom provider.
18+
19+
```ts
20+
@@filename(als.module)
21+
@Module({
22+
providers: [
23+
{
24+
provide: AsyncLocalStorage,
25+
useValue: new AsyncLocalStorage(),
26+
},
27+
],
28+
exports: [AsyncLocalStorage],
29+
})
30+
export class AlsModule {}
31+
```
32+
> info **Hint** `AsyncLocalStorage` is imported from `async_hooks`.
33+
34+
2. We're only concerned with HTTP, so let's use a middleware to wrap the `next` function with `AsyncLocalStorage#run`. Since a middleware is the first thing that the request hits, this will make the `store` available in all enhancers and the rest of the system.
35+
36+
```ts
37+
@@filename(app.module)
38+
@Module({
39+
imports: [AlsModule]
40+
providers: [CatService],
41+
controllers: [CatController],
42+
})
43+
export class AppModule implements NestModule {
44+
constructor(
45+
// inject the AsyncLocalStorage in the module constructor,
46+
private readonly als: AsyncLocalStorage
47+
) {}
48+
49+
configure(consumer: MiddlewareConsumer) {
50+
// bind the middleware,
51+
consumer
52+
.apply((req, res, next) => {
53+
// populate the store with some default values
54+
// based on the request,
55+
const store = {
56+
userId: req.headers['x-user-id'],
57+
};
58+
// and and pass the "next" function as callback
59+
// to the "als.run" method together with the store.
60+
als.run(store, () => next());
61+
})
62+
// and register it for all routes (in case of Fastify use '(.*)')
63+
.forRoutes('*');
64+
}
65+
}
66+
@@switch
67+
@Module({
68+
imports: [AlsModule]
69+
providers: [CatService],
70+
controllers: [CatController],
71+
})
72+
@Dependencies(AsyncLocalStorage)
73+
export class AppModule {
74+
constructor(als) {
75+
// inject the AsyncLocalStorage in the module constructor,
76+
this.als = als
77+
}
78+
79+
configure(consumer) {
80+
// bind the middleware,
81+
consumer
82+
.apply((req, res, next) => {
83+
// populate the store with some default values
84+
// based on the request,
85+
const store = {
86+
userId: req.headers['x-user-id'],
87+
};
88+
// and and pass the "next" function as callback
89+
// to the "als.run" method together with the store.
90+
als.run(store, () => next());
91+
})
92+
// and register it for all routes (in case of Fastify use '(.*)')
93+
.forRoutes('*');
94+
}
95+
}
96+
```
97+
98+
3. Now, anywhere within the lifecycle of a request, we can access the local store instance.
99+
100+
```ts
101+
@@filename(cat.service)
102+
@Injectable()
103+
export class CatService {
104+
constructor(
105+
// We can inject the provided ALS instance.
106+
private readonly als: AsyncLocalStorage,
107+
private readonly catRepository: CatRepository,
108+
) {}
109+
110+
getCatForUser() {
111+
// The "getStore" method will always return the
112+
// store instance associated with the given request.
113+
const userId = this.als.getStore()["userId"] as number;
114+
return this.catRepository.getForUser(userId);
115+
}
116+
}
117+
@@switch
118+
@Injectable()
119+
@Dependencies(AsyncLocalStorage, CatRepository)
120+
export class CatService {
121+
constructor(als, catRepository) {
122+
// We can inject the provided ALS instance.
123+
this.als = als
124+
this.catRepository = catRepository
125+
}
126+
127+
getCatForUser() {
128+
// The "getStore" method will always return the
129+
// store instance associated with the given request.
130+
const userId = this.als.getStore()["userId"] as number;
131+
return this.catRepository.getForUser(userId);
132+
}
133+
}
134+
```
135+
136+
4. That's it. Now we have a way to share request related state without needing to inject the whole `REQUEST` object.
137+
138+
> warning **warning** Please be aware that while the technique is useful for many use-cases, it inherently obfuscates the code flow (creating implicit context), so use it responsibly and especially avoid creating contextual "[God objects](https://en.wikipedia.org/wiki/God_object)".
139+
140+
### NestJS CLS
141+
142+
The [nestjs-cls](https://github.com/Papooch/nestjs-cls) package provides several DX improvements over using plain `AsyncLocalStorage` (`CLS` is an abbreviation of the term _continuation-local storage_). It abstracts the implementation into a `ClsModule` that offers various ways of initializing the `store` for different transports (not only HTTP), as well as a strong-typing support.
143+
144+
The store can then be accessed with an injectable `ClsService`, or entirely abstracted away from the business logic by using [Proxy Providers](https://www.npmjs.com/package/nestjs-cls#proxy-providers).
145+
146+
> info **info** `nestjs-cls` is a third party package and is not managed by the NestJS core team. Please, report any issues found with the library in the [appropriate repository](https://github.com/Papooch/nestjs-cls/issues).
147+
148+
#### Installation
149+
150+
Apart from a peer dependency on the `@nestjs` libs, it only uses the built-in Node.js API. Install it as any other package.
151+
152+
```bash
153+
npm i nestjs-cls
154+
```
155+
156+
#### Usage
157+
158+
A similar functionality as described [above](#custom-implementation) can be implemented using `nestjs-cls` as follows:
159+
160+
1. Import the `ClsModule` in the root module.
161+
162+
```ts
163+
@@filename(app.module)
164+
@Module({
165+
imports: [
166+
// Register the ClsModule,
167+
ClsModule.forRoot({
168+
middleware: {
169+
// automatically mount the
170+
// ClsMiddleware for all routes
171+
mount: true,
172+
// and use the setup method to
173+
// provide default store values.
174+
setup: (cls, req) => {
175+
cls.set('userId', req.headers['x-user-id']);
176+
},
177+
},
178+
}),
179+
],
180+
providers: [CatService],
181+
controllers: [CatController],
182+
})
183+
export class AppModule {}
184+
```
185+
186+
2. And then can use the `ClsService` to access the store values.
187+
188+
```ts
189+
@@filename(cat.service)
190+
@Injectable()
191+
export class CatService {
192+
constructor(
193+
// We can inject the provided ClsService instance,
194+
private readonly cls: ClsService,
195+
private readonly catRepository: CatRepository,
196+
) {}
197+
198+
getCatForUser() {
199+
// and use the "get" method to retrieve any stored value.
200+
const userId = this.cls.get('userId');
201+
return this.catRepository.getForUser(userId);
202+
}
203+
}
204+
@@switch
205+
@Injectable()
206+
@Dependencies(AsyncLocalStorage, CatRepository)
207+
export class CatService {
208+
constructor(als, catRepository) {
209+
// We can inject the provided ClsService instance,
210+
this.als = als
211+
this.catRepository = catRepository
212+
}
213+
214+
getCatForUser() {
215+
// and use the "get" method to retrieve any stored value.
216+
const userId = this.cls.get('userId');
217+
return this.catRepository.getForUser(userId);
218+
}
219+
}
220+
```
221+
222+
3. To get strong typing of the store values managed by the `ClsService` (and also get auto-suggestions of the string keys), we can use an optional type parameter `ClsService<MyClsStore>` when injecting it.
223+
224+
```ts
225+
export interface MyClsStore extends ClsStore {
226+
userId: number
227+
}
228+
```
229+
230+
> info **hint** It it also possible to let the package automatically generate a Request ID and access it later with `cls.getId()`, or to get the whole Request object using `cls.get(CLS_REQ)`.
231+
#### Testing
232+
233+
Since the `ClsService` is just another injectable provider, it can be entirely mocked out in unit tests.
234+
235+
However, in certain integration tests, we might still want to use the real `ClsService` implementation. In that case, we will need to wrap the context-aware piece of code with a call to `ClsService#run` or `ClsService#runWith`.
236+
237+
```ts
238+
describe('CatService', () => {
239+
let service: CatService
240+
let cls: ClsService
241+
const mockCatRepository = createMock<CatRepository>()
242+
243+
beforeEach(async () => {
244+
const module = await Test.createTestingModule({
245+
// Set up most of the testing module as we normally would.
246+
providers: [
247+
CatService,
248+
{
249+
provide: CatRepository
250+
useValue: mockCatRepository
251+
}
252+
],
253+
imports: [
254+
// Import the static version of ClsModule which only provides
255+
// the ClsService, but does not set up the store in any way.
256+
ClsModule
257+
],
258+
}).compile()
259+
260+
service = module.get(CatService)
261+
262+
// Also retrieve the ClsService for later use.
263+
cls = module.get(ClsService)
264+
})
265+
266+
describe('getCatForUser', () => {
267+
it('retrieves cat based on user id', async () => {
268+
const expectedUserId = 42
269+
mockCatRepository.getForUser.mockImplementationOnce(
270+
(id) => ({ userId: id })
271+
)
272+
273+
// Wrap the test call in the `runWith` method
274+
// in which we can pass hand-crafted store values.
275+
const cat = await cls.runWith(
276+
{ userId: expectedUserId },
277+
() => service.getCatForUser()
278+
)
279+
280+
expect(cat.userId).toEqual(expectedUserId)
281+
})
282+
})
283+
})
284+
```
285+
286+
#### More information
287+
288+
Visit the [NestJS CLS GitHub Page](https://github.com/Papooch/nestjs-cls) for the full API documentation and more code examples.

src/app/homepage/menu/menu.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export class MenuComponent implements OnInit {
240240
{ title: 'Prisma', path: '/recipes/prisma' },
241241
{ title: 'Serve static', path: '/recipes/serve-static' },
242242
{ title: 'Commander', path: '/recipes/nest-commander' },
243+
{ title: 'Async Local Storage', path: '/recipes/async-local-storage' },
243244
{ title: 'Automock', path: '/recipes/automock' },
244245
],
245246
},
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
2+
import { ChangeDetectionStrategy, Component } from '@angular/core';
3+
import { BasePageComponent } from '../../page/page.component';
4+
5+
@Component({
6+
selector: 'app-async-local-storage',
7+
templateUrl: './async-local-storage.component.html',
8+
changeDetection: ChangeDetectionStrategy.OnPush,
9+
})
10+
export class AsyncLocalStorageComponent extends BasePageComponent {}

src/app/homepage/pages/recipes/recipes.module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { SqlTypeormComponent } from './sql-typeorm/sql-typeorm.component';
1616
import { TerminusComponent } from './terminus/terminus.component';
1717
import { RouterModuleComponent } from './router-module/router-module.component';
1818
import { NestCommanderComponent } from './nest-commander/nest-commander.component';
19+
import { AsyncLocalStorageComponent } from './async-local-storage/async-local-storage.component';
1920
import { AutomockComponent } from './automock/automock.component';
2021

2122
const routes: Routes = [
@@ -96,6 +97,11 @@ const routes: Routes = [
9697
component: NestCommanderComponent,
9798
data: { title: 'Nest Commander' },
9899
},
100+
{
101+
path: 'async-local-storage',
102+
component: AsyncLocalStorageComponent,
103+
data: { title: 'Async Local Storage' },
104+
},
99105
{
100106
path: 'repl',
101107
component: ReplComponent,
@@ -124,6 +130,7 @@ const routes: Routes = [
124130
RouterModuleComponent,
125131
ServeStaticComponent,
126132
NestCommanderComponent,
133+
AsyncLocalStorageComponent,
127134
AutomockComponent,
128135
ReplComponent,
129136
],

0 commit comments

Comments
 (0)