
Learn how to implement safe, atomic database writes in NestJS using Prisma transactions to prevent data corruption and race conditions.
Hello! When building backend applications for clients, data integrity is everything. Imagine an e-commerce checkout or a financial transfer where money leaves an account, but the receiving record fails to update. Without proper transaction handling, your database ends up in an inconsistent state.
In this guide, I want to show you how I handle complex database operations safely using NestJS, Prisma, and PostgreSQL. We will look at how to use interactive transactions to ensure multiple dependent queries either all succeed or all roll back.
By default, every Prisma Client query executes independently. If you run two write operations back to back like this:
await this.prisma.account.update({
where: { id: senderId },
data: { balance: { decrement: 100 } },
});
// What happens if your server crashes right here?
await this.prisma.account.update({
where: { id: receiverId },
data: { balance: { increment: 100 } },
});
If something goes wrong between the first and second query, the sender loses money, but the receiver never gets it. In a production environment, this is a nightmare.
When I build scalable backends—whether it is for logistics platforms like JobsiteX or data-heavy apps—I rely heavily on database transactions to guarantee atomicity.
Prisma provides two types of transactions: Sequential Transactions ($transaction([ ... ])) and Interactive Transactions ($transaction(async (prisma) => { ... })).
For most real-world scenarios that require reading data, running conditional checks, and writing updates, interactive transactions are the best choice.
Here is a practical service example inside a NestJS application:
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Injectable()
export class WalletService {
constructor(private readonly prisma: PrismaService) {}
async transferFunds(senderId: string, receiverId: string, amount: number) {
return this.prisma.$transaction(async (tx) => {
// 1. Check sender balance inside the transaction
const sender = await tx.account.findUnique({
where: { id: senderId },
});
if (!sender || sender.balance < amount) {
throw new BadRequestException('Insufficient funds');
}
// 2. Deduct from sender
const updatedSender = await tx.account.update({
where: { id: senderId },
data: { balance: { decrement: amount } },
});
// 3. Add to receiver
await tx.account.update({
where: { id: receiverId },
data: { balance: { increment: amount } },
});
// 4. Create an immutable transaction log
await tx.transactionLog.create({
data: {
senderId,
receiverId,
amount,
status: 'COMPLETED',
},
});
return updatedSender;
});
}
}
Tagged with
Leave a comment
Notice how we use the transaction client parameter (commonly named tx) instead of this.prisma inside the callback function. If any query fails, or if our BadRequestException is thrown, Prisma automatically rolls back all changes made within that block.
Database concurrency can introduce subtle bugs. If two requests try to update the same record at the exact same time, you might run into race conditions or deadlocks.
When writing robust NestJS APIs, keep these tips in mind:
Keeping your database logic modular in NestJS makes testing much easier. I usually create a dedicated PrismaService that extends PrismaClient and handles connection lifecycle hooks:
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
By injecting this service into your feature modules, you can cleanly manage transactions across different controllers and services without tightly coupling your business logic to raw database drivers.
Implementing transactions properly in NestJS and Prisma is essential for any application that handles sensitive data, payments, or complex state changes. Taking a few extra minutes to wrap your multi-step queries in a transaction block saves hours of debugging data corruption later.
Need help architecting a secure, high-performance backend or full-stack application for your business? Get in touch and let's discuss your project.