Ja sabem què és NoSQL, per què existeix i com s'opera cadascuna de les seves quatre famílies. I tanmateix, amb tot això, encara no sabem fer el més important: dissenyar bé.

Aquesta és la lliçó on més projectes es torcen. La sintaxi de MongoDB s'aprèn en una tarda; el modelatge documental és on es decideix si el sistema anirà bé d'aquí a dos anys o si caldrà reescriure'l. I el parany és que un mal disseny funciona perfectament el primer dia: amb 200 documents tot és ràpid i no falla res. El dany apareix quan la col·lecció creix, quan un document s'acosta al seu límit de mida o quan algú descobreix que el nom d'una sucursal està duplicat en 40.000 documents i acaba de canviar.

Invertirem l'ordre mental que vam aprendre al mòdul 2, definirem amb precisió què és un agregat, resoldrem la decisió més important del modelatge documental —incrustar o referenciar— amb criteris explícits en lloc d'intuïció, aprendrem els cinc patrons de disseny que resolen gairebé tots els casos reals i els quatre antipatrons que els espatllen, i recuperarem part de les garanties perdudes amb la validació d'esquema.

El lliurable al final és concret: el disseny definitiu de les tres col·leccions de BiblioRedressenyes, cataleg i activitat— amb documents d'exemple i la justificació de cada decisió.

Contingut

  1. El canvi de mentalitat: del domini a les consultes
  2. L'agregat: unitat de lectura, escriptura i atomicitat
  3. La decisió central: incrustar o referenciar
  4. Un a pocs, un a molts, un a moltíssims
  5. Duplicació controlada i com mantenir-la coherent
  6. Patró: referència estesa
  7. Patró: subconjunt
  8. Patró: agrupació (bucket)
  9. Patró: valor atípic (outlier)
  10. Patró: camp calculat
  11. Antipatrons que cal reconèixer
  12. Validació d'esquema amb $jsonSchema
  13. Versionatge de documents i evolució de l'esquema
  14. Els índexs, breument
  15. Lliurable: el disseny final de les col·leccions de BiblioRed
  16. Errors habituals i consells
  17. Exercicis
  18. Conclusió

  1. El canvi de mentalitat: del domini a les consultes

Al mòdul 2 vam seguir, sense anomenar-lo, un mètode molt concret:

  1. Identificar les entitats del domini: sucursals, socis, llibres, exemplars, préstecs.
  2. Donar a cadascuna la seva taula, amb les seves claus i les seves relacions.
  3. Normalitzar per eliminar la redundància (ho formalitzarem al mòdul 5).
  4. I després escriure les consultes, amb la confiança que el model aguantarà qualsevol pregunta.

Aquest últim punt és la gran virtut del model relacional: un esquema ben normalitzat respon a preguntes que ningú no havia previst. El disseny és independent de l'ús.

A NoSQL l'ordre s'inverteix:

  1. Enumerar les consultes que l'aplicació necessita servir, amb la seva freqüència i la seva exigència de latència.
  2. Dissenyar els documents perquè cada consulta freqüent es resolgui amb un sol accés.
  3. Acceptar la duplicació que calgui per aconseguir-ho.
  4. I després comprovar que les entitats del domini continuen sent reconeixibles.
flowchart LR
    subgraph REL["Model relacional"]
        R1["Entitats<br/>del domini"] --> R2["Taules<br/>normalitzades"] --> R3["Consultes<br/>(qualsevol)"]
    end
    subgraph DOC["Model documental"]
        D1["Consultes<br/>de l'aplicació"] --> D2["Documents<br/>a mida"] --> D3["Entitats<br/>reconeixibles"]
    end

Això té una conseqüència incòmoda que convé acceptar com abans millor: el mateix domini admet dissenys documentals completament diferents segons com es consulti. No existeix "el model correcte d'una biblioteca" a MongoDB; existeix el model correcte per al portal de BiblioRed amb aquestes consultes. Si demà canvia radicalment l'ús, el model pot quedar obsolet encara que el domini no hagi canviat.

Per això el primer pas del modelatge documental no és dibuixar entitats, és escriure la llista de consultes. La de BiblioRed, prioritzada:

# Consulta Freqüència Exigència
C1 Fitxa completa d'un material amb les seves dades i les seves 5 millors ressenyes Molt alta < 50 ms
C2 Totes les ressenyes d'un material, paginades Alta < 100 ms
C3 Ressenyes escrites per un soci (el seu perfil) Mitjana < 200 ms
C4 Cercar materials per títol, autor o etiqueta Molt alta < 100 ms
C5 Publicar una ressenya / votar una ressenya com a útil Mitjana < 100 ms
C6 Registrar un esdeveniment d'activitat Molt alta (escriptura) < 10 ms
C7 Activitat d'un soci en un rang de dates Baixa < 1 s
C8 Termes més cercats del mes Baixa (informe) < 10 s

Tot el que ve a continuació és la resposta a aquesta taula.

  1. L'agregat: unitat de lectura, escriptura i atomicitat

Vam introduir el concepte a la lliçó 03-01. Ara el precisem, perquè és l'eina principal de treball.

Un agregat és un conjunt de dades que compleix les tres condicions alhora:

  1. Es llegeix junt: l'aplicació gairebé mai no necessita una part sense les altres.
  2. S'escriu junt: els canvis afecten el conjunt de manera coherent.
  3. Té una arrel: una entitat principal que dóna identitat al conjunt i per la qual s'hi accedeix.

I d'aquí surt la propietat que governa tot el disseny:

L'agregat és la frontera de l'atomicitat. A MongoDB, l'escriptura d'un document és atòmica: o s'aplica sencera o no s'aplica. Tot el que quedi dins del document s'actualitza d'una peça, sense transacció. Tot el que quedi fora necessita una transacció explícita o quedarà exposat a incoherències temporals.

Aquesta frase és el criteri de disseny més útil que t'endús de la lliçó. Quan dubtis entre posar una cosa dins o fora, pregunta't: necessito que això canviï de manera atòmica juntament amb la resta? Si la resposta és sí, dins.

Exemple a BiblioRed. Una ressenya amb el seu text, la seva puntuació, les seves etiquetes i el seu recompte de vots és un agregat: quan un lector edita la seva ressenya, canvia el text i les etiquetes alhora, i ningú no ha de veure el text nou amb les etiquetes velles. En canvi, la ressenya i la fitxa del material no formen un agregat: la fitxa s'edita des del panell del bibliotecari, la ressenya des del portal públic, amb ritmes i responsables diferents.

  1. La decisió central: incrustar o referenciar

Tota la pràctica del modelatge documental es concentra en aquesta pregunta: quan dues entitats estan relacionades, el fill va dins del document del pare o a la seva pròpia col·lecció amb una referència?

Incrustar

{
  "_id": "MAT-0331",
  "titol": "El mapa del temps",
  "ressenyes": [
    { "soci_id": 14, "nom": "Marta Alsina", "puntuacio": 5, "text": "Una novel·la que..." },
    { "soci_id": 15, "nom": "Ivan Pereda", "puntuacio": 3, "text": "Comença molt bé..." }
  ]
}

Referenciar

{ "_id": "MAT-0331", "titol": "El mapa del temps" }
{ "_id": "RES-1001", "material_id": "MAT-0331", "soci_id": 14, "puntuacio": 5, "text": "Una novel·la que..." }
flowchart TD
    subgraph EMB["INCRUSTAR"]
        E1["Document MAT-0331<br/>titol + ressenyes[ ]<br/>1 lectura, tot junt"]
    end
    subgraph REF["REFERENCIAR"]
        R1["cataleg<br/>MAT-0331"] -.->|material_id| R2["ressenyes<br/>RES-1001, RES-1002, ...<br/>2 lectures, creixen sense límit"]
    end

Els sis criteris de decisió

No ho decideixis per intuïció. Recorre aquests sis criteris en ordre:

# Criteri Afavoreix incrustar Afavoreix referenciar
1 Cardinalitat Pocs fills, amb topall conegut Molts o il·limitats
2 Es consulta el fill per separat? No: sempre amb el pare Sí: té vida pròpia
3 Volatilitat El fill canvia poc El fill canvia molt o creix constantment
4 Mida El conjunt queda molt per sota de 16 MB S'hi acosta o el supera
5 Creixement Acotat per naturalesa Il·limitat en el temps
6 Atomicitat Ha de canviar juntament amb el pare Pot canviar de manera independent

El criteri 5 mereix un avís especial, perquè és el que més sistemes ha trencat: tot el que creix amb el temps i no té topall acaba sent un problema si està incrustat. Les ressenyes d'un llibre popular, els esdeveniments d'activitat, els missatges d'un xat, l'historial de préstecs. Encara que avui siguin tres, d'aquí a cinc anys en seran milers. I hi ha un cost que no es veu venir: MongoDB, en actualitzar un document que ha crescut, pot haver de reescriure'l sencer al disc. Un document de 8 MB al qual s'afegeix un element de 200 bytes costa reescriure 8 MB.

Un criteri auxiliar molt pràctic

Si el fill no té sentit sense el pare i desapareix amb ell, gairebé sempre va incrustat. Una adreça sense el seu soci no significa res: dins. Una ressenya, en canvi, té identitat pròpia —s'enllaça, es vota, es denuncia, apareix al perfil del seu autor—: fora.

  1. Un a pocs, un a molts, un a moltíssims

La manera més ràpida d'aplicar els sis criteris és classificar la relació per la seva cardinalitat. És la regla pràctica més citada del modelatge documental i funciona sorprenentment bé.

Tipus Cardinalitat orientativa Recomanació Exemple a BiblioRed
Un a pocs Fins a ~100, amb topall natural Incrustar l'objecte complet Un material i les seves 3–8 etiquetes; un material i les seves dades de portada; un soci i les seves 2 adreces
Un a molts Centenars o milers, amb creixement moderat Referenciar, i guardar al pare un subconjunt dels fills més rellevants Un material i les seves ressenyes; un material i els seus exemplars
Un a moltíssims Desenes de milers o sense límit Referenciar des del fill, mai guardar la llista al pare Un soci i els seus esdeveniments d'activitat; un material i les seves consultes

La diferència entre les dues últimes files és subtil i crucial. A un a molts, el pare pot guardar la llista d'identificadors dels seus fills, perquè hi cap. A un a moltíssims, aquesta llista seria un array de 40.000 elements que creix cada dia: l'enllaç ha d'anar només al fill, que apunta cap amunt.

Aplicat a BiblioRed, cas per cas:

