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 $transaction con isolationLevel: '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

prisma
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

typescript
// 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:

typescript
// 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:

  • stock nunca negativo — enforced en dominio Y en constraint de BD
  • canDecrease(amount): lanza DomainException si stock < amount
  • StockDomainService.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.