Backend Architecture Series — Jocoso.cl eCommerce · #03
Ecommerce Stock: SELECT FOR UPDATE and Serializable Transactions with Prisma
How to prevent race conditions and maintain complete traceability with StockMovement
The Problem: Race Conditions in Stock
Imagine two users buying the last size simultaneously. With a naive SELECT + UPDATE implementation, both transactions read stock = 1, validate OK, and both decrement — stock ends up at -1. This scenario is not theoretical: on any Latin American ecommerce's Black Friday, real concurrency makes it inevitable.
■ Technical Decision
Use
$transactionwithisolationLevel: 'Serializable'+ SELECT FOR UPDATE via$queryRaw. Prisma ORM does not expose SELECT FOR UPDATE natively — raw queries inside the managed transaction are required.
The Golden Rule: Stock Only Through Movements
The ProductVariant.stock field is never modified directly from the application. Every stock change — sale, restock, manual adjustment, return — goes through a StockMovement. This guarantees complete audit: the historical stock can always be reconstructed by summing the movements.
StockMovement Model
model StockMovement {
id String @id @default(uuid())
variantId String
quantity Int // positive = entry, negative = exit
source StockSource // ORDER | MANUAL | RETURN | ML_SALE
referenceType ReferenceType // ORDER | PAYMENT | MANUAL_ADJUSTMENT
referenceId String
externalId String @unique // idempotency (ML webhooks)
userId String? // who executed the movement
createdAt DateTime @default(now())
}The Real Code: SELECT FOR UPDATE
// infrastructure/stock/stock.prisma-repo.ts
async decreaseWithLock(
variantId: string,
amount: number,
movement: StockMovement,
): Promise<void> {
await this.prisma.$transaction(
async (tx) => {
// 1. Acquire exclusive row lock
const rows = await tx.$queryRaw<VariantStockRow[]>`
SELECT stock FROM product_variants
WHERE id = ${variantId} FOR UPDATE
`;
if (!rows.length) throw new NotFoundException('Variant not found');
const current = rows[0].stock;
// 2. Validate in domain
if (current < amount) {
throw new BadRequestException('Insufficient stock');
}
// 3. Decrement and record movement atomically
await tx.$executeRaw`
UPDATE product_variants
SET stock = stock - ${amount}, updated_at = NOW()
WHERE id = ${variantId}
`;
await tx.stockMovement.create({ data: movement.toPersistence() });
},
{ isolationLevel: 'Serializable' },
);
}Why Prisma ORM Is Not Enough
Prisma exposes $transaction() for atomic operations, but has no high-level API for SELECT FOR UPDATE. The solution is $queryRaw inside the transaction: the Prisma client manages the connection and isolation level, while the SQL query guarantees row-level locking. This decision was explicitly documented so future developers understand why raw queries exist in a project using ORM.
Idempotency with externalId
MercadoLibre (and any external webhook) can send the same notification multiple times. The externalId @unique field in StockMovement guarantees that a double webhook does not decrement stock twice:
// If the movement already exists, Prisma throws Unique Constraint Violation
// The use case catches it and returns 200 OK (idempotent)
try {
await this.stockRepo.decreaseWithLock(variantId, qty, movement);
} catch (e) {
if (isUniqueConstraintViolation(e)) return; // already processed
throw e;
}Pure Domain: Stock Entity and StockDomainService
Validation logic lives in the domain, not infrastructure. The Stock entity and its domain service encapsulate business rules:
stocknever negative — enforced in domain AND in DB constraintcanDecrease(amount): throwsDomainExceptionifstock < amountStockDomainService.validateDecrease()coordinates entity + business rules
■ Trade-offs
Serializable isolation vs Read Committed. Serializable prevents all concurrency phenomena (dirty reads, non-repeatable reads, phantom reads) but has higher overhead from conflict management. For stock operations correctness trumps throughput: an ecommerce prefers rejecting a sale over selling nonexistent stock.
Traceability with source + referenceType
Each StockMovement records who, why, and from where. When integrating with MercadoLibre, ML sales are created with source: ML_SALE and referenceType: ORDER, distinguishing them from direct web channel sales. This enables audits, accounting reconciliation, and channel-by-channel analysis.