Material i etiquetes → incrustar. Un material té entre tres i vuit etiquetes, no creixen sense control, es mostren sempre amb la fitxa i no es consulten per separat (la cerca per etiqueta es resol amb un índex sobre l'array incrustat, no amb una altra col·lecció). Els sis criteris apunten a incrustar.

Material i ressenyes → referenciar, amb subconjunt. Les ressenyes creixen sense topall, es consulten per separat (perfil del soci, moderació), canvien sovint (vots útils, edicions, respostes) i són l'entitat principal de la consulta C2. Van a la seva pròpia col·lecció. Però la consulta C1 —la més freqüent del sistema— vol la fitxa amb les seves millors ressenyes en un sol accés, així que el document del material guarda a més una còpia de les cinc millors. Això és el patró de subconjunt de l'apartat 7.

Material i exemplars → ni una cosa ni l'altra. Els exemplars físics, amb el seu estat i la seva sucursal, es queden a PostgreSQL. Estan íntimament lligats als préstecs, que són transaccionals. El catàleg de MongoDB guarda només un comptador desnormalitzat de disponibilitat per poder pintar "3 disponibles a Centre" sense consultar l'altra base. Reconèixer que una dada no ha de migrar també és una decisió de modelatge.

Soci i activitat → un a moltíssims. Cada esdeveniment és un document (o millor, un element dins d'un document d'agrupació, apartat 8) que apunta al soci. El document del soci no guarda cap llista d'esdeveniments.

  1. Duplicació controlada i com mantenir-la coherent

Al mòdul 2 la redundància era l'enemic. En el modelatge documental és una eina. Però és una eina amb tall, i cal agafar-la pel mànec.

La duplicació es justifica quan el cost de mantenir-la és menor que el cost de reconstruir la dada a cada lectura. I aquest càlcul depèn gairebé sempre d'una sola pregunta: amb quina freqüència canvia la dada duplicada, comparada amb la freqüència amb què es llegeix?

Hi ha tres categories de dada duplicada, i es tracten de manera diferent:

Categoria A: dades immutables per naturalesa

No canvien mai, així que duplicar-les és gratis.

{ "material_id": "MAT-0331", "isbn": "9788401339097", "any_publicacio": 2008 }

L'ISBN d'un llibre i el seu any de publicació són fixos. Copia'ls sense remordiment.

Categoria B: dades històriques, que han de congelar-se

Aquí la duplicació no és una optimització: és correcció semàntica.

{
  "_id": "RES-1001",
  "soci": { "soci_id": 14, "nom_mostrat": "Marta Alsina" },
  "data": "2026-03-14T10:25:00Z"
}

Si la Marta canvia de cognom el 2027, cal reescriure les seves 34 ressenyes? Depèn de què representi el camp:

  • Si és "el nom que es mostra ara", sí: cal propagar-ho.
  • Si és "qui va signar això el març del 2026", no: és una dada històrica i reescriure-la seria falsificar-la.

És la mateixa distinció que en una factura: el preu del producte en el moment de la venda es copia a la línia de factura i no es toca mai més, encara que el catàleg canviï demà. Decidir explícitament en quina categoria cau cada camp duplicat —i escriure-ho a la documentació del projecte— evita discussions i errors més endavant.

Categoria C: dades vives que han de propagar-se

Són les perilloses. El títol d'un material apareix a les seves ressenyes; si el bibliotecari el corregeix, hi ha ressenyes amb el títol vell.

Tres estratègies, amb el seu cost:

Estratègia Com funciona Quan fer-la servir
Propagació immediata En canviar l'original, un updateMany actualitza totes les còpies La dada canvia molt de tant en tant i les còpies són poques
Propagació diferida S'encua el canvi i un procés l'aplica en segon pla Moltes còpies; es tolera un desfasament de minuts
Sense propagació, amb relectura La còpia és només una pista; la vista crítica rellegeix l'original El desfasament és inacceptable en algun punt concret
// Propagació immediata: el bibliotecari corregeix un títol amb una errada
db.cataleg.updateOne(
  { _id: "MAT-0331" },
  { $set: { titol: "El mapa del temps" } }
)

// ...i les còpies a les ressenyes s'actualitzen a continuació
db.ressenyes.updateMany(
  { material_id: "MAT-0331" },
  { $set: { titol_material: "El mapa del temps" } }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
{ acknowledged: true, matchedCount: 47, modifiedCount: 47 }

I aquí apareix amb tota la seva cruesa el preu de NoSQL que vam anunciar a 03-01: aquestes dues operacions no són atòmiques entre si. Si el procés cau entre la primera i la segona, el catàleg té el títol nou i 47 ressenyes el vell. A PostgreSQL aquest problema senzillament no existiria, perquè el títol seria en un sol lloc.

Les defenses disponibles: embolcallar totes dues escriptures en una transacció de MongoDB (possible des del 2018, amb cost de rendiment), o —més habitual— dissenyar el sistema perquè el desfasament temporal sigui tolerable i programar un procés periòdic de reconciliació que detecti i corregeixi les divergències.

Regla d'or: duplica només el que es mostra, mai el que es fa servir per decidir. Duplicar el títol d'un material per pintar-lo en una llista és raonable. Duplicar-ne el preu o l'estat de disponibilitat per prendre una decisió de negoci a partir de la còpia, no.

  1. Patró: referència estesa

Problema. Referenciar és correcte, però obliga a una segona consulta per mostrar quatre dades del document referenciat.

Solució. Al costat de la referència, copiar els pocs camps que la vista necessita. Ni tots ni cap: els que es pinten.

// SENSE el patró: dues consultes per cada ressenya mostrada
db.ressenyes.find({ material_id: "MAT-0331" })
db.cataleg.findOne({ _id: "MAT-0331" })   // només per saber el títol i la portada

// AMB referència estesa: una sola consulta
db.ressenyes.findOne({ _id: "RES-1001" })
{
  "_id": "RES-1001",
  "material": {
    "material_id": "MAT-0331",
    "titol": "El mapa del temps",
    "portada": "/img/catalogo/0331-s.webp",
    "tipus": "llibre"
  },
  "soci": { "soci_id": 14, "nom_mostrat": "Marta Alsina" },
  "puntuacio": 5,
  "text": "Una novel·la que juga amb el temps sense marejar el lector."
}

Criteri de selecció de camps: copia el que sigui estable i es mostri. El títol i el tipus són estables; la portada canvia molt de tant en tant. No copiïs la sinopsi (llarga i editable) ni el nombre d'exemplars disponibles (canvia a cada préstec). Per a això hi ha la referència.

Quan aplicar-lo: és el patró més usat i més útil del modelatge documental. Qualsevol llista que mostri elements de dues col·leccions n'és candidata.

  1. Patró: subconjunt

Problema. El document del material podria contenir totes les seves ressenyes, però un títol popular en té 800 i la fitxa només mostra les cinc millors. Incrustar 800 documents per pintar-ne cinc és carregar 160 vegades més dades de les necessàries a la consulta més freqüent del sistema.

Solució. Guardar al pare una còpia del subconjunt que la vista necessita, i el conjunt complet a la seva pròpia col·lecció.

{
  "_id": "MAT-0331",
  "titol": "El mapa del temps",
  "valoracio": { "mitjana": 4.3, "total_ressenyes": 812 },
  "ressenyes_destacades": [
    { "ressenya_id": "RES-1001", "nom": "Marta Alsina", "puntuacio": 5,
      "extracte": "Una novel·la que juga amb el temps sense marejar el lector.", "vots_utils": 41 },
    { "ressenya_id": "RES-1244", "nom": "Núria Bastos", "puntuacio": 5,
      "extracte": "L'ambientació victoriana està molt cuidada.", "vots_utils": 33 },
    { "ressenya_id": "RES-1533", "nom": "Ivan Pereda", "puntuacio": 4,
      "extracte": "Es gaudeix més si coneixes la novel·la de Wells.", "vots_utils": 28 }
  ]
}

Amb això, la consulta C1 —la fitxa completa, la més freqüent del portal— és un sol findOne. Qui premi "veure les 812 ressenyes" farà una segona consulta a ressenyes, però això només ho fa una fracció dels visitants.

Com es manté el subconjunt. Cada vegada que una ressenya puja de vots, es comprova si hauria d'entrar al destacat:

// Recalcular el destacat d'un material després d'un canvi a les seves ressenyes
const top = db.ressenyes.find(
  { material_id: "MAT-0331", estat: "publicada", spoiler: false },
  { _id: 1, "soci.nom_mostrat": 1, puntuacio: 1, text: 1, vots_utils: 1 }
).sort({ vots_utils: -1 }).limit(3).toArray()

db.cataleg.updateOne(
  { _id: "MAT-0331" },
  { $set: { ressenyes_destacades: top.map(r => ({
      ressenya_id: r._id,
      nom: r.soci.nom_mostrat,
      puntuacio: r.puntuacio,
      extracte: r.text.substring(0, 140),
      vots_utils: r.vots_utils
  })) } }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }

Aquest recàlcul no ha de ser immediat: pot executar-se cada pocs minuts en segon pla. Que una ressenya trigui deu minuts a aparèixer al destacat no molesta ningú, i a canvi s'evita executar el recàlcul a cada vot.

Quan aplicar-lo: sempre que la vista principal necessiti només els N primers d'una col·lecció gran.

  1. Patró: agrupació (bucket)

Problema. El registre d'activitat genera 17 milions d'esdeveniments l'any. Un document per esdeveniment significa 17 milions de documents diminuts, cadascun amb la sobrecàrrega del seu _id, la seva entrada d'índex i les seves metadades internes. En molts casos, la sobrecàrrega pesa més que la dada.

Solució. Agrupar els esdeveniments d'una mateixa entitat i un mateix període en un sol document contenidor.

{
  "_id": "ACT-14-2026-08-02",
  "soci_id": 14,
  "dia": "2026-08-02",
  "sucursal_id": 1,
  "num_esdeveniments": 4,
  "primer_esdeveniment": "2026-08-02T09:14:02Z",
  "ultim_esdeveniment": "2026-08-02T09:31:55Z",
  "esdeveniments": [
    { "t": "2026-08-02T09:14:02Z", "tipus": "cerca", "terme": "jules verne" },
    { "t": "2026-08-02T09:14:31Z", "tipus": "fitxa", "material_id": "MAT-0331" },
    { "t": "2026-08-02T09:22:10Z", "tipus": "filtre", "camp": "idioma", "valor": "ca" },
    { "t": "2026-08-02T09:31:55Z", "tipus": "fitxa", "material_id": "MAT-0412" }
  ]
}

La inserció d'un esdeveniment es converteix en una única operació que crea el contenidor si no existeix i afegeix l'esdeveniment si ja existeix:

db.activitat.updateOne(
  { _id: "ACT-14-2026-08-02" },
  {
    $push: { esdeveniments: { t: new Date(), tipus: "fitxa", material_id: "MAT-0508" } },
    $inc:  { num_esdeveniments: 1 },
    $max:  { ultim_esdeveniment: new Date() },
    $setOnInsert: { soci_id: 14, dia: "2026-08-02", sucursal_id: 1 }
  },
  { upsert: true }
)
{
  acknowledged: true,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedId: null
}

upsert: true és la clau: si el document del dia no existeix, es crea; si existeix, s'actualitza. $setOnInsert posa els camps fixos només en la creació, per no reescriure'ls cada vegada.

Les xifres del canvi, amb les estimacions de BiblioRed:

Un document per esdeveniment Amb agrupació per soci i dia
Documents l'any ~17.000.000 ~624.000 (12.000 socis × ~52 dies actius)
Entrades d'índex per esdeveniment 1 o més ~0,04
Espai de sobrecàrrega Molt alt Baix
"Activitat del soci 14 el 2 d'agost" Buscar N documents Un findOne
Escriptura d'un esdeveniment insertOne updateOne amb upsert

Com triar la mida del contenidor. Per soci i dia és el natural a BiblioRed. Però un usuari molt intensiu podria generar centenars d'esdeveniments diaris, així que cal posar-hi un topall: quan un contenidor arriba a, per exemple, 500 esdeveniments, se n'obre un altre (ACT-14-2026-08-02-2). Un contenidor sense topall es converteix en un array il·limitat, que és justament l'antipatró de l'apartat següent.

Quan aplicar-lo: sèries d'esdeveniments, telemetria, mesuraments, registres d'auditoria. És la resposta documental al problema que a 03-02 vam adjudicar a Cassandra, i la raó que BiblioRed pugui resoldre'l sense desplegar una altra base.

  1. Patró: valor atípic (outlier)

Problema. El 99,8 % dels materials de BiblioRed té menys de 50 ressenyes, i per a ells incrustar-les seria perfecte. Però tres o quatre supervendes en tenen milers. Si es dissenya per al cas extrem, es penalitzen els 39.996 materials normals; si es dissenya per al cas normal, els quatre extrems trenquen el sistema.

Solució. Dissenyar per al cas comú i marcar els excepcionals amb un indicador que activi un camí alternatiu.

{
  "_id": "MAT-0412",
  "titol": "Els pilars de la Terra",
  "ressenyes": [ "...les primeres 50, incrustades..." ],
  "ressenyes_desbordades": true,
  "total_ressenyes": 1240
}
// L'aplicació consulta segons l'indicador
const mat = db.cataleg.findOne({ _id: "MAT-0412" })

let ressenyes = mat.ressenyes
if (mat.ressenyes_desbordades) {
  ressenyes = db.ressenyes.find({ material_id: mat._id })
                          .sort({ vots_utils: -1 }).limit(50).toArray()
}

Cost: l'aplicació té dos camins de lectura, i això és complexitat real que cal documentar i provar. Per això aquest patró s'aplica només quan la distribució és realment asimètrica i el cas extrem és una minoria diminuta. Si el 20 % dels materials desborda, no hi ha valor atípic: hi ha un mal disseny i toca referenciar per a tothom.

  1. Patró: camp calculat

Problema. La fitxa d'un material mostra la seva puntuació mitjana. Calcular-la a cada visita significa recórrer les seves 812 ressenyes amb una agregació, per a una pàgina que es demana milers de vegades al dia.

Solució. Guardar el resultat ja calculat al document i actualitzar-lo quan canviïn les dades d'origen.

{
  "_id": "MAT-0331",
  "titol": "El mapa del temps",
  "valoracio": {
    "mitjana": 4.3,
    "total_ressenyes": 812,
    "suma_puntuacions": 3492,
    "distribucio": { "1": 12, "2": 31, "3": 88, "4": 264, "5": 417 },
    "actualitzat": "2026-08-02T09:00:00Z"
  }
}

Fixa't en suma_puntuacions: guardar la suma a més de la mitjana permet actualitzar-la incrementalment, sense recórrer res.

// Arriba una ressenya nova amb puntuació 5
db.cataleg.updateOne(
  { _id: "MAT-0331" },
  {
    $inc: {
      "valoracio.total_ressenyes": 1,
      "valoracio.suma_puntuacions": 5,
      "valoracio.distribucio.5": 1
    },
    $currentDate: { "valoracio.actualitzat": true }
  }
)

// La mitjana es recalcula a partir de dos números, no de 813 documents
db.cataleg.updateOne(
  { _id: "MAT-0331" },
  [ { $set: { "valoracio.mitjana": {
        $round: [ { $divide: ["$valoracio.suma_puntuacions", "$valoracio.total_ressenyes"] }, 2 ] } } } ]
)

db.cataleg.findOne({ _id: "MAT-0331" }, { _id: 0, valoracio: 1 })
{
  valoracio: {
    mitjana: 4.3,
    total_ressenyes: 813,
    suma_puntuacions: 3497,
    distribucio: { '1': 12, '2': 31, '3': 88, '4': 264, '5': 418 },
    actualitzat: ISODate('2026-08-02T09:47:22.108Z')
  }
}

Risc: el camp calculat pot desviar-se de la realitat si alguna escriptura falla o si s'esborra una ressenya sense descomptar-la. Defensa habitual: un procés nocturn que recalcula des de zero i corregeix. Un comptador que només puja mai no torna sol al seu lloc.

Quan aplicar-lo: quan la relació lectures/escriptures és molt alta. Aquí és de milers a un; el patró es paga sol.

  1. Antipatrons que cal reconèixer

11.1 Arrays sense cota

L'antipatró número u, i el més fàcil de cometre.

{
  "_id": "MAT-0412",
  "titol": "Els pilars de la Terra",
  "consultes": [ "...41.238 elements i pujant..." ]
}

Què passa, en ordre d'aparició: el document creix fins a acostar-se al límit de 16 MB; cada $push obliga a reescriure un document cada vegada més gran; les lectures transfereixen megabytes per fer servir dos camps; els índexs sobre l'array es disparen de mida; i un dia una escriptura falla amb BSONObjectTooLarge i no hi ha arranjament ràpid.

Senyal d'alarma: si no pots dir el nombre màxim d'elements que tindrà un array, no l'incrustis. Referencia o agrupa.

11.2 Documents gegants

Encara que no hi hagi arrays il·limitats, un document pot engreixar-se per acumulació: la sinopsi completa, la portada en base64, el text extret del PDF, l'històric de canvis... tot dins de la fitxa del material.

Conseqüència: cada lectura de la fitxa —per mostrar el títol i la portada en miniatura— transfereix el document sencer des del disc a la memòria i d'allà a la xarxa. La memòria cau del servidor s'omple de dades que ningú no mira, i se'n desallotgen documents que sí que es fan servir.

Regla: les dades grans que es consulten molt de tant en tant van a la seva pròpia col·lecció, o directament fora de la base de dades —les imatges, a un magatzem d'objectes o a un sistema de fitxers, amb la URL al document—.

11.3 Col·leccions massives de documents diminuts

L'extrem contrari: 17 milions de documents de 80 bytes. La sobrecàrrega per document (identificador, entrades d'índex, metadades) supera la dada útil, els índexs no caben en memòria i les consultes per rang obliguen a llegir milions de documents dispersos.

Solució: el patró d'agrupació de l'apartat 8.

11.4 Fer servir MongoDB com si fos relacional

És l'antipatró més car perquè no dóna la cara: el sistema funciona, simplement funciona pitjor del que funcionaria PostgreSQL.

// Cinc col·leccions normalitzades i una canalització que les cus amb $lookup
db.ressenyes.aggregate([
  { $lookup: { from: "socis",     localField: "soci_id",     foreignField: "_id", as: "soci" } },
  { $lookup: { from: "cataleg",   localField: "material_id", foreignField: "_id", as: "material" } },
  { $lookup: { from: "autors",    localField: "material.autor_id", foreignField: "_id", as: "autor" } },
  { $lookup: { from: "etiquetes", localField: "etiqueta_ids", foreignField: "_id", as: "etiquetes" } },
  { $unwind: "$soci" }, { $unwind: "$material" }
])

Aquest codi és un esquema relacional escrit a MongoDB, i hereta el pitjor dels dos mons: la lentitud d'unir sense les optimitzacions d'un planificador relacional madur, i la manca d'integritat referencial de la base documental. Si el teu disseny acaba aquí, la conclusió correcta no és "cal optimitzar la canalització": és "aquest domini volia PostgreSQL".

$lookup és legítim per a informes ocasionals i processos per lots. No ho és com a mecanisme habitual de la ruta de lectura principal.

Antipatró Símptoma que veuràs Correcció
Array sense cota Documents que creixen sense parar; escriptures lentes Referenciar o agrupar
Document gegant Lectures que transfereixen molt per fer servir poc Treure les dades grans
Documents diminuts massius Índexs enormes, consultes per rang lentes Patró d'agrupació
Relacional disfressat $lookup a totes les consultes Redissenyar o tornar a SQL

  1. Validació d'esquema amb $jsonSchema

L'esquema flexible és un avantatge mentre l'equip és disciplinat. MongoDB permet recuperar part de la xarxa de seguretat de manera voluntària i gradual, que és justament el que es necessita: regles fortes on importen, llibertat on convé.

db.createCollection("ressenyes", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["material_id", "soci", "puntuacio", "text", "data", "estat", "esquema_v"],
      properties: {
        esquema_v:   { bsonType: "int", minimum: 1, description: "versió del document" },
        material_id: { bsonType: "string", pattern: "^MAT-[0-9]{4}$" },
        soci: {
          bsonType: "object",
          required: ["soci_id", "nom_mostrat"],
          properties: {
            soci_id:     { bsonType: "int", minimum: 1 },
            nom_mostrat: { bsonType: "string", maxLength: 80 }
          }
        },
        puntuacio: { bsonType: "int", minimum: 1, maximum: 5 },
        text:      { bsonType: "string", minLength: 10, maxLength: 4000 },
        etiquetes: { bsonType: "array", maxItems: 10, items: { bsonType: "string" } },
        estat:     { enum: ["publicada", "pendent", "oculta", "denunciada"] },
        data:      { bsonType: "date" }
      }
    }
  },
  validationLevel: "moderate",
  validationAction: "error"
})

