Escena Viva ja persisteix a MongoDB i els controladors no saben que existeix. Ara toca la part de la feina que separa una aplicació que funciona d'una aplicació que aguanta: com es relacionen les dades entre si i com es consulten quan les preguntes deixen de ser trivials.

Prendrem la decisió de modelatge més important del món documental —incrustar o referenciar—, veurem com funciona populate de veritat i per què no és un JOIN, mesurarem el problema N+1, defensarem una desnormalització deliberada, construirem els informes d'Escena Viva amb el framework d'agregació —jubilant els que al mòdul 3 fèiem llegint CSV— i aprendrem a mirar els índexs amb explain() en lloc de suposar.

Contingut

  1. Incrustar davant de referenciar
  2. Les decisions d'Escena Viva, justificades una a una
  3. Referències amb ref i populate
  4. El problema N+1: mesurar-lo i resoldre'l
  5. Desnormalització deliberada
  6. El framework d'agregació com a canonada
  7. Informes reals d'Escena Viva
  8. $lookup davant de populate
  9. Índexs de debò i explain()
  10. Índexs parcials, cost d'escriptura, text i geoespacial

Incrustar davant de referenciar

En una base de dades relacional aquesta decisió no existeix: tot se separa en taules i s'uneix amb JOIN. A MongoDB tens les dues opcions, i triar malament és la causa número u de projectes que "es tornen lents sense saber per què".

Incrustar és ficar les dades filles dins del document pare, com les nostres sessions dins de l'Esdeveniment. Referenciar és desar-les en una altra col·lecció i emmagatzemar només el seu identificador, com Comanda.usuariId.

Criteri Incrusta si... Referencia si...
Cardinalitat Pocs fills (desenes), nombre acotat Molts fills, sense sostre conegut
Mida El document es manté petit El conjunt es pot acostar als 16 MB
Accés conjunt Gairebé sempre es llegeixen pare i fill junts El fill es consulta pel seu compte
Volatilitat Els fills canvien poc, o amb el pare Els fills canvien molt més que el pare
Compartició El fill pertany a un únic pare Diversos pares referencien el mateix fill
Consulta independent Mai no busques fills sense saber el pare Llistes, pagines o filtres fills globalment

El límit de 16 MB per document no és una recomanació: és un límit dur de BSON. Un document que creix indefinidament hi xocarà, i la fallada arribarà en producció, amb dades reals, un dia qualsevol. Però molt abans ja fa mal: MongoDB reescriu el document sencer a cada actualització, així que un document de 2 MB al qual afegeixes un element petit mou 2 MB de disc i de xarxa. Existeix una tercera via, el patró de subconjunt: incrustar els pocs fills que es mostren sempre (les 3 darreres ressenyes) i referenciar la resta.

Les decisions d'Escena Viva, justificades una a una

Les sessions s'incrusten a l'esdeveniment. Cardinalitat acotada: entre 1 i 7 al nostre catàleg, potser 60 en una obra en cartell. Mida ridícula: cinc camps, uns 150 bytes. Accés sempre conjunt: GET /esdeveniments/evt-001 retorna l'esdeveniment amb les seves sessions i la fitxa web les mostra totes. No es comparteixen: una sessió no té sentit fora del seu esdeveniment. L'única objecció seriosa és la volatilitat —venudes canvia a cada compra i això reescriu el document—, però com que és diminut el cost és menyspreable. Incrustar guanya amb claredat.

Les comandes es referencien. Creixement sense sostre: un festival en pot generar 20.000. Dins de l'esdeveniment, el document creixeria fins a rebentar i cada compra reescriuria megabytes. A més es consulten pel seu compte ("les meves comandes", "comandes d'avui") sense partir de l'esdeveniment, i tenen cicle de vida propi amb quatre estats.

Les entrades es referencien. És l'entitat de més volum i s'hi accedeix pel seu codi a la porta, individualment, sense saber la comanda. Tenen el seu propi cicle (valida → usada | anullada).

