Transactions, where they genuinely matter
Most of the time you can ignore transactions. Placing an order is not one of those times — and the difference between a shop that works and one that loses money is largely in this lesson.
The problem
Placing an order does several things:
await this.prisma.order.create({ data: { ... } });
await this.prisma.orderItem.createMany({ data: items });
await this.prisma.variant.update({ where: { id }, data: { stock: { decrement: 2 } } });
await this.prisma.cart.delete({ where: { userId } });
Four statements. If the third fails — the connection drops, the process restarts, the database briefly refuses — you now have an order with items, no stock deducted, and a cart still full. The customer is charged for goods you have not reserved, and your stock is wrong.
Partial failure is the problem. A transaction makes all four happen or none.
$transaction
await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: { userId, status: "PENDING" } });
await tx.orderItem.createMany({
data: items.map((i) => ({
orderId: order.id,
variantId: i.variantId,
quantity: i.quantity,
unitPricePaise: i.pricePaise,
})),
});
for (const item of items) {
await tx.variant.update({
where: { id: item.variantId },
data: { stock: { decrement: item.quantity } },
});
}
await tx.cart.delete({ where: { userId } });
return order;
});
Use tx, not this.prisma, inside. Anything using the outer client runs
outside the transaction and will not be rolled back. That is the most common
mistake here, and it produces exactly the partial-failure bug you were trying
to prevent.
Throwing rolls everything back. There is no commit call — returning commits, throwing aborts.
The array form for independent operations:
const [items, total] = await this.prisma.$transaction([
this.prisma.product.findMany({ where }),
this.prisma.product.count({ where }),
]);
Which is what the pagination lesson used.
The race that actually costs money
Two customers, one packet of atta left:
Request A: read stock → 1 ✓ enough
Request B: read stock → 1 ✓ enough
Request A: set stock = 0
Request B: set stock = -1
Both orders succeed. You have sold one packet twice.
A transaction alone does not prevent this. Both transactions read 1 before either wrote. This is a race condition, and it needs something stronger.
Fix 1: a conditional update
The best answer, because it makes the database decide:
const updated = await tx.variant.updateMany({
where: { id: item.variantId, stock: { gte: item.quantity } },
data: { stock: { decrement: item.quantity } },
});
if (updated.count === 0) {
throw new ConflictException(`${item.label} is no longer available`);
}
The condition and the write are one statement, so there is no window between checking and acting. Whichever request arrives second matches zero rows and throws, rolling back its whole order.
updateMany rather than update because update throws when nothing matches,
and we want the count.
This pattern — check and write in one statement — is the general fix for this class of bug. It applies to reserving a seat, claiming a coupon, and decrementing any limited resource.
Fix 2: a database constraint
Belt and braces:
ALTER TABLE variants ADD CONSTRAINT stock_not_negative CHECK (stock >= 0);
Add it in a migration. Now negative stock is impossible regardless of what your code does — including a future bug, a manual query, or an admin script.
Constraints are the only guarantee that survives your code being wrong.
Fix 3: explicit locking
await this.prisma.$transaction(async (tx) => {
const [variant] = await tx.$queryRaw<Variant[]>`
SELECT * FROM variants WHERE id = ${id} FOR UPDATE
`;
if (variant.stock < quantity) throw new ConflictException("Out of stock");
await tx.variant.update({ where: { id }, data: { stock: variant.stock - quantity } });
});
FOR UPDATE locks the row until the transaction ends, so the second request
waits.
Correct, and slower — requests queue. It also risks deadlock if two transactions lock rows in different orders. Prefer the conditional update. Use locking when the logic genuinely needs to read, compute and then write.
Isolation levels
await this.prisma.$transaction(
async (tx) => { ... },
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
PostgreSQL defaults to ReadCommitted: you see data committed before each
statement.
Serializable behaves as if transactions ran one at a time. It is the strongest
guarantee and it can fail — PostgreSQL aborts one transaction with a
serialization error, and your code must retry.
Rarely needed. The conditional update solves the common cases without it.
Timeouts
await this.prisma.$transaction(async (tx) => { ... }, {
maxWait: 5000,
timeout: 10000,
});
timeout defaults to 5 seconds. A long transaction holds locks and blocks
others.
Never do slow work inside a transaction:
await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({ ... });
await this.payments.charge(order); // an external API — do not
await this.mailer.send(order); // also do not
});
An external call can take seconds and can fail in ways you cannot roll back. Worse, you cannot un-charge a card by rolling back a transaction.
Keep transactions to database work. Charge the card first, then record the result in a short transaction. Send email after the transaction commits.
This ordering matters for payments and is covered properly in module 14.
When you do not need one
Most reads. A single create or update is already atomic.
await this.prisma.$transaction(async (tx) => {
return tx.product.findMany(); // pointless
});
Use a transaction when two or more writes must succeed or fail together. That is the whole rule.
The Kirana Store cases
Worth listing, because these are the ones that matter:
| Operation | Transaction | Why |
|---|---|---|
| Place an order | yes | order, items, stock, cart together |
| Cancel an order | yes | status change plus stock restoration |
| Add to cart | no | one write |
| Update a product | no | one write |
| Bulk price import | yes | all prices or none |
| Record a payment | yes | payment row plus order status |
| List products | no | reads |
Check your work
Why tx and not this.prisma inside a transaction: the outer client runs
outside it, so those writes are not rolled back.
How a transaction commits: by returning. Throwing rolls back. There is no explicit commit.
Why a transaction alone does not prevent overselling: both transactions can read the same stock before either writes.
The best fix: a conditional update — where: { stock: { gte: quantity } } —
so the check and the write are one statement with no window between them.
Why updateMany rather than update: update throws when nothing matches;
updateMany returns a count you can check.
What a CHECK constraint adds: a guarantee that holds even when your code is wrong.
Why not call a payment API inside a transaction: it is slow, it holds locks, and a rollback cannot undo a charge.
When you need a transaction: two or more writes that must succeed or fail together.
Practice
- Write order placement as four separate statements. Throw an error before the last one and inspect the mess left behind.
- Wrap it in
$transactionand confirm the same failure leaves nothing. - Use
this.prismainstead oftxfor one statement inside. Force a rollback and confirm that one write survived. - Set a variant's stock to 1. Fire two order requests at once —
curl ... & curl ...— and confirm you can oversell. - Add the conditional
updateManyand repeat. Confirm one succeeds and one gets a 409. - Add the CHECK constraint in a migration. Try to set stock negative by hand in Prisma Studio.
- Put a five-second delay inside a transaction and watch it hit the timeout.
- Move an email send inside a transaction, then reason about what happens if the transaction rolls back after it.
- List every write in your application and decide which need a transaction.
Next: seed data you can actually develop against.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship