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.
awaitprisma.$transaction(async (prisma)=>{// 1. Decrement amount from the sender.constsender=awaitprisma.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){thrownewError(`${from} doesn't have enough to send ${amount}`)}// 3. Increment the recipient's balance by amountconstrecipient=prisma.account.update({data:{balance:{increment:amount,},},where:{email:to,},})returnrecipient})
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 rollbackawaitthis.orderRepo.create(order);awaitthis.notificationRepo.send(`Successfully created order: ${order.id}`);});
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:
exportclassPrismaTransactionScopeimplementsTransactionScope{privatereadonlyprisma:PrismaClient;privatereadonlytransactionContext:cls.Namespace;constructor(prisma:PrismaClient,transactionContext:cls.Namespace){// inject the original Prisma Client to use when you actually create a transactionthis.prisma=prisma;// A CLS namespace to temporarily save the Transaction Prisma Clientthis.transactionContext=transactionContext;}asyncrun(fn:()=>Promise<void>):Promise<void>{// attempt to get the Transaction Clientconstprisma=this.transactionContext.get(PRISMA_CLIENT_KEY)asPrisma.TransactionClient;// if the Transaction Clientif (prisma){// exists, there is no need to create a transaction and you just execute the callbackawaitfn();}else{// does not exist, create a Prisma transaction awaitthis.prisma.$transaction(async (prisma)=>{awaitthis.transactionContext.runPromise(async ()=>{// and save the Transaction Client inside the CLS namespace to be retrieved later onthis.transactionContext.set(PRISMA_CLIENT_KEY,prisma);try{// execute the transaction callbackawaitfn();}catch (err){// unset the transaction client when something goes wrongthis.transactionContext.set(PRISMA_CLIENT_KEY,null);throwerr;}});});}}}
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:
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:
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:
exportclassPrismaOrderRepositoryimplementsOrderRepository{privatereadonlyclientManager:PrismaClientManager;privatereadonlytransactionScope:TransactionScope;constructor(clientManager:PrismaClientManager,transactionScope:TransactionScope){this.clientManager=clientManager;this.transactionScope=transactionScope;}asynccreate(order:Order):Promise<void>{// you don't need to care if you're inside a transaction or not// just use the TransactionScopeawaitthis.transactionScope.run(async ()=>{constprisma=this.clientManager.getClient();constnewOrder=awaitprisma.order.create({data:{id:order.id,},});for (constproductIdoforder.productIds){awaitprisma.orderProduct.create({data:{id:uuid(),orderId:newOrder.id,productId,},});}});}}
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:
exportclassCreateOrder{privatereadonlyorderRepo:OrderRepository;privatereadonlynotificationRepo:NotificationRepository;privatereadonlytransactionScope:TransactionScope;constructor(orderRepo:OrderRepository,notificationRepo:NotificationRepository,transactionScope:TransactionScope){this.orderRepo=orderRepo;this.notificationRepo=notificationRepo;this.transactionScope=transactionScope;}asyncexecute({productIds}:CreateOrderInput){constorder=Order.create(productIds);// create a transaction scope inside the Application layerawaitthis.transactionScope.run(async ()=>{// call multiple repository methods inside the transaction// if either fails, the transaction will rollbackawaitthis.orderRepo.create(order);awaitthis.notificationRepo.send(`Successfully created order: ${order.id}`);});}}
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.
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-operationsawaitprisma.$transaction(async(prisma)=>{// 1. Decrement amount from the sender.constsender=awaitprisma.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){thrownewError(`${from} doesn't have enough to send ${amount}`);}// 3. Increment the recipient's balance by amountconstrecipient=