RizTech Academy logo
RizTech Academy
The Data LayerLesson 5 of 630 min

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

  1. Write order placement as four separate statements. Throw an error before the last one and inspect the mess left behind.
  2. Wrap it in $transaction and confirm the same failure leaves nothing.
  3. Use this.prisma instead of tx for one statement inside. Force a rollback and confirm that one write survived.
  4. Set a variant's stock to 1. Fire two order requests at once — curl ... & curl ... — and confirm you can oversell.
  5. Add the conditional updateMany and repeat. Confirm one succeeds and one gets a 409.
  6. Add the CHECK constraint in a migration. Try to set stock negative by hand in Prisma Studio.
  7. Put a five-second delay inside a transaction and watch it hit the timeout.
  8. Move an email send inside a transaction, then reason about what happens if the transaction rolls back after it.
  9. 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