Chibuzor Daniel Uwezukwe

Why Postgres Transactions Saved Our Order Flow

2026-09-07

Early in Storich's development, our order creation flow did roughly this:

  1. Create the order record
  2. Deduct stock from each product
  3. Clear the user's cart
  4. Send a confirmation email

It worked fine in testing. Then we hit a production case where step 2 failed halfway through, one product updated, another did not, and we ended up with a paid order referencing stock we had not properly reserved. The fix was Postgres transactions.

What is a Transaction?

A transaction groups multiple database operations into a single atomic unit. Either all of them succeed (commit) or none of them apply (rollback). There is no in-between.

The four properties that guarantee this are called ACID:

The Problem Without Transactions

// Dangerous, no transaction
async createOrder(userId: string, cartItems: CartItem[]) {
  const order = await this.orderRepo.save({ userId, status: 'pending' });

  for (const item of cartItems) {
    // If this throws on item 3, items 1 and 2 are already decremented
    await this.productRepo.decrement(
      { id: item.productId },
      'stock',
      item.quantity,
    );
  }

  await this.cartRepo.delete({ userId });
  // Order exists but cart is not cleared and stock is partially wrong
}

If anything throws after orderRepo.save, you have corrupted state, an order with no corresponding stock deduction, or partial stock deduction with no order.

The Fix with TypeORM QueryRunner

TypeORM exposes transactions via QueryRunner, which gives you a dedicated database connection you fully control:

async createOrder(userId: string, cartItems: CartItem[]): Promise<Order> {
  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    const order = await queryRunner.manager.save(Order, {
      userId,
      status: 'pending',
      orderNumber: this.generateOrderNumber(),
    });

    for (const item of cartItems) {
      const product = await queryRunner.manager.findOne(Product, {
        where: { id: item.productId },
        lock: { mode: 'pessimistic_write' }, // prevents race conditions
      });

      if (!product || product.stock < item.quantity) {
        throw new BadRequestException(
          `Insufficient stock for ${product?.name}`,
        );
      }

      await queryRunner.manager.decrement(
        Product,
        { id: item.productId },
        'stock',
        item.quantity,
      );

      await queryRunner.manager.save(OrderItem, {
        orderId: order.id,
        productId: item.productId,
        quantity: item.quantity,
        unitPrice: product.price,
      });
    }

    await queryRunner.manager.delete(CartItem, { userId });

    await queryRunner.commitTransaction();
    return order;
  } catch (error) {
    await queryRunner.rollbackTransaction();
    throw error;
  } finally {
    await queryRunner.release(); // always release back to pool
  }
}

Pessimistic Locking

Notice lock: { mode: 'pessimistic_write' } on the product fetch. Without it, two concurrent orders for the same product could both read a stock of 1, both pass the stock check, and both decrement, leaving stock at -1. Pessimistic write locking tells Postgres to hold a row-level lock until the transaction commits, so the second request blocks until the first is done.

-- What TypeORM generates under the hood
SELECT * FROM product WHERE id = $1 FOR UPDATE;

Simpler Alternative: dataSource.transaction()

For less complex flows, TypeORM offers a cleaner wrapper:

await this.dataSource.transaction(async (manager) => {
  await manager.save(Order, orderData);
  await manager.decrement(Product, { id: productId }, 'stock', qty);
  await manager.delete(CartItem, { userId });
});

It handles commit and rollback automatically. Use QueryRunner when you need fine-grained control (savepoints, custom isolation levels); use dataSource.transaction() for straightforward flows.

Key Takeaways