Blog
node.jsmongodbbackendclean code

How to use MongoDB transactions in the service layer with Unit of Work.

How I applied the Unit of Work pattern combined with AsyncLocalStorage to orchestrate transactions across multiple repositories in Node.js.

12 de julho de 20264 min read

Recently, in a project where I have domain-level separations with submodules, I ran into the following situation: submodule B does a save of domain object B, but needs to associate that object's id with object A, which made the request.

Since these are submodules within the same domain, a quick option would be to call A's persistence layer directly from B, using the same transaction. But I like the idea that each submodule takes care of its own responsibilities. Another option would be wrapping the call in a try/catch and exposing a method that deletes object B if an error occurs later — but I also find that approach a bit more verbose and fragile.

While researching, I found a pattern called Unit of Work, which is basically a design pattern that groups multiple database operations into a single transaction.

From there, I paired this pattern with Node.js's AsyncLocalStorage, storing the session in the request context. This way, in the service layer, we can group calls that need to be in a single transaction within a function, and each method, in its respective repository, retrieves the session directly from AsyncLocalStorage.

Why AsyncLocalStorage?

Without it, we would likely need to pass the session as a parameter in service and repository methods, leaking database types and operations into a layer closer to the domain, which shouldn't know about infrastructure details.

With AsyncLocalStorage, we simply wrap the calls in a function, and each method in the persistence layer retrieves the session directly from context, without needing to receive it explicitly.

Code

Method responsible for the MongoDB transaction logic:

async withTransaction<T>(
  fn: (session: ClientSession) => Promise<T>,
): Promise<T> {
  const session = this.client.startSession();
  try {
    session.startTransaction();
    const result = await fn(session);
    await session.commitTransaction();
    return result;
  } catch (error) {
    await session.abortTransaction();
    throw error;
  } finally {
    await session.endSession();
  }
}

Our Unit of Work pattern class using AsyncLocalStorage:

const storage = new AsyncLocalStorage<ClientSession>();
 
@Injectable()
export class UnitOfWorkService {
  constructor(private readonly db: DatabaseService) {}
 
  async run<T>(fn: () => Promise<T>): Promise<T> {
    const existingSession = storage.getStore();
 
    if (existingSession) {
      return fn();
    }
 
    return this.db.withTransaction(async (session) => {
      return storage.run(session, fn);
    });
  }
 
  static getStore(): ClientSession {
    const session = storage.getStore();
    if (!session) throw new Error('No active transaction');
    return session;
  }
}

Method in the service wrapping both calls that need to be in the same transaction:

async create(userId: string, data: DTO): Promise<void> {
  await this.uow.run(async () => {
    const id = await this.repositoryB.create(data);
    await this.serviceA.associate(userId, id);
  });
}

Getting the session from AsyncLocalStorage and using it in the call:

async create(data: Input): Promise<string> {
  const session = UnitOfWorkService.getStore();
 
  const document = Schema.parse(data);
  const result = await this.collection.insertOne(document, { session });
  return result.insertedId.toString();
}

Example flow

ServiceB.create()
   -> uow.run(fn)
       -> withTransaction(session)
           -> storage.run(session, fn)
               -> repositoryB.create(data)     // getStore() -> gets the session
               -> serviceA.associate()
                   -> repositoryA.associate()  // getStore() -> gets the session
           -> commitTransaction()

This same reasoning applies regardless of how deep the chain is. With 3 levels, for example, it looks like this:

uow.run(fn1)                          // ROOT -> getStore() empty -> opens real transaction
  -> repositoryA.create()             // level 1: saves A
  -> serviceB.doSomething()
       -> uow.run(fn2)                // getStore() already has session -> just reuses it
            -> repositoryB.create()   // level 2: saves B
            -> serviceC.doSomethingElse()
                 -> uow.run(fn3)      // getStore() already has session -> just reuses it
                      -> repositoryC.create()  // level 3: ERROR here

// error propagates (throw) through fn3 -> fn2 -> fn1
// only the ROOT withTransaction has try/catch
// catch -> session.abortTransaction()
// result: A, B, and C are all rolled back together, nothing is half-committed

This approach allowed me to have atomic transactions across multiple repositories, with each submodule taking care of its own persistence layer.