L'usuari es referencia des de la comanda. Un usuari té N comandes i no volem duplicar el seu nom i correu a cadascuna: si canvia el correu, caldria reescriure-les totes.

graph TD
    subgraph esdeveniments["Colleccio: esdeveniments"]
        E["Esdeveniment evt-001<br/>Concierto de Otono<br/>Teatro Almendra"]
        S1["sessio ses-001-1<br/>aforament 400 / venudes 312"]
        S2["sessio ses-001-2<br/>aforament 400 / venudes 289"]
        E -->|incrustada| S1
        E -->|incrustada| S2
    end
    subgraph usuaris["Colleccio: usuaris"]
        U["Usuari<br/>rol assistent"]
    end
    subgraph comandes["Colleccio: comandes"]
        P["Comanda<br/>sessioId ses-001-1<br/>quantitat 2"]
    end
    subgraph entrades["Colleccio: entrades"]
        T1["Entrada EV-2026-000431"]
        T2["Entrada EV-2026-000432"]
    end
    U -.->|referencia usuariId| P
    P -.->|referencia comandaId| T1
    P -.->|referencia comandaId| T2
    T1 -.->|referencia sessioId| S1

Les fletxes contínues són dades que viuen al mateix document; les discontínues, referències que exigeixen una consulta addicional. Aquesta distinció visual és exactament la diferència de cost.

Referències amb ref i populate

Ja vam declarar les referències: usuariId: { type: Schema.Types.ObjectId, ref: 'Usuari' }. El ref diu quin model hi ha a l'altre costat; populate substitueix l'identificador pel document.

// Sense populate: usuariId es un ObjectId. Amb populate, el document sencer.
const ambUsuari = await Comanda.findById(id).populate('usuariId').lean();

// Poblat selectiu: nomes els camps necessaris.
const lleuger = await Comanda.findById(id)
  .populate({ path: 'usuariId', select: 'nom correu rol -_id' })
  .lean();

// Poblat imbricat: entrada -> comanda -> usuari.
const entrada = await Entrada.findOne({ codi: 'EV-2026-000431' })
  .populate({
    path: 'comandaId',
    select: 'sessioId quantitat estat usuariId',
    populate: { path: 'usuariId', select: 'nom correu' },
  })
  .lean();

I ara el més important, el que la documentació esmenta de passada i arruïna aplicacions: populate no és un JOIN. MongoDB no uneix res. Mongoose executa la teva consulta, recull els identificadors del camp poblat, llança una segona consulta (Usuari.find({ _id: { $in: [...] } })) i cus els resultats en memòria, al teu procés Node. Conseqüències:

  • Un populate sobre 50 comandes són 2 consultes, no 50: Mongoose agrupa els identificadors. Un poblat imbricat de dos nivells, 3 consultes.
  • El cost de cosir el paga el teu bucle d'esdeveniments, no el servidor de base de dades.
  • No pots filtrar el pare per un camp del fill. "Comandes l'usuari de les quals sigui organitzador" no s'expressa amb populate; l'opció match no descarta pares, deixa el camp a null i la comanda continua apareixent. Per a això, $lookup amb $match.

El problema N+1: mesurar-lo i resoldre'l

Apareix quan, per resoldre una llista de N elements, llances una consulta per element. És facilíssim d'escriure sense adonar-se'n:

// MALAMENT: N+1. Una consulta per a les comandes i una MES per cada comanda.
const comandes = await Comanda.find({ estat: 'pagat' }).limit(100).lean(); // 1
for (const comanda of comandes) {
  comanda.usuari = await Usuari.findById(comanda.usuariId).lean();         // 100
}

Amb una latència modesta de 2 ms per consulta, són 202 ms d'espera pura i seqüencial per a una resposta que hauria de trigar 5 ms. I escala amb el trànsit: 100 peticions simultànies són 10.100 consultes. Mesurar-ho és trivial i ho hauries de fer abans d'optimitzar res: mongoose.set('debug', true) imprimeix cada consulta que s'envia al motor.

