Escena Viva ya persiste en MongoDB y los controladores no saben que existe. Ahora toca la parte del trabajo que separa una aplicación que funciona de una aplicación que aguanta: cómo se relacionan los datos entre sí y cómo se consultan cuando las preguntas dejan de ser triviales.
Tomaremos la decisión de modelado más importante del mundo documental —incrustar o referenciar—, veremos cómo funciona populate de verdad y por qué no es un JOIN, mediremos el problema N+1, defenderemos una desnormalización deliberada, construiremos los informes de Escena Viva con el framework de agregación —jubilando los que en el módulo 3 hacíamos leyendo CSV— y aprenderemos a mirar los índices con explain() en vez de suponer.
Contenido
- Incrustar frente a referenciar
- Las decisiones de Escena Viva, justificadas una a una
- Referencias con
refypopulate - El problema N+1: medirlo y resolverlo
- Desnormalización deliberada
- El framework de agregación como tubería
- Informes reales de Escena Viva
$lookupfrente apopulate- Índices en serio y
explain() - Índices parciales, coste de escritura, texto y geoespacial
Incrustar frente a referenciar
En una base de datos relacional esta decisión no existe: todo se separa en tablas y se une con JOIN. En MongoDB tienes las dos opciones, y elegir mal es la causa número uno de proyectos que "se vuelven lentos sin saber por qué".
Incrustar es meter los datos hijos dentro del documento padre, como nuestras sesiones dentro del Evento. Referenciar es guardarlos en otra colección y almacenar solo su identificador, como Pedido.usuarioId.
| Criterio | Incrustar si... | Referenciar si... |
|---|---|---|
| Cardinalidad | Pocos hijos (decenas), número acotado | Muchos hijos, sin techo conocido |
| Tamaño | El documento se mantiene pequeño | El conjunto puede acercarse a los 16 MB |
| Acceso conjunto | Casi siempre se leen padre e hijo juntos | El hijo se consulta por su cuenta |
| Volatilidad | Los hijos cambian poco, o con el padre | Los hijos cambian mucho más que el padre |
| Compartición | El hijo pertenece a un único padre | Varios padres referencian el mismo hijo |
| Consulta independiente | Nunca buscas hijos sin saber el padre | Listas, paginas o filtras hijos globalmente |
El límite de 16 MB por documento no es una recomendación: es un límite duro de BSON. Un documento que crece indefinidamente chocará con él, y el fallo llegará en producción, con datos reales, un día cualquiera. Pero mucho antes ya duele: MongoDB reescribe el documento entero en cada actualización, así que un documento de 2 MB al que añades un elemento pequeño mueve 2 MB de disco y de red. Existe una tercera vía, el patrón de subconjunto: incrustar los pocos hijos que se muestran siempre (las 3 últimas reseñas) y referenciar el resto.
Las decisiones de Escena Viva, justificadas una a una
Las sesiones se incrustan en el evento. Cardinalidad acotada: entre 1 y 7 en nuestro catálogo, quizá 60 en una obra en cartel. Tamaño ridículo: cinco campos, unos 150 bytes. Acceso siempre conjunto: GET /eventos/evt-001 devuelve el evento con sus sesiones y la ficha web las muestra todas. No se comparten: una sesión no tiene sentido fuera de su evento. La única objeción seria es la volatilidad —vendidas cambia en cada compra y eso reescribe el documento—, pero como es diminuto el coste es despreciable. Incrustar gana con claridad.
Los pedidos se referencian. Crecimiento sin techo: un festival puede generar 20.000. Dentro del evento, el documento crecería hasta reventar y cada compra reescribiría megabytes. Además se consultan por su cuenta ("mis pedidos", "pedidos de hoy") sin partir del evento, y tienen ciclo de vida propio con cuatro estados.
Las entradas se referencian. Es la entidad de mayor volumen y se acceden por su código en la puerta, individualmente, sin saber el pedido. Tienen su propio ciclo (valida → usada | anulada).
El usuario se referencia desde el pedido. Un usuario tiene N pedidos y no queremos duplicar su nombre y correo en cada uno: si cambia el correo, habría que reescribirlos todos.
graph TD
subgraph eventos["Coleccion: eventos"]
E["Evento evt-001<br/>Concierto de Otono<br/>Teatro Almendra"]
S1["sesion ses-001-1<br/>aforo 400 / vendidas 312"]
S2["sesion ses-001-2<br/>aforo 400 / vendidas 289"]
E -->|incrustada| S1
E -->|incrustada| S2
end
subgraph usuarios["Coleccion: usuarios"]
U["Usuario<br/>rol asistente"]
end
subgraph pedidos["Coleccion: pedidos"]
P["Pedido<br/>sesionId ses-001-1<br/>cantidad 2"]
end
subgraph entradas["Coleccion: entradas"]
T1["Entrada EV-2026-000431"]
T2["Entrada EV-2026-000432"]
end
U -.->|referencia usuarioId| P
P -.->|referencia pedidoId| T1
P -.->|referencia pedidoId| T2
T1 -.->|referencia sesionId| S1
Las flechas continuas son datos que viven en el mismo documento; las discontinuas, referencias que exigen una consulta adicional. Esa distinción visual es exactamente la diferencia de coste.
Referencias con ref y populate
Ya declaramos las referencias: usuarioId: { type: Schema.Types.ObjectId, ref: 'Usuario' }. El ref dice qué modelo hay al otro lado; populate sustituye el identificador por el documento.
// Sin populate: usuarioId es un ObjectId. Con populate, el documento entero.
const conUsuario = await Pedido.findById(id).populate('usuarioId').lean();
// Poblado selectivo: solo los campos necesarios.
const ligero = await Pedido.findById(id)
.populate({ path: 'usuarioId', select: 'nombre email rol -_id' })
.lean();
// Poblado anidado: entrada -> pedido -> usuario.
const entrada = await Entrada.findOne({ codigo: 'EV-2026-000431' })
.populate({
path: 'pedidoId',
select: 'sesionId cantidad estado usuarioId',
populate: { path: 'usuarioId', select: 'nombre email' },
})
.lean();Y ahora lo importante, lo que la documentación menciona de pasada y arruina aplicaciones: populate no es un JOIN. MongoDB no une nada. Mongoose ejecuta tu consulta, recoge los identificadores del campo poblado, lanza una segunda consulta (Usuario.find({ _id: { $in: [...] } })) y cose los resultados en memoria, en tu proceso Node. Consecuencias:
- Un
populatesobre 50 pedidos son 2 consultas, no 50: Mongoose agrupa los identificadores. Un poblado anidado de dos niveles, 3 consultas. - El coste de coser lo paga tu bucle de eventos, no el servidor de base de datos.
- No puedes filtrar el padre por un campo del hijo. "Pedidos cuyo usuario sea organizador" no se expresa con
populate; la opciónmatchno descarta padres, deja el campo anully el pedido sigue apareciendo. Para eso,$lookupcon$match.
El problema N+1: medirlo y resolverlo
Aparece cuando, para resolver una lista de N elementos, lanzas una consulta por elemento. Es facilísimo de escribir sin darse cuenta:
// MAL: N+1. Una consulta para los pedidos y una MAS por cada pedido.
const pedidos = await Pedido.find({ estado: 'pagado' }).limit(100).lean(); // 1
for (const pedido of pedidos) {
pedido.usuario = await Usuario.findById(pedido.usuarioId).lean(); // 100
}Con una latencia modesta de 2 ms por consulta, son 202 ms de espera pura y secuencial para una respuesta que debería tardar 5 ms. Y escala con el tráfico: 100 peticiones simultáneas son 10.100 consultas. Medirlo es trivial y deberías hacerlo antes de optimizar nada: mongoose.set('debug', true) imprime cada consulta que se envía al motor.
// Solucion 1: populate. 2 consultas en total. Suficiente el 90 % de las veces.
await Pedido.find({ estado: 'pagado' }).limit(100)
.populate({ path: 'usuarioId', select: 'nombre email' }).lean();
// Solucion 2: dos consultas manuales y un Map. Control total, sin magia.
const pedidos = await Pedido.find({ estado: 'pagado' }).limit(100).lean();
const ids = [...new Set(pedidos.map((pedido) => String(pedido.usuarioId)))];
const usuarios = await Usuario.find({ _id: { $in: ids } }).select('nombre email').lean();
const porId = new Map(usuarios.map((usuario) => [String(usuario._id), usuario]));
const unidos = pedidos.map((p) => ({ ...p, usuario: porId.get(String(p.usuarioId)) }));
// Solucion 3: $lookup en una agregacion. UNA sola consulta; une el motor.
await Pedido.aggregate([
{ $match: { estado: 'pagado' } },
{ $limit: 100 },
{ $lookup: { from: 'usuarios', localField: 'usuarioId', foreignField: '_id', as: 'usuario' } },
{ $unwind: '$usuario' },
{ $project: { sesionId: 1, cantidad: 1, 'usuario.nombre': 1 } },
]);Desnormalización deliberada
sesion.vendidas es información redundante: podría calcularse contando entradas válidas. La guardamos igualmente, y no por pereza.
A favor: comprobar el aforo antes de vender es la operación más frecuente y más sensible a la latencia de la plataforma, y leer un entero de un documento ya cargado cuesta cero, mientras que contar entradas exige una agregación sobre millones de documentos. El catálogo muestra "quedan 88 entradas" en cada tarjeta: con el contador, el listado sale de una sola lectura. Y permite la actualización atómica condicional que resolverá la sobreventa, porque solo se puede comparar contra un campo que exista en el documento.
El precio, dicho con claridad: hay dos fuentes de verdad para el mismo hecho y pueden divergir. Si se crean entradas sin incrementar el contador, o al revés, o un proceso muere entre ambas operaciones, el catálogo miente. Y ningún mecanismo del motor lo impide: MongoDB no conoce esa relación. Ese precio se paga en tres niveles:
- Atomicidad del conjunto: que reservar aforo, crear el pedido y emitir entradas ocurra todo o nada. Es la lección 07-06.
- Conciliación periódica: un trabajo programado que recuenta y corrige dejando traza. La red de seguridad.
- Una única puerta de escritura: nadie toca
vendidasni crea entradas fuera del repositorio. Si hay diez sitios que tocan el contador, la incoherencia es cuestión de tiempo.
// Conciliacion: recuento real de entradas por sesion, para contrastar.
const reales = await Entrada.aggregate([
{ $match: { estado: { $in: ['valida', 'usada'] } } },
{ $group: { _id: '$sesionId', reales: { $sum: 1 } } },
]);El framework de agregación como tubería
Si el módulo 3 te dejó cómodo con pipeline() y los streams, la agregación te resultará familiar: es una tubería de etapas donde cada una recibe documentos, los transforma y pasa el resultado a la siguiente. La diferencia es que se ejecuta dentro del servidor de base de datos, cerca de los datos, y no en tu proceso.
| Etapa | Qué hace | Análogo en arrays |
|---|---|---|
$match |
Filtra documentos | filter |
$project / $addFields |
Elige o calcula campos | map |
$group |
Agrupa por clave y acumula | reduce |
$sort / $limit / $skip |
Ordena y recorta | sort, slice |
$unwind |
Desdobla un array en un documento por elemento | flatMap |
$lookup |
Une con otra colección | JOIN |
$facet |
Varias tuberías en paralelo sobre la misma entrada | Varios reduce a la vez |
Dos reglas de oro sobre el orden, porque el rendimiento depende de él. $match lo más pronto posible: es la única etapa que puede usar índices, y solo si va al principio; filtrar al final significa procesar toda la colección. Y $project para descartar campos pesados pronto: menos bytes por la tubería y menos memoria, porque cada etapa tiene un límite de 100 MB (superable con allowDiskUse: true, pero si lo necesitas, replantea la consulta).
Informes reales de Escena Viva
En el módulo 3 calculábamos la ocupación leyendo datos/ventas.csv con streams. Aquel fue un ejercicio excelente de tuberías, pero ahora los datos están en la base y el motor los agrega mejor que nosotros.
// src/informes/agregados.js — recaudacion por sala.
async function recaudacionPorSala() {
return Evento.aggregate([
// 1. Solo lo que esta a la venta o cerrado; descarta borradores.
{ $match: { estado: { $in: ['publicado', 'finalizado'] } } },
// 2. Una fila por sesion: el array pasa a documentos independientes.
{ $unwind: '$sesiones' },
// 3. Agrupamos por sala y acumulamos. El dinero, entero por entero.
{ $group: {
_id: '$sala',
recaudacionCentimos: { $sum: { $multiply: ['$sesiones.vendidas', '$sesiones.precioCentimos'] } },
entradasVendidas: { $sum: '$sesiones.vendidas' },
aforoTotal: { $sum: '$sesiones.aforo' },
sesiones: { $sum: 1 },
} },
// 4. Damos forma a la salida y calculamos la ocupacion derivada.
{ $project: {
_id: 0, sala: '$_id', recaudacionCentimos: 1, entradasVendidas: 1, sesiones: 1,
ocupacion: { $round: [{ $divide: ['$entradasVendidas', '$aforoTotal'] }, 4] },
} },
{ $sort: { recaudacionCentimos: -1 } },
]);
}Con nuestro catálogo semilla (7 sesiones, aforo total 3000, 1811 vendidas), esta tubería devuelve las tres salas ordenadas por recaudación y una ocupación global del 60,4 %.
// Ocupacion media por categoria. $addToSet acumula valores unicos; con $size
// equivale al COUNT(DISTINCT ...) de SQL.
async function ocupacionPorCategoria() {
return Evento.aggregate([
{ $match: { estado: { $in: ['publicado', 'finalizado'] } } },
{ $unwind: '$sesiones' },
{ $group: {
_id: '$categoria',
vendidas: { $sum: '$sesiones.vendidas' },
aforo: { $sum: '$sesiones.aforo' },
eventos: { $addToSet: '$eventoId' },
} },
{ $project: {
_id: 0, categoria: '$_id', numeroEventos: { $size: '$eventos' },
ocupacion: { $round: [{ $divide: ['$vendidas', '$aforo'] }, 4] },
} },
{ $sort: { ocupacion: -1 } },
]);
}
// Ranking de sesiones mas vendidas.
async function sesionesMasVendidas(limite = 10) {
return Evento.aggregate([
{ $match: { estado: 'publicado' } },
{ $unwind: '$sesiones' },
{ $project: {
_id: 0, sesionId: '$sesiones.sesionId', evento: '$titulo', sala: '$sala',
fechaHora: '$sesiones.fechaHora', vendidas: '$sesiones.vendidas',
libres: { $subtract: ['$sesiones.aforo', '$sesiones.vendidas'] },
} },
{ $sort: { vendidas: -1 } },
{ $limit: limite },
]);
}
// Ventas por mes, sobre la coleccion de pedidos. Agrupamos por la cadena
// 'yyyy-MM', que ademas ordena alfabeticamente bien.
async function ventasPorMes(anio) {
return Pedido.aggregate([
{ $match: {
estado: { $in: ['pagado', 'emitido'] },
createdAt: { $gte: new Date(`${anio}-01-01`), $lt: new Date(`${anio + 1}-01-01`) },
} },
{ $group: {
_id: { $dateToString: { format: '%Y-%m', date: '$createdAt' } },
pedidos: { $sum: 1 },
entradas: { $sum: '$cantidad' },
importeCentimos: { $sum: '$totalCentimos' },
} },
{ $sort: { _id: 1 } },
]);
}Y un panel completo en una sola consulta con $facet: cada rama recibe los mismos documentos de entrada y produce su propio array, sustituyendo tres viajes a la base por uno. Es la etapa perfecta para paneles de control.
async function panelDeControl() {
const [panel] = await Evento.aggregate([
{ $match: { estado: 'publicado' } },
{ $unwind: '$sesiones' },
{ $facet: {
porSala: [{ $group: { _id: '$sala', vendidas: { $sum: '$sesiones.vendidas' } } }],
porCategoria: [{ $group: { _id: '$categoria', vendidas: { $sum: '$sesiones.vendidas' } } }],
totales: [{ $group: {
_id: null, sesiones: { $sum: 1 },
aforo: { $sum: '$sesiones.aforo' }, vendidas: { $sum: '$sesiones.vendidas' },
} }],
} },
]);
return panel;
}$lookup frente a populate
populate (Mongoose) |
$lookup (agregación) |
|
|---|---|---|
| Dónde se une | En tu proceso Node | En el servidor de MongoDB |
| Número de consultas | 1 + 1 por nivel poblado | 1 |
| Filtrar el padre por campos del hijo | No | Sí, con $match posterior |
| Agregar sobre el resultado unido | No | Sí, es una etapa más |
| Usa el esquema y sus tipos | Sí | No: trabajas con la colección cruda |
Criterio práctico: populate para servir documentos a la API; $lookup para informes y cuando necesites filtrar o agregar sobre la relación. Un detalle que despista: en $lookup, from es el nombre real de la colección (usuarios, en plural y minúsculas, tal como lo pluraliza Mongoose desde el modelo Usuario), no el nombre del modelo.
Índices en serio y explain()
Un índice es un árbol B ordenado por los valores de uno o varios campos, con punteros a los documentos. Sin él, "dame los eventos del Teatro Almendra" obliga a leer todos los documentos: un collection scan o COLLSCAN. Con él, el motor baja por el árbol: un IXSCAN.
En un índice compuesto el orden de los campos manda, como en un listín ordenado por apellido y luego por nombre: sirve para buscar por apellido, y por apellido+nombre, pero no solo por nombre. Es el principio del prefijo, aplicado a esquemaEvento.index({ estado: 1, sala: 1, titulo: 1 }):
| Consulta | ¿Usa el índice? |
|---|---|
{ estado: 'publicado' } |
Sí (prefijo de 1 campo) |
{ estado, sala } |
Sí (prefijo de 2 campos) |
{ estado, sala } ordenado por titulo |
Sí, entero: filtro + ordenación |
{ sala: 'Sala Boveda' } o { titulo: 'Jazz' } |
No: no son prefijo |
La regla nemotécnica es ESR: primero los campos comparados por Equaldad, luego el de Sort y por último los de Rango. Un campo de rango antes del de ordenación obliga al motor a ordenar en memoria, que es justo lo que quieres evitar.
const plan = await Evento.find({ sala: 'Teatro Almendra', estado: 'publicado' })
.explain('executionStats');
plan.executionStats.executionTimeMillis; // tiempo real
plan.executionStats.totalDocsExamined; // documentos leidos
plan.executionStats.nReturned; // documentos devueltos
plan.queryPlanner.winningPlan.inputStage.stage; // 'IXSCAN' o 'COLLSCAN'Se lee con dos indicadores. stage: 'COLLSCAN' significa que no hay índice utilizable: en una colección pequeña da igual, en una grande es una alarma. Y la proporción totalDocsExamined / nReturned: lo ideal es 1, examinar exactamente lo que devuelves; si examinas 50.000 para devolver 20, el índice no está haciendo su trabajo aunque exista. Un caso especial excelente: si el índice contiene todos los campos que la consulta necesita (filtro y proyección), MongoDB responde sin tocar los documentos —consulta cubierta— y totalDocsExamined vale 0.
Índices parciales, coste de escritura, texto y geoespacial
Un índice único normal rechaza duplicados incluyendo los null: si diez entradas tienen codigoExterno: null, el segundo ya viola la unicidad. La solución es el índice parcial, que solo indexa los documentos que cumplen un filtro.
// Unico solo entre las entradas que realmente tienen codigo externo.
esquemaEntrada.index(
{ codigoExterno: 1 },
{ unique: true, partialFilterExpression: { codigoExterno: { $type: 'string' } } },
);
// Un usuario no puede tener dos pedidos PENDIENTES para la misma sesion,
// pero si varios pagados.
esquemaPedido.index(
{ usuarioId: 1, sesionId: 1 },
{ unique: true, partialFilterExpression: { estado: 'pendiente' } },
);Y el recordatorio incómodo: cada índice se paga en cada escritura. Insertar un documento con seis índices son siete estructuras que actualizar. Ocupa memoria —idealmente los índices caben en RAM— y disco. Un índice que ninguna consulta usa es puro coste, y MongoDB te deja auditarlo con Evento.collection.aggregate([{ $indexStats: {} }]): si accesses.ops sigue a 0 tras semanas en producción, sobra.
// Indice de texto: busqueda por palabras, con pesos por campo.
esquemaEvento.index({ titulo: 'text', categoria: 'text' }, { weights: { titulo: 10 } });
await Evento.find({ $text: { $search: 'jazz primavera' } }).lean();
// Indice geoespacial: salas cerca de una coordenada.
esquemaSala.index({ ubicacion: '2dsphere' });Solo puede haber un índice de texto por colección, y para buscadores serios (sinónimos, corrección, relevancia afinada) lo habitual es delegar en un motor especializado como Elasticsearch o Meilisearch, no forzar MongoDB.
Errores Comunes y Consejos
- Incrustar algo que crece sin límite. El día que un evento acumule 30.000 pedidos incrustados, superará los 16 MB y no se podrá escribir. No hay parche, hay migración.
- Creer que
populatees unJOIN. Son consultas adicionales, y dentro de un bucle se convierten en N+1. - Filtrar por un campo poblado.
populateconmatchno descarta padres: deja el campo anull. Usa$lookup+$match. - Poner
$matchal final de la agregación. Pierdes el índice y procesas toda la colección. - Olvidar
$unwindantes de agrupar por un campo de un array. Sumarías arrays enteros en vez de sus elementos. - Crear índices "por si acaso". Cada uno ralentiza las escrituras. Créalos desde consultas medidas y audítalos con
$indexStats. - Consejo:
explain('executionStats')en cualquier consulta de una ruta caliente, antes de darla por buena. - Consejo: una agregación con tres
$lookupanidados suele ser la señal de que ese subsistema encajaría mejor en un modelo relacional. Buen momento para leer la lección siguiente sin prejuicios.
Ejercicios
Ejercicio 1: informe de organizador
Escribe una agregación resumenPorOrganizador() que devuelva, para cada organizadorId, el número de eventos publicados, el total de sesiones, las entradas vendidas, la recaudación en céntimos y la ocupación media, ordenado por recaudación descendente.
Ejercicio 2: cazar un N+1
Este código lista las entradas de una sesión con el nombre del comprador. Indica cuántas consultas lanza para 200 entradas y reescríbelo con una sola.
const entradas = await Entrada.find({ sesionId, estado: 'valida' }).lean();
for (const entrada of entradas) {
const pedido = await Pedido.findById(entrada.pedidoId).lean();
const usuario = await Usuario.findById(pedido.usuarioId).lean();
entrada.comprador = usuario.nombre;
}Ejercicio 3: diseñar el índice
Escena Viva añade la pantalla "próximas sesiones con entradas de una sala": filtra por estado: 'publicado' y sala, exige sesiones con fechaHora >= ahora y ordena por sesiones.fechaHora ascendente. Propón el índice, justifica el orden con la regla ESR y di qué esperarías ver en explain().
Soluciones
Ejercicio 1.
async function resumenPorOrganizador() {
return Evento.aggregate([
{ $match: { estado: 'publicado' } },
{ $unwind: '$sesiones' },
{ $group: {
_id: '$organizadorId',
eventos: { $addToSet: '$eventoId' },
sesiones: { $sum: 1 },
vendidas: { $sum: '$sesiones.vendidas' },
aforo: { $sum: '$sesiones.aforo' },
recaudacionCentimos: {
$sum: { $multiply: ['$sesiones.vendidas', '$sesiones.precioCentimos'] },
},
} },
{ $project: {
_id: 0, organizadorId: '$_id', sesiones: 1, vendidas: 1, recaudacionCentimos: 1,
numeroEventos: { $size: '$eventos' },
ocupacionMedia: { $round: [{ $divide: ['$vendidas', '$aforo'] }, 4] },
} },
{ $sort: { recaudacionCentimos: -1 } },
]);
}Ejercicio 2. Lanza 401 consultas: 1 para las entradas, 200 para los pedidos y 200 para los usuarios. Con $lookup se resuelve en una:
const entradas = await Entrada.aggregate([
{ $match: { sesionId, estado: 'valida' } },
{ $lookup: { from: 'pedidos', localField: 'pedidoId', foreignField: '_id', as: 'pedido' } },
{ $unwind: '$pedido' },
{ $lookup: { from: 'usuarios', localField: 'pedido.usuarioId', foreignField: '_id', as: 'comprador' } },
{ $unwind: '$comprador' },
{ $project: { codigo: 1, estado: 1, comprador: '$comprador.nombre' } },
]);Ejercicio 3. Índice: { estado: 1, sala: 1, 'sesiones.fechaHora': 1 }. Por ESR, estado y sala se comparan por igualdad y van primero; sesiones.fechaHora cumple a la vez el papel de ordenación y de rango, y va al final. Al ser un campo de un array de subdocumentos, MongoDB lo trata como índice multiclave. En explain('executionStats') esperaríamos stage: 'IXSCAN', ausencia de una etapa SORT en memoria —la ordenación la sirve el propio índice— y una relación totalDocsExamined / nReturned cercana a 1.
Conclusión
Has aprendido a modelar relaciones en MongoDB con criterio: cuándo incrustar y cuándo referenciar, con la tabla de criterios y las decisiones de Escena Viva justificadas una por una. Sabes qué hace populate de verdad —consultas adicionales cosidas en tu proceso, no un JOIN—, cómo detectar y matar un N+1, y por qué mantenemos vendidas desnormalizado aceptando conscientemente la deuda de coherencia que genera. Has construido con el framework de agregación los informes que en el módulo 3 salían de un CSV: recaudación por sala, ocupación por categoría, ranking de sesiones y ventas por mes, además de un panel completo con $facet. Y ya no supones nada sobre índices: los diseñas con la regla ESR y los verificas con explain().
También has visto los límites. $lookup anidado, integridad referencial que nadie garantiza, una invariante vendidas <= aforo que depende de tu disciplina en vez de del motor. Nada de esto descalifica a MongoDB —es la persistencia oficial de Escena Viva y funciona—, pero deja una pregunta legítima en el aire: ¿cómo sería esto en un sistema relacional?
En la lección siguiente lo respondemos modelando el mismo dominio en PostgreSQL con Sequelize: tablas, claves foráneas, normalización, una restricción CHECK (vendidas <= aforo) que el motor hace cumplir pase lo que pase, include como un JOIN real de una sola consulta, SQL parametrizado frente a la inyección, y una segunda implementación de src/repositorios/eventos.js que demostrará que la arquitectura de la lección 07-01 no era teoría.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
