DEV Community 👩‍💻👨‍💻

Ken Fukuyama
Ken Fukuyama

Posted on

Cross Module Transaction with Prisma

TL;DR

Prisma and Interactive Transaction

There's no doubt that Prisma boosts your productivity when dealing with Databases in Node.js + TypeScript. But as you start creating complex software, there are some cases you can't use Prisma the way you'd like to out of the box. One of them is when you want to use the interactive transaction across modules.

What I mean by cross module is a bit obscure. Let's look at how you can write interactive transactions in Prisma. The following code is from the official docs.

await prisma.$transaction(async (prisma) => {
  // 1. Decrement amount from the sender.
  const sender = await prisma.account.update({
    data: {
      balance: {
        decrement: amount,
      },
    },
    where: {
      email: from,
    },
  })
  // 2. Verify that the sender's balance didn't go below zero.
  if (sender.balance < 0) {
    throw new Error(`${from} doesn't have enough to send ${amount}`)
  }
  // 3. Increment the recipient's balance by amount
  const recipient = prisma.account.update({
    data: {
      balance: {
        increment: amount,
      },
    },
    where: {
      email: to,
    },
  })
  return recipient
})
Enter fullscreen mode Exit fullscreen mode

The point is that you call prisma.$transaction and you pass a callback to it with the parameter prisma. Inside the transaction, you use the prisma instance passed as the callback to use it as the transaction prisma client. It's simple and easy to use. But what if you don't want to show the prisma interface inside the transaction code? Perhaps you're working with a enterprise-ish app and have a layered architecture and you are not allowed to use the prisma client in say, the application layer.

It's probably easier to look at it in code. Suppose you would like to write some transaction code like this:

await $transaction(async () => {
  // call multiple repository methods inside the transaction
  // if either fails, the transaction will rollback
  await this.orderRepo.create(order);
  await this.notificationRepo.send(
    `Successfully created order: ${order.id}`
  );
});
Enter fullscreen mode Exit fullscreen mode

There are multiple Repositories that hide the implementation details(e.g. Prisma, SNS, etc.). You would not want to show prisma inside this code because it is an implementation detail. So how can you deal with this using Prisma? It's actually not that easy because you'll somehow have to pass the Transaction Prisma Client to the Repository across modules without explicitly passing it.

Creating a custom TransactionScope

This is when I came across this issue comment. It says you can use cls-hooked to create a thread-like local storage to temporarily store the Transaction Prisma Client, and then get the client from somewhere else via CLS (Continuation-Local Storage) afterwards.

After looking at how I can use cls-hooked, here is a TransactionScope class I've created to create a transaction which can be used from any layer:

export class PrismaTransactionScope implements TransactionScope {
  private readonly prisma: PrismaClient;
  private readonly transactionContext: cls.Namespace;

  constructor(prisma: PrismaClient, transactionContext: cls.Namespace) {
    // inject the original Prisma Client to use when you actually create a transaction
    this.prisma = prisma;
    // A CLS namespace to temporarily save the Transaction Prisma Client
    this.transactionContext = transactionContext;
  }