// Solucio 1: populate. 2 consultes en total. Suficient el 90 % de les vegades.
await Comanda.find({ estat: 'pagat' }).limit(100)
  .populate({ path: 'usuariId', select: 'nom correu' }).lean();

// Solucio 2: dues consultes manuals i un Map. Control total, sense magia.
const comandes = await Comanda.find({ estat: 'pagat' }).limit(100).lean();
const ids = [...new Set(comandes.map((comanda) => String(comanda.usuariId)))];
const usuaris = await Usuari.find({ _id: { $in: ids } }).select('nom correu').lean();
const perId = new Map(usuaris.map((usuari) => [String(usuari._id), usuari]));
const units = comandes.map((c) => ({ ...c, usuari: perId.get(String(c.usuariId)) }));

// Solucio 3: $lookup en una agregacio. UNA sola consulta; uneix el motor.
await Comanda.aggregate([
  { $match: { estat: 'pagat' } },
  { $limit: 100 },
  { $lookup: { from: 'usuaris', localField: 'usuariId', foreignField: '_id', as: 'usuari' } },
  { $unwind: '$usuari' },
  { $project: { sessioId: 1, quantitat: 1, 'usuari.nom': 1 } },
]);

Desnormalització deliberada

sessio.venudes és informació redundant: es podria calcular comptant entrades vàlides. La desem igualment, i no per mandra.

A favor: comprovar l'aforament abans de vendre és l'operació més freqüent i més sensible a la latència de la plataforma, i llegir un enter d'un document ja carregat costa zero, mentre que comptar entrades exigeix una agregació sobre milions de documents. El catàleg mostra "queden 88 entrades" a cada targeta: amb el comptador, el llistat surt d'una sola lectura. I permet l'actualització atòmica condicional que resoldrà la sobrevenda, perquè només es pot comparar contra un camp que existeixi al document.

El preu, dit amb claredat: hi ha dues fonts de veritat per al mateix fet i poden divergir. Si es creen entrades sense incrementar el comptador, o a l'inrevés, o un procés mor entre totes dues operacions, el catàleg menteix. I cap mecanisme del motor no ho impedeix: MongoDB no coneix aquesta relació. Aquest preu es paga en tres nivells:

  1. Atomicitat del conjunt: que reservar aforament, crear la comanda i emetre entrades passi tot o res. És la lliçó 07-06.
  2. Conciliació periòdica: una tasca programada que recompta i corregeix deixant traça. La xarxa de seguretat.
  3. Una única porta d'escriptura: ningú no toca venudes ni crea entrades fora del repositori. Si hi ha deu llocs que toquen el comptador, la incoherència és qüestió de temps.
// Conciliacio: recompte real d'entrades per sessio, per contrastar.
const reals = await Entrada.aggregate([
  { $match: { estat: { $in: ['valida', 'usada'] } } },
  { $group: { _id: '$sessioId', reals: { $sum: 1 } } },
]);

El framework d'agregació com a canonada

Si el mòdul 3 et va deixar còmode amb pipeline() i els streams, l'agregació et resultarà familiar: és una canonada d'etapes on cadascuna rep documents, els transforma i passa el resultat a la següent. La diferència és que s'executa dins del servidor de base de dades, a prop de les dades, i no al teu procés.

Etapa Què fa Anàleg en arrays
$match Filtra documents filter
$project / $addFields Tria o calcula camps map
$group Agrupa per clau i acumula reduce
$sort / $limit / $skip Ordena i retalla sort, slice
$unwind Desdobla un array en un document per element flatMap
$lookup Uneix amb una altra col·lecció JOIN
$facet Diverses canonades en paral·lel sobre la mateixa entrada Diversos reduce alhora

