Caso práctico de MongoDB: pedidos e inventario | Nicolás Garzón
Inicio Wiki MongoDB Caso práctico: pedidos e inventario Volver a MongoDBMongoDB
Caso práctico: pedidos e inventario Aplica modelado documental, índices, transacciones, consistencia, seguridad y operación a un sistema multi-tenant de pedidos e inventario.
Última actualización Actualizada 25 de jul de 2026
Diseñar pedidos e inventario obliga a combinar modelado documental, concurrencia, transacciones, índices, idempotencia y operación. El objetivo no es solo guardar una orden: debe evitar sobreventa, conservar historia, aislar negocios y sobrevivir a retries y fallos parciales.
Nota anteriorAntipatrones de MongoDB Nota siguiente Modelo mental completo de MongoDB Texto
Copiar request idempotente
→ validar catálogo y precios
→ reservar stock
→ crear orden + movimientos + outbox
→ commit
→ efectos externosEl diseño siguiente representa un SaaS multi-tenant con múltiples sucursales.
cada orden pertenece a un businessId y una branchId;
el stock se controla por producto y sucursal;
una orden conserva nombre y precio vendidos;
el stock nunca puede quedar negativo;
repetir la misma request no crea otra orden;
confirmar, cancelar o entregar exige transiciones válidas;
pagos, emails y notificaciones no pueden romper la transacción;
listados deben paginar por negocio, estado y fecha;
cada cambio de inventario debe poder auditarse.
Estas invariantes guían los documentos y no deben quedar dispersas entre controllers.
JavaScript
Copiar {
_id : ObjectId ( ) ,
businessId : ObjectId ( ) ,
sku : 'PAN-001' ,
name : 'Pan artesanal' ,
salePrice : Decimal128 ( '3500.00' ) ,
active : true ,
version : 7 ,
createdAt : ISODate ( ) ,
updatedAt : ISODate ( )
} Índice de identidad por tenant:
JavaScript
Copiar db. products. createIndex (
{ businessId : 1 , sku : 1 } ,
{ unique : true }
) ; El producto actual es source of truth para catálogo. Las órdenes no deben poblarlo para reconstruir el precio histórico.
Un documento por producto y sucursal:
JavaScript
Copiar {
_id : ObjectId ( ) ,
businessId : ObjectId ( ) ,
branchId : ObjectId ( ) ,
productId : ObjectId ( ) ,
available : 24 ,
reserved : 0 ,
version : 18 ,
updatedAt : ISODate ( )
} JavaScript
Copiar db. inventory. createIndex (
{ businessId : 1 , branchId : 1 , productId : 1 } ,
{ unique : true }
) ; La combinación evita dos registros de stock para el mismo producto, sucursal y negocio.
JavaScript
Copiar {
_id : ObjectId ( ) ,
businessId : ObjectId ( ) ,
branchId : ObjectId ( ) ,
orderNumber : 'BOG-2026-000184' ,
idempotencyKey : 'checkout-9f1...' ,
customer : {
id : ObjectId ( ) ,
nameAtPurchase : 'Ana Torres' ,
phoneAtPurchase : '+57...'
} ,
items : [
{
productId : ObjectId ( ) ,
skuAtPurchase : 'PAN-001' ,
nameAtPurchase : 'Pan artesanal' ,
quantity : 2 ,
unitPrice : Decimal128 ( '3500.00' ) ,
subtotal : Decimal128 ( '7000.00' )
}
] ,
totals : {
subtotal : Decimal128 ( '7000.00' ) ,
discount : Decimal128 ( '0.00' ) ,
deliveryFee : Decimal128 ( '2000.00' ) ,
total : Decimal128 ( '9000.00' )
} ,
status : 'pending' ,
paymentStatus : 'unpaid' ,
version : 1 ,
createdAt : ISODate ( ) ,
updatedAt : ISODate ( )
} Los items son snapshots. Si mañana cambia el nombre o precio del producto, la factura histórica permanece correcta.
Embeber items funciona porque pertenecen a la orden, se leen juntos y su cantidad debe estar limitada. Define un máximo de líneas por pedido.
todos los eventos de estado;
intentos de pago;
mensajes;
archivos;
auditoría completa.
Esos elementos pueden vivir en colecciones separadas o buckets.
El cliente envía una clave única por intento lógico:
JavaScript
Copiar db. orders. createIndex (
{ businessId : 1 , idempotencyKey : 1 } ,
{ unique : true }
) ; Si la conexión falla después del commit, repetir la request encuentra la misma orden en vez de crear otra.
La clave debe vincularse al payload. Si una misma key llega con items distintos, responde conflicto y no reutilices silenciosamente el resultado.
JavaScript
Copiar db. orders. createIndex (
{ businessId : 1 , orderNumber : 1 } ,
{ unique : true }
) ; No generes el siguiente número mediante countDocuments() + 1. Dos requests pueden producir el mismo valor. Usa un contador atómico por negocio/sucursal, un rango preasignado o una identidad distinta del _id técnico.
El backend recibe IDs y cantidades, no subtotales confiables:
TypeScript
Copiar const requestedItems = input. items;
const products = await productsRepository. findSellableProducts (
businessId,
requestedItems. map ( item => item. productId)
) ;
confirma que todos existen en el mismo tenant;
valida estado y sucursal;
toma el precio vigente;
calcula Decimal128 o una representación monetaria exacta;
genera snapshots.
Nunca aceptes total calculado por el frontend como source of truth.
JavaScript
Copiar db. inventory. updateOne (
{
businessId,
branchId,
productId,
available : { $gte : quantity }
} ,
{
$inc : {
available : - quantity,
reserved : quantity,
version : 1
} ,
$set : { updatedAt : new Date ( ) }
}
) ; Si matchedCount es cero, no sabes automáticamente si faltó el registro o el stock. La capa de dominio puede realizar una lectura diagnóstica o devolver “stock no disponible” sin revelar datos indebidos.
La condición y el decremento ocurren en la misma escritura, evitando la carrera read-modify-write.
Una orden normal afecta varios documentos de inventario. Opciones:
Reserva cada línea, crea la orden, registra movimientos y outbox en una única transacción corta.
Coste: retries, mayor presión y necesidad de replica set.
Reserva cada línea con una identidad de operación; si una falla, libera las anteriores de manera idempotente.
Útil para flujos distribuidos o muy grandes, pero es más compleja y temporalmente inconsistente.
Para un pedido pequeño dentro del mismo cluster, una transacción suele ser razonable.
TypeScript
Copiar await session. withTransaction ( async ( ) => {
for ( const item of pricedItems) {
const result = await inventory. updateOne (
{
businessId,
branchId,
productId: item. productId,
available: { $gte: item. quantity }
} ,
{
$inc: {
available: - item. quantity,
reserved: item. quantity,
version: 1
}
} ,
{ session }
) ;
if ( result. matchedCount === 0 ) {
throw new InsufficientStockError ( item. productId) ;
}
}
await orders. insertOne ( orderDocument, { session } ) ;
await inventoryMovements. insertMany ( movements, { session } ) ;
await outbox. insertOne ( orderCreatedEvent, { session } ) ;
} ) ; No uses Promise.all() dentro del callback. Mantén la transacción corta y sin HTTP, emails ni pagos externos.
Dos transacciones que reservan productos A y B en orden opuesto pueden aumentar conflictos.
Ordena las líneas por productId antes de actualizar. No elimina toda contention, pero hace el acceso más consistente.
JavaScript
Copiar {
_id : ObjectId ( ) ,
businessId,
branchId,
productId,
orderId,
type : 'reservation' ,
quantity : - 2 ,
balanceAfter : 22 ,
operationId : 'order:<id>:reserve:<productId>' ,
occurredAt : ISODate ( )
} JavaScript
Copiar db. inventoryMovements. createIndex (
{ businessId : 1 , operationId : 1 } ,
{ unique : true }
) ; Los movimientos explican por qué cambió el stock. No son por sí solos la fuente rápida para cada lectura; el documento inventory mantiene el estado actual y los movimientos permiten auditoría/reconciliación.
Dentro de la transacción se guarda:
JavaScript
Copiar {
_id : ObjectId ( ) ,
businessId,
aggregateType : 'order' ,
aggregateId : orderId,
eventType : 'OrderCreated' ,
payload : { orderId, customerId } ,
status : 'pending' ,
attempts : 0 ,
createdAt : ISODate ( )
} Un worker publica notificaciones, integra pagos o actualiza proyecciones después del commit.
Debe usar retries, backoff e idempotency key. Marcar enviado y ejecutar el efecto no forman una transacción distribuida; el consumidor también debe deduplicar.
No permitas cualquier cambio:
Texto
Copiar pending → confirmed → preparing → dispatched → delivered
pending/confirmed → cancelledJavaScript
Copiar db. orders. findOneAndUpdate (
{
_id : orderId,
businessId,
status : 'pending' ,
version : expectedVersion
} ,
{
$set : {
status : 'confirmed' ,
confirmedAt : new Date ( )
} ,
$inc : { version : 1 }
} ,
{ returnDocument : 'after' }
) ; Si no coincide, el estado cambió o la orden no pertenece al tenant. No sobrescribas ciegamente.
Cancelar una orden reservada debe:
cambiar estado con precondition;
reducir reserved;
aumentar available cuando la política lo indique;
registrar movimientos;
crear evento outbox.
Hazlo en una transacción o mediante saga idempotente. Repetir la cancelación no debe devolver stock dos veces.
Usa un operationId único por liberación.
No cobres dentro de la transacción. Flujo posible:
crea orden pending_payment y reserva stock;
commit;
worker o endpoint inicia pago con idempotency key;
webhook verificado actualiza payment status;
confirma o libera stock según resultado/timeout.
Define expiración de reservas y un job idempotente. TTL puede limpiar registros auxiliares, pero no debe ejecutar la liberación de negocio.
JavaScript
Copiar db. orders. createIndex ( {
businessId : 1 ,
branchId : 1 ,
status : 1 ,
createdAt : - 1 ,
_id : - 1
} ) ; JavaScript
Copiar db. orders. createIndex ( {
businessId : 1 ,
'customer.id' : 1 ,
createdAt : - 1 ,
_id : - 1
} ) ; JavaScript
Copiar { businessId : 1 , orderNumber : 1 } No añadas todos los campos al mismo índice. Valida cada shape con explain('executionStats').
JavaScript
Copiar { createdAt : - 1 , _id : - 1 } JavaScript
Copiar {
businessId,
branchId,
status,
$or : [
{ createdAt : { $lt : cursor. createdAt } } ,
{
createdAt : cursor. createdAt,
_id : { $lt : cursor. id }
}
]
} Codifica el cursor, valida tipos y limita el tamaño de página.
Todo documento contiene businessId. Toda query, update, aggregation, population y Search filter lo aplica desde la sesión.
branchId pertenece al negocio;
productos pertenecen al negocio;
customer pertenece o es permitido;
roles pueden ejecutar la transición;
delivery staff solo accede a órdenes asignadas.
Un ObjectId difícil de adivinar no es autorización.
Protege tipos y campos básicos con $jsonSchema:
ObjectId para IDs;
Decimal128 para dinero;
enum de estados;
items no vacíos;
quantity positiva;
timestamps Date.
Las reglas cross-document siguen en la aplicación, índices y transacciones.
Para mostrar disponibilidad, consulta inventory por tenant/sucursal/producto. No recalcules sumando todos los movimientos en cada request.
Ejecuta reconciliación periódica entre movimientos y estado materializado. Una diferencia genera alerta y reparación controlada.
Después de crear la orden, el mismo request ya tiene el documento confirmado. Para pantallas posteriores define read concern/preference según necesidad.
No leas inmediatamente desde un secondary eventual si el usuario exige ver su orden recién creada.
duplicate idempotency key;
stock insuficiente;
transient transaction error;
unknown commit result;
duplicate order number;
validation;
timeout/network;
autorización.
Ante commit ambiguo, busca por idempotency key antes de crear otra orden.
dos pedidos compiten por el último stock;
una línea falla y ninguna queda reservada;
retry de la misma idempotency key;
timeout después del commit;
cancelación repetida;
webhook de pago duplicado;
tenant intenta usar producto ajeno;
transaction callback se reejecuta;
movement/outbox quedan dentro del commit;
restore y reconciliación.
Usa MongoDB real con replica set.
tiempo de creación;
retries y conflicts;
stock insufficiency rate;
transaction duration;
unknown commit outcomes;
outbox lag y dead letters;
orders por estado;
inventory reconciliation differences;
pool wait;
slow queries;
replication lag.
Añade comment por query shape sin registrar PII.
El restore debe recuperar órdenes, inventario, movimientos, outbox, índices y validators en un punto coherente.
bloquea tráfico hasta validar;
ejecuta reconciliación;
identifica pagos externos posteriores al punto restaurado;
reanuda outbox con deduplicación;
invalida sesiones o credenciales cuando corresponda.
La base no conoce automáticamente el estado actual del proveedor de pagos.
No shardeas desde el primer día. Primero optimiza schema, índices y replica set.
Si el crecimiento multi-tenant lo requiere, businessId puede formar parte de una shard key candidata, pero debes analizar:
tenants outlier;
distribución;
queries por sucursal;
operaciones cross-tenant;
unique indexes;
hotspots de negocios grandes.
Una shard key solo por tenant puede concentrar un negocio enorme en un shard.
pedido con demasiadas líneas;
producto desactivado mientras se compra;
precio cambia durante checkout;
stock existe pero documento de inventario falta;
dos cancelaciones simultáneas;
reserva expira mientras llega confirmación de pago;
restore retrocede MongoDB pero no proveedor externo;
order number counter queda adelantado;
outbox publica dos veces;
branch se desactiva durante la transacción.
aceptar precios del cliente;
usar count + 1 para order number;
leer stock y guardar después;
enviar email dentro de transaction;
no usar idempotency key;
poblar producto actual para historial;
guardar movimientos ilimitados en la orden;
omitir tenant en inventory update;
paginar con skip profundo;
considerar backup sin reconciliación externa.
Lista invariantes y owners.
Define límites de items.
Crea índices unique y de lectura.
Ejecuta explain.
Simula carreras de stock.
Fuerza retry y commit ambiguo.
Duplica webhooks/outbox.
Prueba cancelación y expiración.
Aísla dos tenants.
Restaura backup y reconcilia pagos/inventario.
Una orden combina snapshots históricos con referencias técnicas. El stock se protege mediante updates condicionales y, cuando varias líneas deben cambiar juntas, una transacción corta. Idempotency keys, movimientos y outbox convierten retries y efectos externos en procesos recuperables. Tenant, índices, observabilidad y restore son parte del mismo diseño.
Comprueba lo aprendido
¿Por qué los items de la orden son snapshots?
¿Cómo evita sobreventa un update condicional?
¿Cuándo usarías una transacción?
¿Qué resuelve la idempotency key?
¿Por qué el pago ocurre después del commit?
¿Qué debe reconciliarse después de un restore?
Modelo mental completo de MongoDB.