Prova que funciona:

db.ressenyes.insertOne({
  material_id: "MAT-0331",
  soci: { soci_id: 14, nom_mostrat: "Marta Alsina" },
  puntuacio: 9,                        // fora del rang 1–5
  text: "Curta",                       // menys de 10 caràcters
  data: "2026-08-02",                  // cadena, no data
  estat: "publicada",
  esquema_v: 1
})
MongoServerError: Document failed validation

Additional information: {
  failingDocumentId: ObjectId('66ab21f35c9e1b2f3d4a6c90'),
  details: {
    operatorName: '$jsonSchema',
    schemaRulesNotSatisfied: [
      { operatorName: 'properties', propertiesNotSatisfied: [
          { propertyName: 'puntuacio', description: 'maximum: 5', consideredValue: 9 },
          { propertyName: 'text',      description: 'minLength: 10', consideredValue: 'Curta' },
          { propertyName: 'data',      description: 'bsonType: date',  consideredValue: '2026-08-02' }
      ]}
    ]
  }
}

Tres errors capturats en l'escriptura, que és exactament on volíem. El de la data com a cadena és el més valuós: és l'error de la lliçó 03-01 que arruïna tota agregació posterior, i aquí no arriba ni a entrar.

Els dos paràmetres de comportament:

Paràmetre Valor Efecte
validationLevel strict Valida totes les insercions i totes les actualitzacions
moderate Valida les insercions i només les actualitzacions de documents que ja complien
off No valida
validationAction error Rebutja l'escriptura
warn L'accepta i anota un avís al registre

