Taming cross-service database transactions in NestJS with AsyncLocalStorage
Tags: prisma, typeorm, sequelize, nestjs
Published: 2024-02-21 21:51:03

## 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.

<small>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.</small>

## The Solution

All of the solutions rely in one way or another on a little known, but very powerful, Node.js API called [`AsyncLocalStorage`](https://docs.nestjs.com/recipes/async-local-storage). 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](https://sequelize.org/docs/v6/other-topics/transactions/#automatically-pass-transactions-to-all-queries) 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.
```ts
await this.connection.transaction(async () => {
  return await this.otherService.doWork()
})
```

### TypeORM
TypeORM doesn't have this feature, but a maintained community package called [typeorm-transactional](https://github.com/Aliheym/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`

```ts
@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();
  }
}
```

### Prisma
There have been multiple [feature requests](https://github.com/prisma/prisma/issues/5729) 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`](https://papooch.github.io/nestjs-cls/plugins/available-plugins/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.

```ts
@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() },
    });
  }
}
```

### Other ORMs

The `@nestjs-cls/transactional` library works in conjunction with [database adapters](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional#installation), 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.