I’ve been using NestJS for quite a while now, both professionally and for pet projects. The way it enforces certain design principles makes it easy to get started and, more importantly, helps keep projects maintainable in the long run. Compared to plain Express, which I used a few years back, Nest brings a lot of structure that naturally scales with application complexity.
That said, there was one thing I kept struggling with: maintaining transactional integrity across services.
In NestJS, we typically break applications into modules based on features or domains. Each module contains services, and those services encapsulate business logic. A single module may have multiple services, each exposing several methods that handle specific business actions. These services often call one another, sometimes even across module boundaries.
The problem starts when a service needs to orchestrate multiple other services, each of which performs changes in the database. In such cases, we often want all those changes to be atomic: either everything succeeds, or everything is rolled back.
Consider an order management system as an example. An OrderService might:
- call an
InventoryServiceto reduce stock, - call a
LedgerServiceto record financial changes, - and possibly trigger other side effects.
All of these operations must succeed together. If any one of them fails, the system should revert all previously applied changes. This is where database transactions, commit and rollback, come into play.
Most ORMs make this relatively straightforward. For example, in Prisma:
prisma.$transaction((tx) => {
// operations using tx
});
At this level, transactions are easy to work with. The real challenge, however, is how to pass this transaction context cleanly across multiple services without polluting method signatures or tightly coupling services together.
And that’s where things start to get tricky.
How do we pass this transaction around? One obvious and simple solution would be to make each service accept and optional argument, the transaction itself. Something like
async someService(argA:type, argB:type, ..., tx?:transaction){
//code
}
This works, but as the application grows, it quickly becomes messy.
The transaction parameter starts appearing everywhere, even in methods that don’t always need it. Debugging becomes harder, method signatures become noisy, and transaction objects get passed deeper and deeper through the call stack.
After running into this a few times, I started wondering: isn’t there a cleaner way to do this?
As it turns out, there is. Before getting to that, though, we need to understand Node’s Async Local Storage.
What the hell is an Async Local Storage?
Node.js provides a feature called Async Local Storage (ALS), built on top of the async_hooks API.The official documentation describes it like this:
AsyncLocalStorage allows storing data throughout the lifetime of a web request or any other asynchronous duration. It is similar to thread-local storage in other languages.
In simpler terms, Async Local Storage lets you attach data to an asynchronous execution context and access it anywhere down the async call chain, without explicitly passing it around as a function argument.
If you come from languages like Java or C#, you can think of it as the Node.js equivalent of thread-local storage. Since JavaScript doesn’t have threads in the same way, ALS instead tracks async execution contexts.
You can think of it like a hidden backpack. You put something into the backpack at the start of a request, like a database transaction, and any code executed as part of that request can retrieve it later, without knowing who put it there or how it got passed down.
We can initialize ALS once (for example, in middleware) and then use it throughout the entire request lifecycle.
A minimal ALS Setup
First, we create a shared Async Local Storage instance:
// als.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export const als = new AsyncLocalStorage<Map<string, any>>();
This is like defining the “backpack” where we’ll store transaction and other stuff and use per request.
Next, we initialize a new async context in middleware:
// als.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { als } from './als';
export function alsMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
const store = new Map<string, any>();
als.run(store, () => {
next();
});
}
Here, als.run(store, () => next()) creates a new async context and ensures that everything that happens after next() shares the same store.
Finally, we register the middleware:
// app.module.ts
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { alsMiddleware } from './als.middleware';
@Module({})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(alsMiddleware).forRoutes('*');
}
}
At this point, every request gets its own isolated context, which can be accessed anywhere during that request.
Reading and writing from the context
To store something in the ALS context:
import { als } from './als';
export class SomeService {
doSomething() {
const store = als.getStore();
if (store) {
store.set('foo', 'bar');
}
}
}
And to access:
const value = als.getStore()?.get('foo');
No extra parameters. No tight coupling. Just request-scoped state.
Using ALS with transactions
Now we can come back to the original problem.
Here’s a simple example using Prisma:
import { als } from './als';
export class SomeService {
async doSomething() {
prisma.$transaction(async (tx) => {
const store = als.getStore();
if (store) {
store.set('tx', tx);
}
await serviceA();
await serviceB();
//...
}
}
}
Inside the transaction callback, we store the Prisma transaction object in ALS. From this point on, any service called as part of this request can retrieve the transaction from ALS and use it, without having it explicitly passed in.
This keeps service APIs clean, avoids deeply nested transaction plumbing, and lets transactions remain a cross-cutting concern rather than a business-logic detail.
Learning this approach has been a nice win for me, and I’ve been happily using it ever since. It removed a lot of friction around transactions in NestJS. How are you solving this in your own applications?
Top comments (1)
nice