Estratègia recomanada per a una col·lecció que ja té dades: començar amb validationAction: "warn" per descobrir quants documents incomplirien sense trencar res, corregir-los, i només llavors passar a error. I fer servir moderate mentre quedin documents antics per migrar, per no bloquejar les actualitzacions del que encara no compleix.

Què no fa $jsonSchema, per no crear falses expectatives: no comprova que material_id apunti a un material existent. La integritat referencial continua sense existir. Valida forma, no referències.

  1. Versionatge de documents i evolució de l'esquema

En una base relacional, canviar la forma de les dades és un ALTER TABLE que afecta totes les files alhora, amb el seu bloqueig i la seva finestra. En una documental hi ha una alternativa molt més còmoda: no migrar res i conviure amb diverses versions.

La tècnica és senzilla i consisteix en un camp:

{ "_id": "RES-0450", "esquema_v": 1, "puntuacio": 5, "text": "..." }
{ "_id": "RES-1001", "esquema_v": 2, "puntuacio": 5, "text": "...",
  "etiquetes": ["novella historica"], "vots_utils": 41, "spoiler": false }

L'aplicació llegeix el camp i sap què esperar:

function normalitzarRessenya(doc) {
  if (doc.esquema_v === 1) {
    return { ...doc, etiquetes: [], vots_utils: 0, spoiler: false, esquema_v: 2 }
  }
  return doc
}

Tres estratègies de migració, en ordre d'agressivitat:

Estratègia Com funciona Quan
Mandrosa (lazy) El document s'actualitza a la versió nova la propera vegada que s'escriu El més habitual: cost zero, migració progressiva
En segon pla Un procés recorre la col·lecció per lots i va actualitzant Quan convé acabar en un termini, sense aturar el servei
Massiva Un updateMany sobre tota la col·lecció Només si són pocs documents o hi ha finestra d'aturada
// Migració mandrosa: en afegir un camp nou, s'actualitza la versió
db.ressenyes.updateOne(
  { _id: "RES-0450" },
  { $set: { etiquetes: [], vots_utils: 0, spoiler: false, esquema_v: 2 } }
)

// Migració en segon pla, per lots de 1000
db.ressenyes.updateMany(
  { esquema_v: 1 },
  { $set: { etiquetes: [], vots_utils: 0, spoiler: false, esquema_v: 2 } }
)
{ acknowledged: true, matchedCount: 12483, modifiedCount: 12483 }
// Comprovar com va la migració
db.ressenyes.aggregate([ { $group: { _id: "$esquema_v", n: { $sum: 1 } } }, { $sort: { _id: 1 } } ])
[ { _id: 2, n: 12483 } ]

L'advertiment imprescindible: conviure amb diverses versions té un cost, i és el codi que les gestiona. Si l'equip acumula sis versions actives, la funció de normalització es converteix en un laberint de condicionals que ningú no gosa tocar. Conviu amb dues, potser tres, i tanca les migracions: quan el recompte d'una versió antiga arriba a zero, esborra la seva branca de codi.

  1. Els índexs, breument

Tot el que hem dit sobre modelatge suposa que les consultes es resolen eficientment, i això depèn dels índexs tant a MongoDB com a PostgreSQL.

La bona notícia és que la lògica és la mateixa que aprendràs a la lliçó 06-03: un índex és una estructura auxiliar —normalment un arbre B— que evita recórrer totes les dades; accelera les lectures, alenteix les escriptures, ocupa espai, i l'ordre dels camps en un índex compost determina quines consultes pot servir.

Les particularitats documentals, en quatre línies:

  • Es pot indexar qualsevol camp, encara que estigui imbricat: db.ressenyes.createIndex({ "soci.soci_id": 1 }).
  • Es pot indexar el contingut d'un array (índex multiclau): una entrada per element. És el que fa ràpida la cerca per etiqueta.
  • L'índex TTL esborra documents automàticament passat un temps, i és el que BiblioRed farà servir per caducar l'activitat als dos anys.
  • explain() és l'equivalent d'EXPLAIN en SQL i diu si la consulta ha fet servir un índex o ha recorregut la col·lecció sencera.
db.ressenyes.createIndex({ material_id: 1, vots_utils: -1 })
db.ressenyes.createIndex({ etiquetes: 1 })
db.activitat.createIndex({ dia: 1 }, { expireAfterSeconds: 63072000 })  // 2 anys
material_id_1_vots_utils_-1
etiquetes_1
dia_1

No hi aprofundim més: els índexs tenen la seva lliçó pròpia al mòdul 6.

  1. Lliurable: el disseny final de les col·leccions de BiblioRed

Aquí hi ha el resultat d'aplicar tot l'anterior. Tres col·leccions a la base bibliored de MongoDB, cada decisió justificada.

15.1 Col·lecció cataleg

Arrel de l'agregat: el material. Respon a: C1 (fitxa completa) i C4 (cerca).

{
  "_id": "MAT-0331",
  "esquema_v": 2,
  "tipus": "llibre",
  "titol": "El mapa del temps",
  "titol_normalitzat": "el mapa del temps",
  "idioma": "es",
  "any_publicacio": 2008,
  "sinopsi": "Al Londres del 1896, un jove aristòcrata busca la manera de viatjar al passat...",
  "portada": { "gran": "/img/catalogo/0331-g.webp", "miniatura": "/img/catalogo/0331-s.webp" },
  "autors": [
    { "autor_id": 77, "nom_complet": "Félix J. Palma", "rol": "autor" }
  ],
  "etiquetes": ["novella historica", "ciencia ficcio", "epoca victoriana", "premiat"],
  "metadades": {
    "isbn": "9788401339097",
    "editorial": "Edicions Vallmar",
    "pagines": 612,
    "enquadernacio": "tapa dura"
  },
  "disponibilitat": {
    "total_exemplars": 6,
    "per_sucursal": { "1": 2, "2": 1, "3": 2, "4": 1 },
    "actualitzat": "2026-08-02T06:00:00Z"
  },
  "valoracio": {
    "mitjana": 4.3,
    "total_ressenyes": 812,
    "suma_puntuacions": 3492,
    "distribucio": { "1": 12, "2": 31, "3": 88, "4": 264, "5": 417 }
  },
  "ressenyes_destacades": [
    { "ressenya_id": "RES-1001", "nom": "Marta Alsina", "puntuacio": 5,
      "extracte": "Una novel·la que juga amb el temps sense marejar el lector.", "vots_utils": 41 },
    { "ressenya_id": "RES-1244", "nom": "Núria Bastos", "puntuacio": 5,
      "extracte": "L'ambientació victoriana està molt cuidada.", "vots_utils": 33 }
  ],
  "alta": "2024-11-03T10:00:00Z",
  "actiu": true
}

