All-or-Nothing Multi-Step Writes
Database Transactions
Wrap related writes in a transaction so they commit together or roll back together. Just don't make the transaction wait on slow work.
What you'll learn
- Open a transaction with DataSource (TypeORM) or $transaction (Prisma)
- Handle errors and rollback
- Avoid running long async work inside a transaction
A transaction groups multiple writes so the database either applies all of them or none of them. The classic example: transfer money from one account to another — you must not debit without crediting.
TypeORM Transactions
The DataSource.transaction helper runs a callback inside a managed
transaction. If the callback throws, TypeORM rolls back.
@Injectable()
export class PaymentsService {
constructor(private readonly dataSource: DataSource) {}
async transfer(fromId: number, toId: number, amount: number) {
return this.dataSource.transaction(async (manager) => {
const from = await manager.findOneByOrFail(Account, { id: fromId });
const to = await manager.findOneByOrFail(Account, { id: toId });
if (from.balance < amount) {
throw new Error('Insufficient funds');
}
from.balance -= amount;
to.balance += amount;
await manager.save([from, to]);
});
}
} The manager parameter is an EntityManager scoped to the transaction.
Use it instead of repositories so all reads and writes participate.
Prisma Transactions
Prisma has two styles. The array form runs the operations atomically with no logic in between:
await this.prisma.$transaction([
this.prisma.account.update({ where: { id: fromId }, data: { balance: { decrement: amount } } }),
this.prisma.account.update({ where: { id: toId }, data: { balance: { increment: amount } } }),
]); For conditional logic, use the interactive form — same shape as TypeORM:
await this.prisma.$transaction(async (tx) => {
const from = await tx.account.findUniqueOrThrow({ where: { id: fromId } });
if (from.balance < amount) throw new Error('Insufficient funds');
await tx.account.update({ where: { id: fromId }, data: { balance: { decrement: amount } } });
await tx.account.update({ where: { id: toId }, data: { balance: { increment: amount } } });
}); Keep Transactions Short
A transaction holds row locks until it commits. Anything slow inside one blocks other writes touching the same rows.
A few rules that have saved me:
- Never call an external HTTP API inside a transaction. A flaky vendor will tank your throughput.
- Don’t send emails or queue jobs inside one — if the transaction rolls back, you’ve still sent the email. Use a “transactional outbox”: write the event to a table inside the transaction, fan it out after.
- Don’t await user input. Sounds obvious, but I’ve seen it.
If you need to do slow work and then a write, do the slow work first, then open a short transaction at the end.
Pagination Patterns →