  async run(fn: () => Promise<void>): Promise<void> {
    // attempt to get the Transaction Client
    const prisma = this.transactionContext.get(
      PRISMA_CLIENT_KEY
    ) as Prisma.TransactionClient;

    // if the Transaction Client
    if (prisma) {
      // exists, there is no need to create a transaction and you just execute the callback
      await fn();
    } else {
      // does not exist, create a Prisma transaction 
      await this.prisma.$transaction(async (prisma) => {
        await this.transactionContext.runPromise(async () => {
          // and save the Transaction Client inside the CLS namespace to be retrieved later on
          this.transactionContext.set(PRISMA_CLIENT_KEY, prisma);

          try {
            // execute the transaction callback
            await fn();
          } catch (err) {
            // unset the transaction client when something goes wrong
            this.transactionContext.set(PRISMA_CLIENT_KEY, null);
            throw err;
          }
        });
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

You can see that the Transaction Client is created inside this class and is saved inside the CLS namespace. Hence, the repositories who want to use the Prisma Client can retrieve it from the CLS indirectly.

Is this it? Actually, no. There's one more point you have to be careful when using transactions in Prisma. It's that the prisma instance inside the transaction callback has different types than the original prisma instance. You can see this in the type definitions:

export type TransactionClient = Omit<PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use'>
Enter fullscreen mode Exit fullscreen mode

Be aware that the $transaction method is being Omitted. So, you can see that at this moment you cannot create nested transactions using Prisma.

To deal with this, I've created a PrismaClientManager which returns a Transaction Prisma Client if it exists, and if not, returns the original Prisma Client. Here's the implementation:

export class PrismaClientManager {
  private prisma: PrismaClient;
  private transactionContext: cls.Namespace;

  constructor(prisma: PrismaClient, transactionContext: cls.Namespace) {
    this.prisma = prisma;
    this.transactionContext = transactionContext;
  }

  getClient(): Prisma.TransactionClient {
    const prisma = this.transactionContext.get(
      PRISMA_CLIENT_KEY
    ) as Prisma.TransactionClient;
    if (prisma) {
      return prisma;
    } else {
      return this.prisma;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

It's simple, but notice that the return type is Prisma.TransactionClient. This means that the Prisma Client returned from this PrismaClientManager always returns the Prisma.TransactionClient type. Therefore, this client cannot create a transaction.

This is the constraint I made in order to achieve this cross module transaction using Prisma. In other words, you cannot call prisma.$transaction from within repositories. Instead, you always use the TransactionScope class I mentioned above.

It will create transactions if needed, and won't if it isn't necessary. So, from repositories, you can write code like this:

export class PrismaOrderRepository implements OrderRepository {
  private readonly clientManager: PrismaClientManager;
  private readonly transactionScope: TransactionScope;

  constructor(
    clientManager: PrismaClientManager,
    transactionScope: TransactionScope
  ) {
    this.clientManager = clientManager;
    this.transactionScope = transactionScope;
  }

  async create(order: Order): Promise<void> {
    // you don't need to care if you're inside a transaction or not
    // just use the TransactionScope
    await this.transactionScope.run(async () => {
      const prisma = this.clientManager.getClient();
      const newOrder = await prisma.order.create({
        data: {
          id: order.id,
        },
      });

      for (const productId of order.productIds) {
        await prisma.orderProduct.create({
          data: {
            id: uuid(),
            orderId: newOrder.id,
            productId,
          },
        });
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

If the repository is used inside a transaction, no transaction will be created again (thanks to the PrismaClientManager). If the repository is used outside a transaction, a transaction will be created and consistency will be kept between the Order and OrderProduct data.

Finally, with the power of the TransactionScope class, you can create a transaction from the application layer as follows:

export class CreateOrder {
  private readonly orderRepo: OrderRepository;
  private readonly notificationRepo: NotificationRepository;
  private readonly transactionScope: TransactionScope;
  constructor(
    orderRepo: OrderRepository,
    notificationRepo: NotificationRepository,
    transactionScope: TransactionScope
  ) {
    this.orderRepo = orderRepo;
    this.notificationRepo = notificationRepo;
    this.transactionScope = transactionScope;
  }

  async execute({ productIds }: CreateOrderInput) {
    const order = Order.create(productIds);

    // create a transaction scope inside the Application layer
    await this.transactionScope.run(async () => {
      // call multiple repository methods inside the transaction
      // if either fails, the transaction will rollback
      await this.orderRepo.create(order);
      await this.notificationRepo.send(
        `Successfully created order: ${order.id}`
      );
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that the OrderRepository and NotificationRepository are inside the same transaction and therefore, if the Notification fails, you can rollback the data which was saved from the OrderRepository (leave the architecture decision for now 😂. you get the point.). Therefore, you don't have to mix the database responsibilities with the notification responsibilities.

Wrap up

I've shown how you can create a TransactionScope using Prisma in Node.js. It's not ideal, but looks like it's working as expected. I've seen people struggling about this architecture and hope this post comes in some kind of help.

Feedbacks are extremely welcome!

Prisma cross module transaction PoC

This is a PoC to see if cross module transaction is possible with Prisma.

Despite Prisma being able to use interactive transaction, it forces you to use a newly created Prisma.TransactionClient as follows:

// copied from official docs https://www.prisma.io/docs/concepts/components/prisma-client/transactions#batchbulk-operations
await prisma.$transaction(async (prisma) => {
  // 1. Decrement amount from the sender.
  const sender = await prisma.account.update({
    data: {
      balance: {
        decrement: amount,
      },
    },
    where: {
      email: from,
    },
  });
  // 2. Verify that the sender's balance didn't go below zero.
  if (sender.balance < 0) {
    throw new Error(`${from} doesn't have enough to send ${amount}`);
  }
  // 3. Increment the recipient's balance by amount
  const recipient =
Enter fullscreen mode Exit fullscreen mode

Top comments (0)

🌚 Life is too short to browse without dark mode