Dues regles d'or sobre l'ordre, perquè el rendiment en depèn. $match com més aviat millor: és l'única etapa que pot fer servir índexs, i només si va al principi; filtrar al final significa processar tota la col·lecció. I $project per descartar camps pesants aviat: menys bytes per la canonada i menys memòria, perquè cada etapa té un límit de 100 MB (superable amb allowDiskUse: true, però si el necessites, replanteja la consulta).

Informes reals d'Escena Viva

Al mòdul 3 calculàvem l'ocupació llegint dades/vendes.csv amb streams. Aquell va ser un exercici excel·lent de canonades, però ara les dades són a la base i el motor les agrega millor que nosaltres.

// src/informes/agregats.js — recaptacio per sala.
async function recaptacioPerSala() {
  return Esdeveniment.aggregate([
    // 1. Nomes el que esta a la venda o tancat; descarta esborranys.
    { $match: { estat: { $in: ['publicat', 'finalitzat'] } } },
    // 2. Una fila per sessio: l'array passa a documents independents.
    { $unwind: '$sessions' },
    // 3. Agrupem per sala i acumulem. Els diners, enter per enter.
    { $group: {
      _id: '$sala',
      recaptacioCentims: { $sum: { $multiply: ['$sessions.venudes', '$sessions.preuCentims'] } },
      entradesVenudes: { $sum: '$sessions.venudes' },
      aforamentTotal: { $sum: '$sessions.aforament' },
      sessions: { $sum: 1 },
    } },
    // 4. Donem forma a la sortida i calculem l'ocupacio derivada.
    { $project: {
      _id: 0, sala: '$_id', recaptacioCentims: 1, entradesVenudes: 1, sessions: 1,
      ocupacio: { $round: [{ $divide: ['$entradesVenudes', '$aforamentTotal'] }, 4] },
    } },
    { $sort: { recaptacioCentims: -1 } },
  ]);
}

Amb el nostre catàleg llavor (7 sessions, aforament total 3000, 1811 venudes), aquesta canonada retorna les tres sales ordenades per recaptació i una ocupació global del 60,4 %.

// Ocupacio mitjana per categoria. $addToSet acumula valors unics; amb $size
// equival al COUNT(DISTINCT ...) de SQL.
async function ocupacioPerCategoria() {
  return Esdeveniment.aggregate([
    { $match: { estat: { $in: ['publicat', 'finalitzat'] } } },
    { $unwind: '$sessions' },
    { $group: {
      _id: '$categoria',
      venudes: { $sum: '$sessions.venudes' },
      aforament: { $sum: '$sessions.aforament' },
      esdeveniments: { $addToSet: '$esdevenimentId' },
    } },
    { $project: {
      _id: 0, categoria: '$_id', nombreEsdeveniments: { $size: '$esdeveniments' },
      ocupacio: { $round: [{ $divide: ['$venudes', '$aforament'] }, 4] },
    } },
    { $sort: { ocupacio: -1 } },
  ]);
}

// Ranquing de sessions mes venudes.
async function sessionsMesVenudes(limit = 10) {
  return Esdeveniment.aggregate([
    { $match: { estat: 'publicat' } },
    { $unwind: '$sessions' },
    { $project: {
      _id: 0, sessioId: '$sessions.sessioId', esdeveniment: '$titol', sala: '$sala',
      dataHora: '$sessions.dataHora', venudes: '$sessions.venudes',
      lliures: { $subtract: ['$sessions.aforament', '$sessions.venudes'] },
    } },
    { $sort: { venudes: -1 } },
    { $limit: limit },
  ]);
}

// Vendes per mes, sobre la colleccio de comandes. Agrupem per la cadena
// 'yyyy-MM', que a mes ordena alfabeticament be.
async function vendesPerMes(any) {
  return Comanda.aggregate([
    { $match: {
      estat: { $in: ['pagat', 'emes'] },
      createdAt: { $gte: new Date(`${any}-01-01`), $lt: new Date(`${any + 1}-01-01`) },
    } },
    { $group: {
      _id: { $dateToString: { format: '%Y-%m', date: '$createdAt' } },
      comandes: { $sum: 1 },
      entrades: { $sum: '$quantitat' },
      importCentims: { $sum: '$totalCentims' },
    } },
    { $sort: { _id: 1 } },
  ]);
}

