#Use transactions across services

1 messages · Page 1 of 1 (latest)

low mica
#

I recently switched from Prisma to TypeORM. From what I understand, the best practice with TypeORM in NestJS is to keep repositories scoped to their own feature modules. For example, the ProductsRepository should only be used within the ProductsService and registered in the ProductsModule.

The challenge I’m facing is when I need to perform operations that span multiple entities. For example, in a CartsService, I might need to modify products along with other database operations. With Prisma, this was straightforward: I use prisma.$transaction to work directly with the entities in a single transactional context (tho this has his cons I know ).

With TypeORM, however, this becomes tricky. I don’t want to inject the ProductsRepository directly into the CartsService, since that breaks the separation of concerns and goes against Nest/TypeORM best practices. I thought about passing a QueryRunner into the ProductsService as an optional argument, but that would require code changes and feels inelegant, breaking the centralized, clean NestJS design.

So, what’s the best way to handle cross-entity transactional operations in TypeORM while still keeping services and repositories properly encapsulated?

fossil dawn
#

I use this package at work https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional.

This way you can have your business logic encapsulated in your service and internally use this.txHost.tx to use the EntityManager within a transaction or this.txHost.tx.getRepository(YourEntity) to use the repository within a transaction.

It's not hard to use and honestly it helped me to clean the code because before I was send the EntityManager through function parameters and it was a mess

mild wasp
#

I'm not sure why you feel passing a QueryRunner between services is inelegant but I totally do understand why it may seem unusual or difficult to get your head around.

Previously I had ended up created a "main" service file that imported all the different services I needed to potentially use but this really got out of hand quickly when you have more than 4 services.

For instance:

export class mainService {
  constructor(
    private  otherServiceA: OtherServiceA,
    private  otherServiceB: OtherServiceB,
    private  otherServiceC: OtherServiceC,
    private  otherServiceD: OtherServiceD,
    // etc....
  ) {}

async doSomething(dto: mainServiceDto, queryRunner?: QueryRunner): Promise<void> {
const thingA = await this.otherSerivceA.doAThing(dto, queryRunner)
if(!thingA){
throw new BadRequestException(`No thingA`}
const thingB = await this.otherServiceB.doBThing(thingA, queryRunner)
if(!thingB){
throw new BadRequestException(`No thingB`)
}
// etc...
}```

While this does keep our services and repositories nicely separated, it does mean having to manage this mainService that can really appear messy and 'could' become difficult to understand completely and follow the logic. The main benefit here is because we use the same QueryRunner throughout, we do get transactional safety if any of these services fail to perform or return values as expected.
#

Now, if you're truly hellbent on keeping all your services and respositories totally isolated you 'could', not that you should, use an async eventEmitter to fire off requests to other services in your application and wait for values/operations to be completed before proceeding.

Here's a cursed example, also you still need to pass a queryRunner manager around to ensure transactional safety:

Say we have two Tables (User and Cart). A User can have many Carts, but a Cart can only have one User. So it's a basic ManyToOne relation for the User. The scenario is when we create a new User we want to automatically create a new Cart for them. Usually we'd just call cartService.createOne() in the userService, wait for a return value, then create a User record with the Cart, but we could just trigger events.

export class UserController {
  constructor(
    private readonly userService: UserService,
    private readonly dataSoure: DataSource
  ) {}

  @Post()
  async createOne(@Body() createDto: CreateUserDto) {
    const queryRunner = this.dataSoure.createQueryRunner()
    await queryRunner.connect()
    try {
      await queryRunner.startTransaction()
      const result = await this.userService.createOne(createDto, queryRunner)
      await queryRunner.commitTransaction()
      return result
    } catch (error) {
      await queryRunner.rollbackTransaction()
      throw error
    } finally {
      await queryRunner.release()
    }
  }```