DEV Community

Ondřej Švanda
Ondřej Švanda

Posted on

Taming cross-service database transactions in NestJS with AsyncLocalStorage

The Problem

If you've ever bumped into the issue that you needed a single database transaction to span multiple services without breaking encapsulation of the repository layer, you'll know what I'm talking about.

If you're coming from a framework where this was just a thing (e.g. the @Transactional annotation from Spring Hibernate, @transaction.atomic from Django, or automatic contextual transactions from .NET Entity Framework) and then you learned that there's nothing like that built into NestJS, I feel your frustration.

You are either trying to create an involved solution yourself, awkwardly breaking encapsulation by passing the transaction client as a parameter around your services or straight up abandoning the idea.

This post, should serve as a guide on how to solve this issue using most known ORMs. And yes, you might be bummed to hear that the solution is often to install a 3rd party library, but that's just the world we live in.

By the way, yes, I can hear the DDD evangelists sayin that if you model your aggregates right, you won't ever need cross-service transaction. Well, yes, but not all problems need to be modeled that way.

The Solution

All of the solutions rely in one way or another on a little known, but very powerful, Node.js API called AsyncLocalStorage. If you're not familiar with the concept, I encourage you to give it a read before continuing.

Sequelize

You'll be pleased to know that Sequelize has this feature built-in, all you need is to install cls-hooked (which is a predecessor of AsyncLocalStorage, but will be replaced by it in Sequelize v7) and enable it globally with Sequelize.useCLS(...)

After that, wrapping a service call in a transaction callback would make sure the same transaction is re-used by all Sequelize queries made within the callback even without passing it explicitly.

await this.connection.transaction(async () => {
  return await this.otherService.doWork()
})
Enter fullscreen mode Exit fullscreen mode

TypeORM

TypeORM doesn't have this feature, but a maintained community package called typeorm-transactional exists to solve this shortcoming. It behaves almost exactly like the @Transactional annotation in Spring Hibernate with complete support for custom repositories.

Creating and propagating a transaction is then as simple as decorating a method with @Transactional

@Injectable()
export class PostService {
  constructor(
    private readonly repository: PostRepository,
    private readonly dataSource: DataSource
  ) {}

  @Transactional()
  async createAndGetPost(id, message): Promise<Post> {
    const post = this.repository.create({ id, message })

    await this.repository.save(post)

    return dataSource.createQueryBuilder(Post, 'p').where('id = :id', id).getOne();
  }
}
Enter fullscreen mode Exit fullscreen mode

Prisma

There have been multiple feature requests to add native support for AsyncLocalStorage to Prisma, but they haven't been met with much enthusiasm from the maintainers. Some people solved it by extending and overriding the client (which is arguably prone to breaking with updates).

However, now you can also use @nestjs-cls/transactional (made by yours truly) to enable a similar behavior.

The library also takes inspiration from Spring Hibernate (mainly to ease the learning curve), so the usage is very similar to the former, only with a little bit of extra boilerplate at the expense of delibrately not monkey-patching any underlying library.

@Injectable()
class UserService {
  constructor(
    @InjectTransaction()
    private readonly tx: Transaction<TransactionalAdapterPrisma>,
    private readonly accountService: AccountService,
  ) {}

  @Transactional()
  async createUser(name: string): Promise<User> {
    const user = await this.tx.user.create({ data: { name } });
    await this.accountService.createAccountForUser(user.id);
    return user;
  }
}

@Injectable()
class AccountService {
  constructor(
    @InjectTransaction()
    private readonly tx: Transaction<TransactionalAdapterPrisma>,
  ) {}

  async createAccountForUser(id: number): Promise<Account> {
    return this.tx.create({
      data: { userId: id, number: Math.random() },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Other ORMs

The @nestjs-cls/transactional library works in conjunction with database adapters, so an identical setup can be made with any other database library which supports callback-based transactions.

If there doesn't exist a ready-made adapter for your database library of choice, it's pretty straight-forward to create a custom one.

Top comments (0)