I un panell complet en una sola consulta amb $facet: cada branca rep els mateixos documents d'entrada i produeix el seu propi array, substituint tres viatges a la base per un. És l'etapa perfecta per a panells de control.

async function panellDeControl() {
  const [panell] = await Esdeveniment.aggregate([
    { $match: { estat: 'publicat' } },
    { $unwind: '$sessions' },
    { $facet: {
      perSala: [{ $group: { _id: '$sala', venudes: { $sum: '$sessions.venudes' } } }],
      perCategoria: [{ $group: { _id: '$categoria', venudes: { $sum: '$sessions.venudes' } } }],
      totals: [{ $group: {
        _id: null, sessions: { $sum: 1 },
        aforament: { $sum: '$sessions.aforament' }, venudes: { $sum: '$sessions.venudes' },
      } }],
    } },
  ]);
  return panell;
}

$lookup davant de populate

populate (Mongoose) $lookup (agregació)
On s'uneix Al teu procés Node Al servidor de MongoDB
Nombre de consultes 1 + 1 per nivell poblat 1
Filtrar el pare per camps del fill No Sí, amb $match posterior
Agregar sobre el resultat unit No Sí, és una etapa més
Fa servir l'esquema i els seus tipus Sí No: treballes amb la col·lecció crua

Criteri pràctic: populate per servir documents a l'API; $lookup per a informes i quan necessitis filtrar o agregar sobre la relació. Un detall que despista: a $lookup, from és el nom real de la col·lecció (usuaris, en plural i minúscules, tal com el pluralitza Mongoose des del model Usuari), no el nom del model.

Índexs de debò i explain()

Un índex és un arbre B ordenat pels valors d'un o diversos camps, amb punters als documents. Sense ell, "dóna'm els esdeveniments del Teatro Almendra" obliga a llegir tots els documents: un collection scan o COLLSCAN. Amb ell, el motor baixa per l'arbre: un IXSCAN.

En un índex compost l'ordre dels camps mana, com en una llista ordenada per cognom i després per nom: serveix per cercar per cognom, i per cognom+nom, però no només per nom. És el principi del prefix, aplicat a esquemaEsdeveniment.index({ estat: 1, sala: 1, titol: 1 }):

