Backend Architecture Series — Jocoso.cl eCommerce · #03
Stock en ecommerce: SELECT FOR UPDATE y transacciones serializables con Prisma
Cómo evitar race conditions y mantener trazabilidad completa con StockMovement
El problema: race conditions en stock
Imagina dos usuarios comprando el último par de tallas simultáneamente. Con una implementación naive de SELECT + UPDATE separados, ambas transacciones leen stock = 1, validan OK, y ambas decrementan — el stock queda en -1. Este escenario no es teórico: en el Black Friday de cualquier ecommerce latinoamericano la concurrencia real lo hace inevitable.
■ Decisión Técnica
Usar
$transactionconisolationLevel: 'Serializable'+ SELECT FOR UPDATE via$queryRaw. Prisma ORM no expone SELECT FOR UPDATE nativamente — se necesitan raw queries dentro de la transacción administrada.
La regla de oro: stock solo mediante movimientos
El campo ProductVariant.stock nunca se modifica directamente desde la aplicación. Cada cambio de stock — venta, reposición, ajuste manual, devolución — pasa por un StockMovement. Esto garantiza auditoría completa: siempre se puede reconstruir el stock histórico sumando los movimientos.
Modelo StockMovement
model StockMovement {
id String @id @default(uuid())
variantId String
quantity Int // positivo = entrada, negativo = salida
source StockSource // ORDER | MANUAL | RETURN | ML_SALE
referenceType ReferenceType // ORDER | PAYMENT | MANUAL_ADJUSTMENT
referenceId String
externalId String @unique // idempotencia (webhooks ML)
userId String? // quién ejecutó el movimiento
createdAt DateTime @default(now())
}El código real: 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. Adquirir lock exclusivo sobre la fila
const rows = await tx.$queryRaw<VariantStockRow[]>`
SELECT stock FROM product_variants
WHERE id = ${variantId} FOR UPDATE
`;
if (!rows.length) throw new NotFoundException('Variante no encontrada');
const current = rows[0].stock;
// 2. Validar en dominio
if (current < amount) {
throw new BadRequestException('Stock insuficiente');
}
// 3. Decrementar y registrar movimiento atómicamente
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' },
);
}Por qué Prisma ORM no es suficiente
Prisma expone $transaction() para operaciones atómicas, pero no tiene una API de alto nivel para SELECT FOR UPDATE. La solución es usar $queryRaw dentro de la transacción: el cliente Prisma administra la conexión y el isolation level, mientras la query SQL garantiza el bloqueo a nivel de fila. Esta decisión se documentó explícitamente para que futuros desarrolladores entiendan por qué existen raw queries en un proyecto que usa ORM.
Idempotencia con externalId
MercadoLibre (y cualquier webhook externo) puede enviar la misma notificación múltiples veces. El campo externalId @unique en StockMovement garantiza que un doble webhook no decremente el stock dos veces:
// Si el movimiento ya existe, Prisma lanza Unique Constraint Violation
// El use case lo captura y retorna 200 OK (idempotente)
try {
await this.stockRepo.decreaseWithLock(variantId, qty, movement);
} catch (e) {
if (isUniqueConstraintViolation(e)) return; // ya procesado
throw e;
}Dominio puro: Stock entity y StockDomainService
La lógica de validación vive en el dominio, no en la infraestructura. La entidad Stock y su servicio de dominio encapsulan las reglas de negocio:
stocknunca negativo — enforced en dominio Y en constraint de BDcanDecrease(amount): lanzaDomainExceptionsistock < amountStockDomainService.validateDecrease()coordina entidad + reglas de negocio
■ Trade-offs
Serializable isolation vs Read Committed. Serializable previene todos los fenómenos de concurrencia (dirty reads, non-repeatable reads, phantom reads) pero tiene mayor overhead por gestión de conflictos. Para operaciones de stock la corrección prima sobre el throughput: un ecommerce prefiere rechazar una venta antes que vender stock inexistente.
Trazabilidad con source + referenceType
Cada StockMovement registra quién, por qué y desde dónde. Al integrar con MercadoLibre, las ventas desde ML se crean con source: ML_SALE y referenceType: ORDER, distinguiéndolas de ventas directas en el canal web. Esto permite auditorías, reconciliaciones contables y análisis de canal por canal.