I un document d'un altre tipus, a la mateixa col·lecció, perquè es vegi l'heterogeneïtat:

{
  "_id": "MAT-0802",
  "esquema_v": 2,
  "tipus": "revista",
  "titol": "Vallmar Cultural",
  "titol_normalitzat": "vallmar cultural",
  "idioma": "ca",
  "any_publicacio": 2026,
  "portada": { "miniatura": "/img/catalogo/0802-s.webp" },
  "etiquetes": ["cultura local", "hemeroteca"],
  "metadades": {
    "issn": "2604-1188",
    "numero": 42,
    "volum": 7,
    "periodicitat": "mensual"
  },
  "disponibilitat": { "total_exemplars": 4, "per_sucursal": { "1": 1, "2": 1, "3": 1, "4": 1 } },
  "valoracio": { "mitjana": 0, "total_ressenyes": 0, "suma_puntuacions": 0 },
  "ressenyes_destacades": [],
  "alta": "2026-05-11T09:04:00Z",
  "actiu": true
}
Decisió Justificació
_id natural "MAT-0331" Llegible en registres i URL; estalvia un índex addicional
metadades com a subdocument lliure Aïlla el que és específic de cada tipus de material; afegir "còmic" el 2027 no toca la resta del document
autors incrustat amb nom Un a pocs + referència estesa: la fitxa mostra el nom sense anar a una altra col·lecció
etiquetes com a array de cadenes Un a pocs amb topall; índex multiclau resol C4
ressenyes_destacades Patró de subconjunt: C1 es resol amb un sol findOne
valoracio amb suma i distribució Patró de camp calculat, actualitzable amb $inc sense recórrer res
disponibilitat desnormalitzada Còpia de només lectura de PostgreSQL, refrescada cada hora; la veritat continua a exemplars
titol_normalitzat Sense accents ni majúscules, per a cerques insensibles als accents
Sinopsi incrustada És text d'uns pocs kilobytes i es mostra a la mateixa fitxa: no justifica una altra col·lecció
Portades com a rutes, no com a binaris Antipatró de document gegant evitat: les imatges viuen fora

Índexs: { titol_normalitzat: 1 }, { etiquetes: 1 }, { tipus: 1, "valoracio.mitjana": -1 }, { "autors.autor_id": 1 }.

15.2 Col·lecció ressenyes

Arrel de l'agregat: la ressenya. Respon a: C2, C3 i C5.

{
  "_id": "RES-1001",
  "esquema_v": 2,
  "material": {
    "material_id": "MAT-0331",
    "titol": "El mapa del temps",
    "tipus": "llibre",
    "portada": "/img/catalogo/0331-s.webp"
  },
  "soci": {
    "soci_id": 14,
    "nom_mostrat": "Marta Alsina",
    "sucursal_id": 1
  },
  "puntuacio": 5,
  "text": "Una novel·la que juga amb el temps sense marejar el lector. L'ambientació victoriana està molt cuidada i els tres actes se sostenen sols.",
  "etiquetes": ["novella historica", "ciencia ficcio"],
  "spoiler": false,
  "estat": "publicada",
  "vots_utils": 41,
  "votants": [15, 16, 22, 31],
  "resposta_bibliotecari": {
    "sucursal_id": 1,
    "text": "Si t'ha agradat, a la sucursal Centre tenim la continuació disponible.",
    "data": "2026-03-16T09:10:00Z"
  },
  "data": "2026-03-14T10:25:00Z",
  "editada": null
}
Decisió Justificació
Col·lecció pròpia, no incrustada a cataleg Un a molts sense topall, amb vida pròpia (perfil, moderació) i alta volatilitat
material com a referència estesa Quatre camps estables permeten pintar el perfil del soci (C3) sense consultar cataleg
soci.nom_mostrat duplicat Categoria B: és "qui va signar això", dada històrica congelada expressament
vots_utils + votants incrustats Un vot ha de canviar comptador i llista atòmicament: van dins de l'agregat
votants amb només el soci_id Array acotat a la pràctica; si un dia desbordés, passaria a col·lecció pròpia (valor atípic)
resposta_bibliotecari com a subdocument opcional Zero o una per ressenya; el camp simplement no existeix si no n'hi ha
estat amb valors tancats Permet moderar sense esborrar; validat amb enum a $jsonSchema
etiquetes incrustades Un a pocs amb topall de 10 imposat per la validació

Índexs: { "material.material_id": 1, vots_utils: -1 }, { "soci.soci_id": 1, data: -1 }, { estat: 1, data: -1 }, { etiquetes: 1 }.

15.3 Col·lecció activitat

Arrel de l'agregat: el conjunt d'esdeveniments d'un soci en un dia. Respon a: C6, C7 i C8.

{
  "_id": "ACT-14-2026-08-02",
  "esquema_v": 1,
  "soci_id": 14,
  "dia": "2026-08-02T00:00:00Z",
  "sucursal_id": 1,
  "num_esdeveniments": 5,
  "primer_esdeveniment": "2026-08-02T09:14:02Z",
  "ultim_esdeveniment": "2026-08-02T09:41:07Z",
  "esdeveniments": [
    { "t": "2026-08-02T09:14:02Z", "tipus": "cerca",   "terme": "jules verne", "resultats": 7 },
    { "t": "2026-08-02T09:14:31Z", "tipus": "fitxa",   "material_id": "MAT-0331" },
    { "t": "2026-08-02T09:22:10Z", "tipus": "filtre",  "camp": "idioma", "valor": "ca" },
    { "t": "2026-08-02T09:31:55Z", "tipus": "fitxa",   "material_id": "MAT-0412" },
    { "t": "2026-08-02T09:41:07Z", "tipus": "reserva", "material_id": "MAT-0412" }
  ]
}
Decisió Justificació
Patró d'agrupació per soci i dia De ~17 M documents anuals a ~624 K; C7 es resol amb un findOne
_id compost "ACT-<soci>-<data>" Determinista: permet l'upsert sense buscar abans
Noms de camp curts (t) dins d'esdeveniments En un array de milers d'elements, els noms de camp es repeteixen a cadascun i pesen
Comptadors num_esdeveniments, primer_esdeveniment, ultim_esdeveniment Responen sense obrir l'array
Topall de 500 esdeveniments per contenidor Impedeix l'antipatró d'array sense cota; en superar-lo s'obre -2
sucursal_id copiat del soci Permet l'informe per sucursal sense consultar PostgreSQL
Índex TTL sobre dia a 2 anys L'activitat antiga s'esborra sola; caducitat com a propietat de la dada
Sense referència a ressenyes ni cataleg L'activitat és un registre immutable: no necessita coherència amb res

Índexs: { soci_id: 1, dia: -1 }, { dia: 1 } amb expireAfterSeconds: 63072000, { "esdeveniments.material_id": 1 }.

15.4 El mapa complet

flowchart TD
    subgraph PG["PostgreSQL — biblioredb (la veritat transaccional)"]
        T["sucursals · socis · autors · llibres<br/>exemplars · prestecs · reserves"]
    end
    subgraph MG["MongoDB — bibliored (contingut i activitat)"]
        C["cataleg<br/>_id MAT-nnnn"]
        R["ressenyes<br/>_id RES-nnnn"]
        A["activitat<br/>_id ACT-soci-data"]
    end
    T -->|sincronització horària:<br/>disponibilitat| C
    R -->|patró subconjunt:<br/>ressenyes_destacades| C
    R -.->|referència estesa:<br/>material_id| C
    A -.->|referència:<br/>material_id, soci_id| C

Fixa't en l'estil de les fletxes: les contínues són còpies de dades que cal mantenir coherents; les discontínues són referències que l'aplicació resol quan cal. Cada fletxa contínua és una responsabilitat de coherència que algú ha d'assumir al codi, i per això convé que en siguin poques i estiguin documentades. Aquí n'hi ha exactament dues.

Errors Habituals i Consells

Error 1: començar dibuixant entitats. És el reflex que portem del model relacional i aquí condueix a col·leccions normalitzades cosides amb $lookup. Comença sempre per la llista de consultes.

Error 2: incrustar "perquè és el que es fa a MongoDB". Incrustar és correcte per a un a pocs. Per a un a molts i un a moltíssims és una bomba de rellotgeria que esclata en producció, no en desenvolupament.

Error 3: duplicar sense decidir la categoria de la dada. Cada camp duplicat ha d'estar classificat: immutable, històric congelat o viu amb propagació. Si no ho escrius, d'aquí a sis mesos ningú no sabrà si cal actualitzar-lo.

Error 4: confiar que l'esquema flexible es documenta sol. No es documenta. Escriu el $jsonSchema encara que el posis en warn: és documentació executable del contracte de la col·lecció.

Error 5: provar el disseny només amb dades de joguina. Un disseny amb 200 documents sempre sembla bo. Genera 500.000 documents sintètics amb la distribució real esperada —inclosos els valors atípics— i mesura abans de donar res per bo.

Error 6: oblidar que l'aplicació és ara la responsable. No hi ha cap FOREIGN KEY que impedeixi una ressenya sobre un material inexistent. Si l'esborrat d'un material ha d'arrossegar les seves ressenyes, aquest CASCADE l'escrius tu.

Consell 1: escriu la fitxa de decisió de cada col·lecció. Arrel de l'agregat, consultes que serveix, què s'incrusta i per què, què es duplica i de quina categoria és, índexs. Mitja pàgina per col·lecció que estalvia setmanes.

Consell 2: anomena els camps de manera consistent i curta on es repeteixen molt. Dins d'un array de milers d'elements, t en lloc de timestamp estalvia megabytes reals. Fora d'aquí, prioritza la llegibilitat.

Consell 3: posa el camp esquema_v des del primer document. Costa quatre bytes i el dia que el necessitis —i el necessitaràs— t'estalviarà una migració massiva a cegues.

Consell 4: revisa el disseny quan canviïn les consultes, no quan canviï el domini. És el corol·lari de l'apartat 1 i l'avís més important de la lliçó: al món documental, un canvi en com es fan servir les dades pot obligar a redissenyar encara que les dades siguin les mateixes.

Exercicis

Exercici 1

BiblioRed vol afegir clubs de lectura: grups amb nom, sucursal de reunió, entre 8 i 25 socis membres, un llibre assignat cada mes i un fil de comentaris per cada llibre llegit (uns 30–60 comentaris per llibre, i el club pot portar anys en marxa). Decideix per a cada relació si incrustes o referencies, justificant-ho amb els sis criteris de l'apartat 3, i escriu el document d'exemple de la col·lecció clubs.

Exercici 2

Aquest document té quatre problemes de disseny. Identifica'ls, digues quin antipatró o mal criteri representa cadascun i proposa el disseny corregit.

{
  "_id": ObjectId("..."),
  "soci_id": 14,
  "nom": "Marta Alsina",
  "email": "[email protected]",
  "foto_perfil_base64": "iVBORw0KGgoAAAANSUhEUgAAB...(1,8 MB)...",
  "historial_prestecs": [ "...318 préstecs des del 2019, un per element..." ],
  "esdeveniments_navegacio": [ "...11.402 esdeveniments, un per element..." ],
  "ressenyes_escrites": [ "...34 ressenyes amb el seu text complet duplicat..." ]
}

Exercici 3

Escriu la validació $jsonSchema de la col·lecció activitat dissenyada a l'apartat 15.3, exigint: soci_id enter positiu obligatori; dia de tipus data obligatori; num_esdeveniments enter entre 0 i 500; esdeveniments array obligatori de màxim 500 elements, on cada element tingui obligatòriament t (data) i tipus (un de cerca, fitxa, filtre, reserva, descarrega). Després explica per què convindria desplegar-la amb validationAction: "warn" abans que amb "error".

Solucions

Solució 1

Club i els seus membres → incrustar. Cardinalitat de 8 a 25, amb topall natural imposat pel mateix reglament del club (criteri 1: pocs). Es mostren sempre amb la fitxa del club i no es consulten per separat (criteri 2). Canvien poc: algú entra o surt unes quantes vegades l'any (criteri 3). Ocupen uns pocs kilobytes (criteri 4). El creixement està acotat pel màxim de places (criteri 5). I les altes i baixes han de ser coherents amb el recompte de places lliures, cosa que afavoreix l'atomicitat del mateix document (criteri 6). Sis de sis a favor d'incrustar, amb referència estesa al nom del soci.

Club i el seu calendari de lectures → incrustar, però vigilant. Dotze llibres l'any. Al cap de cinc anys són seixanta elements: continua sent un a pocs i hi cap de sobres. Es mostra sencer a la fitxa del club ("què hem llegit"). S'incrusta, i es deixa anotat que si un club superés els ~200 elements convindria separar l'històric antic.

Club i comentaris de cada llibre → referenciar. Aquí el criteri 5 mana: 40 comentaris per llibre × 12 llibres l'any × diversos anys és creixement sense topall (criteris 1 i 5). A més tenen vida pròpia —es moderen, es responen, s'enllacen— (criteri 2), canvien amb freqüència (criteri 3) i no necessiten canviar atòmicament amb el club (criteri 6). Col·lecció pròpia comentaris_club, amb el subconjunt dels tres últims incrustat al club per poder mostrar "última activitat" sense una segona consulta.

{
  "_id": "CLUB-007",
  "esquema_v": 1,
  "nom": "Els dimarts de Vallmar",
  "sucursal_id": 3,
  "dia_reunio": "dimarts",
  "hora_reunio": "19:00",
  "places": 25,
  "actiu": true,
  "membres": [
    { "soci_id": 14, "nom_mostrat": "Marta Alsina", "alta": "2025-09-02T00:00:00Z", "rol": "coordinadora" },
    { "soci_id": 16, "nom_mostrat": "Núria Bastos", "alta": "2025-10-07T00:00:00Z", "rol": "membre" },
    { "soci_id": 15, "nom_mostrat": "Ivan Pereda",  "alta": "2026-01-13T00:00:00Z", "rol": "membre" }
  ],
  "num_membres": 3,
  "calendari": [
    { "mes": "2026-06", "material_id": "MAT-0331", "titol": "El mapa del temps",      "estat": "llegit" },
    { "mes": "2026-07", "material_id": "MAT-0412", "titol": "Els pilars de la Terra", "estat": "llegit" },
    { "mes": "2026-08", "material_id": "MAT-0508", "titol": "L'ombra del far",        "estat": "en curs" }
  ],
  "ultims_comentaris": [
    { "comentari_id": "COM-4471", "soci_id": 16, "nom_mostrat": "Núria Bastos",
      "material_id": "MAT-0412", "extracte": "El capítol de la catedral mereix rellegir-se.",
      "data": "2026-07-28T20:14:00Z" }
  ],
  "total_comentaris": 187,
  "creat": "2025-09-02T00:00:00Z"
}

Solució 2

# Problema Antipatró o criteri incomplert Correcció
1 foto_perfil_base64 d'1,8 MB dins del document Document gegant: cada lectura del soci, encara que només se'n vulgui el nom, transfereix 1,8 MB i desallotja la memòria cau Guardar la imatge en un magatzem d'objectes o al sistema de fitxers i deixar només la ruta: "foto": "/img/socios/14.webp"
2 historial_prestecs amb 318 elements i creixent Array sense cota + dada que no hauria de ser aquí: els préstecs són transaccionals i viuen a PostgreSQL Eliminar-lo del document. Si cal un resum per al perfil, un camp calculat: "estadistiques": { "total_prestecs": 318, "ultim": "2026-07-19" }
3 esdeveniments_navegacio amb 11.402 elements Array sense cota en la seva forma més greu: creix cada dia i sense límit. És un a moltíssims Col·lecció activitat amb patró d'agrupació (apartat 15.3). El document del soci no guarda cap llista
4 ressenyes_escrites amb el text complet duplicat Duplicació de categoria C mal aplicada: el text és voluminós, editable i ja viu a ressenyes; tota edició obligaria a actualitzar dos llocs Referenciar. Si el perfil necessita mostrar les últimes, aplicar subconjunt amb només ressenya_id, títol del material, puntuació i extracte
{
  "_id": 14,
  "esquema_v": 2,
  "nom_mostrat": "Marta Alsina",
  "email": "[email protected]",
  "sucursal_id": 1,
  "foto": "/img/socios/14.webp",
  "preferencies": { "idioma": "ca", "avisos_email": true },
  "estadistiques": {
    "total_prestecs": 318,
    "total_ressenyes": 34,
    "ultim_prestec": "2026-07-19T00:00:00Z",
    "actualitzat": "2026-08-02T06:00:00Z"
  },
  "ressenyes_recents": [
    { "ressenya_id": "RES-1001", "material_id": "MAT-0331", "titol": "El mapa del temps",
      "puntuacio": 5, "extracte": "Una novel·la que juga amb el temps sense marejar...",
      "data": "2026-03-14T10:25:00Z" }
  ]
}

Un detall deliberat: _id és 14, el mateix identificador que el soci_id de PostgreSQL. Quan una entitat existeix a les dues bases, compartir l'identificador és la decisió que menys disgustos dóna.

Solució 3

db.runCommand({
  collMod: "activitat",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["soci_id", "dia", "num_esdeveniments", "esdeveniments", "esquema_v"],
      properties: {
        esquema_v:         { bsonType: "int", minimum: 1 },
        soci_id:           { bsonType: "int", minimum: 1 },
        dia:               { bsonType: "date" },
        sucursal_id:       { bsonType: "int", minimum: 1, maximum: 4 },
        num_esdeveniments: { bsonType: "int", minimum: 0, maximum: 500 },
        esdeveniments: {
          bsonType: "array",
          maxItems: 500,
          items: {
            bsonType: "object",
            required: ["t", "tipus"],
            properties: {
              t:           { bsonType: "date" },
              tipus:       { enum: ["cerca", "fitxa", "filtre", "reserva", "descarrega"] },
              terme:       { bsonType: "string", maxLength: 200 },
              material_id: { bsonType: "string", pattern: "^MAT-[0-9]{4}$" },
              camp:        { bsonType: "string" },
              valor:       { bsonType: "string" },
              resultats:   { bsonType: "int", minimum: 0 }
            }
          }
        }
      }
    }
  },
  validationLevel: "moderate",
  validationAction: "warn"
})
{ ok: 1 }

Fixa't en dues coses del disseny de la validació. Primer, maxItems: 500 converteix en regla comprovada pel servidor el topall que a l'apartat 15.3 era només una convenció: l'array sense cota deixa de ser possible per descuit. Segon, els camps terme, material_id, camp i valor no són a required: cada tipus d'esdeveniment fa servir uns i no uns altres, i aquesta heterogeneïtat és precisament el que fa que aquesta dada visqui a MongoDB.

Per què desplegar primer amb warn. La col·lecció ja té dades escrites abans que existís la validació, i és pràcticament segur que algunes incompleixen: esdeveniments antics amb t guardada com a cadena, un tipus que es deia "consulta" abans de canviar-li el nom, contenidors generats en proves amb més de 500 elements. Amb validationAction: "error" aquestes escriptures començarien a fallar de cop en producció i a la ruta d'escriptura més freqüent del sistema (C6, el registre d'esdeveniments, amb exigència de menys de 10 ms). Amb warn, MongoDB accepta l'escriptura i anota l'incompliment al registre: durant uns dies es recullen els avisos, es quantifica el problema, es corregeixen els documents antics i les rutes de codi que els generen, i només llavors es passa a error amb la certesa que no es trencarà res.

I validationLevel: "moderate" complementa l'estratègia: mentre duri la neteja, les actualitzacions de documents que ja incomplien no es bloquegen, així que el procés de correcció pot treballar sense barallar-se amb la seva pròpia validació.

Conclusió

Aquesta ha estat la lliçó de disseny, i el seu contingut es resumeix així:

  • L'ordre s'inverteix: en el model relacional es modela el domini i després es consulta; en el documental es parteix de la llista de consultes i es dissenyen documents que les resolguin en un sol accés. El mateix domini admet dissenys diferents segons el seu ús.
  • Un agregat es llegeix junt, s'escriu junt i té una arrel. I sobretot: és la frontera de l'atomicitat. El que és dins del document canvia d'una peça; el que és fora necessita transacció o tolerància al desfasament.
  • Incrustar o referenciar es decideix amb sis criteris: cardinalitat, si el fill es consulta per separat, volatilitat, mida enfront del límit de 16 MB, creixement acotat o il·limitat, i necessitat d'atomicitat conjunta.
  • Un a pocs → incrustar. Un a molts → referenciar amb subconjunt al pare. Un a moltíssims → referenciar només des del fill, sense llista al pare.
  • La duplicació és una eina, no un error, i cada camp duplicat pertany a una categoria: immutable (gratis), històric congelat (correcte per semàntica) o viu (exigeix propagació immediata, diferida o relectura). Duplica el que es mostra, mai el que es fa servir per decidir.
  • Cinc patrons: referència estesa (copiar els pocs camps que es pinten), subconjunt (els N primers al pare), agrupació (sèries d'esdeveniments en contenidors per període, amb upsert i $setOnInsert), valor atípic (indicador per a la minoria desbordada) i camp calculat (guardar l'agregat i mantenir-lo amb $inc).
  • Quatre antipatrons: arrays sense cota, documents gegants, col·leccions massives de documents diminuts i MongoDB fet servir com a base relacional a força de $lookup —el diagnòstic correcte del qual sol ser "això volia PostgreSQL"—.
  • $jsonSchema recupera de manera voluntària i gradual part de la xarxa de seguretat perduda: tipus, rangs, camps obligatoris, patrons i valors tancats, amb validationLevel i validationAction regulables. No valida referències: la integritat referencial continua sent cosa de l'aplicació.
  • El versionatge amb esquema_v permet evolucionar sense migració massiva, amb estratègies mandrosa, en segon pla o massiva. Conviu amb dues o tres versions i tanca les migracions.
  • I el lliurable: cataleg (arrel el material, amb metadades lliures per tipus, valoracio calculada, ressenyes_destacades com a subconjunt i disponibilitat copiada de PostgreSQL), ressenyes (arrel la ressenya, amb material i soci com a referències esteses i vots incrustats per atomicitat) i activitat (arrel el conjunt soci-dia, amb patró d'agrupació, _id determinista, topall de 500 esdeveniments i índex TTL a dos anys).

Ja tenim les dues meitats del curs construïdes: biblioredb a PostgreSQL i bibliored a MongoDB, cadascuna amb el disseny que li correspon. A la lliçó 03-04, Comparació entre Bases de Dades Relacionals i No Relacionals, les posem cara a cara dimensió per dimensió i hi afegim les peces teòriques que hem anat ajornant: el teorema CAP sense el malentès habitual i el seu refinament PACELC, el contrast entre ACID i BASE, què significa realment la consistència eventual per al lector que acaba de publicar una ressenya, els nivells de consistència ajustables de MongoDB, i el matís decisiu que la frontera s'ha difuminat —PostgreSQL guarda documents amb jsonb i MongoDB té transaccions multidocument—. Tancarem amb una guia de decisió honesta, els mites més repetits desmuntats i el nom de l'arquitectura a la qual ha arribat BiblioRed: la persistència poliglota.

© Copyright 2026. Tots els drets reservats