Consulta Fa servir l'índex?
{ estat: 'publicat' } Sí (prefix d'1 camp)
{ estat, sala } Sí (prefix de 2 camps)
{ estat, sala } ordenat per titol Sí, sencer: filtre + ordenació
{ sala: 'Sala Boveda' } o { titol: 'Jazz' } No: no són prefix

La regla nemotècnica és ESR: primer els camps comparats per Equaltat, després el de Sort i finalment els de Rang. Un camp de rang abans del d'ordenació obliga el motor a ordenar en memòria, que és justament el que vols evitar.

const pla = await Esdeveniment.find({ sala: 'Teatro Almendra', estat: 'publicat' })
  .explain('executionStats');

pla.executionStats.executionTimeMillis;          // temps real
pla.executionStats.totalDocsExamined;            // documents llegits
pla.executionStats.nReturned;                    // documents retornats
pla.queryPlanner.winningPlan.inputStage.stage;   // 'IXSCAN' o 'COLLSCAN'

Es llegeix amb dos indicadors. stage: 'COLLSCAN' significa que no hi ha índex utilitzable: en una col·lecció petita tant se val, en una de gran és una alarma. I la proporció totalDocsExamined / nReturned: l'ideal és 1, examinar exactament el que retornes; si n'examines 50.000 per retornar-ne 20, l'índex no està fent la seva feina encara que existeixi. Un cas especial excel·lent: si l'índex conté tots els camps que la consulta necessita (filtre i projecció), MongoDB respon sense tocar els documents —consulta coberta— i totalDocsExamined val 0.

Índexs parcials, cost d'escriptura, text i geoespacial

Un índex únic normal rebutja duplicats incloent-hi els null: si deu entrades tenen codiExtern: null, la segona ja viola la unicitat. La solució és l'índex parcial, que només indexa els documents que compleixen un filtre.

// Unic nomes entre les entrades que realment tenen codi extern.
esquemaEntrada.index(
  { codiExtern: 1 },
  { unique: true, partialFilterExpression: { codiExtern: { $type: 'string' } } },
);

// Un usuari no pot tenir dues comandes PENDENTS per a la mateixa sessio,
// pero si diverses pagades.
esquemaComanda.index(
  { usuariId: 1, sessioId: 1 },
  { unique: true, partialFilterExpression: { estat: 'pendent' } },
);

I el recordatori incòmode: cada índex es paga a cada escriptura. Inserir un document amb sis índexs són set estructures per actualitzar. Ocupa memòria —idealment els índexs caben a la RAM— i disc. Un índex que cap consulta no fa servir és pur cost, i MongoDB et deixa auditar-ho amb Esdeveniment.collection.aggregate([{ $indexStats: {} }]): si accesses.ops continua a 0 després de setmanes en producció, sobra.

// Index de text: cerca per paraules, amb pesos per camp.
esquemaEsdeveniment.index({ titol: 'text', categoria: 'text' }, { weights: { titol: 10 } });
await Esdeveniment.find({ $text: { $search: 'jazz primavera' } }).lean();

// Index geoespacial: sales a prop d'una coordenada.
esquemaSala.index({ ubicacio: '2dsphere' });

Només hi pot haver un índex de text per col·lecció, i per a cercadors seriosos (sinònims, correcció, rellevància afinada) l'habitual és delegar en un motor especialitzat com Elasticsearch o Meilisearch, no forçar MongoDB.

Errors Comuns i Consells

  • Incrustar quelcom que creix sense límit. El dia que un esdeveniment acumuli 30.000 comandes incrustades, superarà els 16 MB i no es podrà escriure. No hi ha pedaç, hi ha migració.
  • Creure que populate és un JOIN. Són consultes addicionals, i dins d'un bucle es converteixen en N+1.
  • Filtrar per un camp poblat. populate amb match no descarta pares: deixa el camp a null. Fes servir $lookup + $match.
  • Posar $match al final de l'agregació. Perds l'índex i processes tota la col·lecció.
  • Oblidar $unwind abans d'agrupar per un camp d'un array. Sumaries arrays sencers en lloc dels seus elements.
  • Crear índexs "per si de cas". Cadascun alenteix les escriptures. Crea'ls des de consultes mesurades i audita'ls amb $indexStats.
  • Consell: explain('executionStats') en qualsevol consulta d'una ruta calenta, abans de donar-la per bona.
  • Consell: una agregació amb tres $lookup imbricats sol ser el senyal que aquell subsistema encaixaria millor en un model relacional. Bon moment per llegir la lliçó següent sense prejudicis.

Exercicis

Exercici 1: informe d'organitzador

Escriu una agregació resumPerOrganitzador() que retorni, per a cada organitzadorId, el nombre d'esdeveniments publicats, el total de sessions, les entrades venudes, la recaptació en cèntims i l'ocupació mitjana, ordenat per recaptació descendent.

Exercici 2: caçar un N+1

Aquest codi llista les entrades d'una sessió amb el nom del comprador. Indica quantes consultes llança per a 200 entrades i reescriu-lo amb una de sola.

const entrades = await Entrada.find({ sessioId, estat: 'valida' }).lean();
for (const entrada of entrades) {
  const comanda = await Comanda.findById(entrada.comandaId).lean();
  const usuari = await Usuari.findById(comanda.usuariId).lean();
  entrada.comprador = usuari.nom;
}

Exercici 3: dissenyar l'índex

Escena Viva afegeix la pantalla "properes sessions amb entrades d'una sala": filtra per estat: 'publicat' i sala, exigeix sessions amb dataHora >= ara i ordena per sessions.dataHora ascendent. Proposa l'índex, justifica l'ordre amb la regla ESR i digues què esperaries veure a explain().

Solucions

Exercici 1.

async function resumPerOrganitzador() {
  return Esdeveniment.aggregate([
    { $match: { estat: 'publicat' } },
    { $unwind: '$sessions' },
    { $group: {
      _id: '$organitzadorId',
      esdeveniments: { $addToSet: '$esdevenimentId' },
      sessions: { $sum: 1 },
      venudes: { $sum: '$sessions.venudes' },
      aforament: { $sum: '$sessions.aforament' },
      recaptacioCentims: {
        $sum: { $multiply: ['$sessions.venudes', '$sessions.preuCentims'] },
      },
    } },
    { $project: {
      _id: 0, organitzadorId: '$_id', sessions: 1, venudes: 1, recaptacioCentims: 1,
      nombreEsdeveniments: { $size: '$esdeveniments' },
      ocupacioMitjana: { $round: [{ $divide: ['$venudes', '$aforament'] }, 4] },
    } },
    { $sort: { recaptacioCentims: -1 } },
  ]);
}

Exercici 2. Llança 401 consultes: 1 per a les entrades, 200 per a les comandes i 200 per als usuaris. Amb $lookup es resol en una:

const entrades = await Entrada.aggregate([
  { $match: { sessioId, estat: 'valida' } },
  { $lookup: { from: 'comandes', localField: 'comandaId', foreignField: '_id', as: 'comanda' } },
  { $unwind: '$comanda' },
  { $lookup: { from: 'usuaris', localField: 'comanda.usuariId', foreignField: '_id', as: 'comprador' } },
  { $unwind: '$comprador' },
  { $project: { codi: 1, estat: 1, comprador: '$comprador.nom' } },
]);

Exercici 3. Índex: { estat: 1, sala: 1, 'sessions.dataHora': 1 }. Per ESR, estat i sala es comparen per igualtat i van primer; sessions.dataHora compleix alhora el paper d'ordenació i de rang, i va al final. En ser un camp d'un array de subdocuments, MongoDB el tracta com a índex multiclau. A explain('executionStats') esperaríem stage: 'IXSCAN', absència d'una etapa SORT en memòria —l'ordenació la serveix el mateix índex— i una relació totalDocsExamined / nReturned propera a 1.

Conclusió

Has après a modelar relacions a MongoDB amb criteri: quan incrustar i quan referenciar, amb la taula de criteris i les decisions d'Escena Viva justificades una per una. Saps què fa populate de veritat —consultes addicionals cosides al teu procés, no un JOIN—, com detectar i matar un N+1, i per què mantenim venudes desnormalitzat acceptant conscientment el deute de coherència que genera. Has construït amb el framework d'agregació els informes que al mòdul 3 sortien d'un CSV: recaptació per sala, ocupació per categoria, rànquing de sessions i vendes per mes, a més d'un panell complet amb $facet. I ja no suposes res sobre índexs: els dissenyes amb la regla ESR i els verifiques amb explain().

També has vist els límits. $lookup imbricat, integritat referencial que ningú no garanteix, una invariant venudes <= aforament que depèn de la teva disciplina en lloc del motor. Res d'això no desqualifica MongoDB —és la persistència oficial d'Escena Viva i funciona—, però deixa una pregunta legítima a l'aire: com seria això en un sistema relacional?

A la lliçó següent ho responem modelant el mateix domini a PostgreSQL amb Sequelize: taules, claus foranes, normalització, una restricció CHECK (venudes <= aforament) que el motor fa complir passi el que passi, include com un JOIN real d'una sola consulta, SQL parametritzat davant de la injecció, i una segona implementació de src/repositoris/esdeveniments.js que demostrarà que l'arquitectura de la lliçó 07-01 no era teoria.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats