Ya sabemos qué es NoSQL, por qué existe y cómo se opera cada una de sus cuatro familias. Y sin embargo, con todo eso, todavía no sabemos hacer lo más importante: diseñar bien.

Esta es la lección donde más proyectos se tuercen. La sintaxis de MongoDB se aprende en una tarde; el modelado documental es donde se decide si el sistema irá bien dentro de dos años o si habrá que reescribirlo. Y la trampa es que un mal diseño funciona perfectamente el primer día: con 200 documentos todo es rápido y no falla nada. El daño aparece cuando la colección crece, cuando un documento se acerca a su límite de tamaño o cuando alguien descubre que el nombre de una sucursal está duplicado en 40.000 documentos y acaba de cambiar.

Vamos a invertir el orden mental que aprendimos en el módulo 2, a definir con precisión qué es un agregado, a resolver la decisión más importante del modelado documental —embeber o referenciar— con criterios explícitos en lugar de intuición, a aprender los cinco patrones de diseño que resuelven casi todos los casos reales y los cuatro anti-patrones que los estropean, y a recuperar parte de las garantías perdidas con la validación de esquema.

El entregable al final es concreto: el diseño definitivo de las tres colecciones de BiblioRedresenas, catalogo y actividad— con documentos de ejemplo y la justificación de cada decisión.

Contenido

  1. El cambio de mentalidad: del dominio a las consultas
  2. El agregado: unidad de lectura, escritura y atomicidad
  3. La decisión central: embeber o referenciar
  4. Uno a pocos, uno a muchos, uno a muchísimos
  5. Duplicación controlada y cómo mantenerla coherente
  6. Patrón: referencia extendida
  7. Patrón: subconjunto
  8. Patrón: agrupación (bucket)
  9. Patrón: valor atípico (outlier)
  10. Patrón: campo calculado
  11. Anti-patrones que hay que reconocer
  12. Validación de esquema con $jsonSchema
  13. Versionado de documentos y evolución del esquema
  14. Los índices, brevemente
  15. Entregable: el diseño final de las colecciones de BiblioRed
  16. Errores comunes y consejos
  17. Ejercicios
  18. Conclusión

  1. El cambio de mentalidad: del dominio a las consultas

En el módulo 2 seguimos, sin nombrarlo, un método muy concreto:

  1. Identificar las entidades del dominio: sucursales, socios, libros, ejemplares, préstamos.
  2. Darle a cada una su tabla, con sus claves y sus relaciones.
  3. Normalizar para eliminar la redundancia (lo formalizaremos en el módulo 5).
  4. Y después escribir las consultas, con la confianza de que el modelo aguantará cualquier pregunta.

Ese último punto es la gran virtud del modelo relacional: un esquema bien normalizado responde a preguntas que nadie había previsto. El diseño es independiente del uso.

En NoSQL el orden se invierte:

  1. Enumerar las consultas que la aplicación necesita servir, con su frecuencia y su exigencia de latencia.
  2. Diseñar los documentos para que cada consulta frecuente se resuelva con un solo acceso.
  3. Aceptar la duplicación que haga falta para conseguirlo.
  4. Y después comprobar que las entidades del dominio siguen siendo reconocibles.
flowchart LR
    subgraph REL["Modelo relacional"]
        R1["Entidades<br/>del dominio"] --> R2["Tablas<br/>normalizadas"] --> R3["Consultas<br/>(cualquiera)"]
    end
    subgraph DOC["Modelo documental"]
        D1["Consultas<br/>de la aplicación"] --> D2["Documentos<br/>a medida"] --> D3["Entidades<br/>reconocibles"]
    end

Esto tiene una consecuencia incómoda que conviene aceptar cuanto antes: el mismo dominio admite diseños documentales completamente distintos según cómo se consulte. No existe "el modelo correcto de una biblioteca" en MongoDB; existe el modelo correcto para el portal de BiblioRed con estas consultas. Si mañana cambia radicalmente el uso, el modelo puede quedarse obsoleto aunque el dominio no haya cambiado.

Por eso el primer paso del modelado documental no es dibujar entidades, es escribir la lista de consultas. La de BiblioRed, priorizada:

# Consulta Frecuencia Exigencia
C1 Ficha completa de un material con sus datos y sus 5 mejores reseñas Muy alta < 50 ms
C2 Todas las reseñas de un material, paginadas Alta < 100 ms
C3 Reseñas escritas por un socio (su perfil) Media < 200 ms
C4 Buscar materiales por título, autor o etiqueta Muy alta < 100 ms
C5 Publicar una reseña / votar una reseña como útil Media < 100 ms
C6 Registrar un evento de actividad Muy alta (escritura) < 10 ms
C7 Actividad de un socio en un rango de fechas Baja < 1 s
C8 Términos más buscados del mes Baja (informe) < 10 s

Todo lo que viene a continuación es la respuesta a esta tabla.

  1. El agregado: unidad de lectura, escritura y atomicidad

Introdujimos el concepto en la lección 03-01. Ahora lo precisamos, porque es la herramienta principal de trabajo.

Un agregado es un conjunto de datos que cumple las tres condiciones a la vez:

  1. Se lee junto: la aplicación casi nunca necesita una parte sin las demás.
  2. Se escribe junto: los cambios afectan al conjunto de forma coherente.
  3. Tiene una raíz: una entidad principal que da identidad al conjunto y por la que se accede a él.

Y de ahí sale la propiedad que gobierna todo el diseño:

El agregado es la frontera de la atomicidad. En MongoDB, la escritura de un documento es atómica: o se aplica entera o no se aplica. Todo lo que quede dentro del documento se actualiza de una pieza, sin transacción. Todo lo que quede fuera necesita una transacción explícita o quedará expuesto a incoherencias temporales.

Esta frase es el criterio de diseño más útil que te llevas de la lección. Cuando dudes entre meter algo dentro o fuera, pregúntate: ¿necesito que esto cambie de forma atómica junto con lo demás? Si la respuesta es sí, dentro.

Ejemplo en BiblioRed. Una reseña con su texto, su puntuación, sus etiquetas y su recuento de votos es un agregado: cuando un lector edita su reseña, cambia el texto y las etiquetas a la vez, y nadie debe ver el texto nuevo con las etiquetas viejas. En cambio, la reseña y la ficha del material no forman un agregado: la ficha se edita desde el panel del bibliotecario, la reseña desde el portal público, con ritmos y responsables distintos.

  1. La decisión central: embeber o referenciar

Toda la práctica del modelado documental se concentra en esta pregunta: cuando dos entidades están relacionadas, ¿el hijo va dentro del documento del padre o en su propia colección con una referencia?

Embeber

{
  "_id": "MAT-0331",
  "titulo": "El mapa del tiempo",
  "resenas": [
    { "socio_id": 14, "nombre": "Marta Alsina", "puntuacion": 5, "texto": "Una novela que..." },
    { "socio_id": 15, "nombre": "Iván Pereda", "puntuacion": 3, "texto": "Empieza muy bien..." }
  ]
}

Referenciar

{ "_id": "MAT-0331", "titulo": "El mapa del tiempo" }
{ "_id": "RES-1001", "material_id": "MAT-0331", "socio_id": 14, "puntuacion": 5, "texto": "Una novela que..." }
flowchart TD
    subgraph EMB["EMBEBER"]
        E1["Documento MAT-0331<br/>titulo + resenas[ ]<br/>1 lectura, todo junto"]
    end
    subgraph REF["REFERENCIAR"]
        R1["catalogo<br/>MAT-0331"] -.->|material_id| R2["resenas<br/>RES-1001, RES-1002, ...<br/>2 lecturas, crecen sin límite"]
    end

Los seis criterios de decisión

No lo decidas por intuición. Recorre estos seis criterios en orden:

# Criterio Favorece embeber Favorece referenciar
1 Cardinalidad Pocos hijos, con tope conocido Muchos o ilimitados
2 ¿Se consulta el hijo por separado? No: siempre con el padre Sí: tiene vida propia
3 Volatilidad El hijo cambia poco El hijo cambia mucho o crece constantemente
4 Tamaño El conjunto queda muy por debajo de 16 MB Se acerca o lo supera
5 Crecimiento Acotado por naturaleza Ilimitado en el tiempo
6 Atomicidad Debe cambiar junto con el padre Puede cambiar de forma independiente

El criterio 5 merece un aviso especial, porque es el que más sistemas ha roto: todo lo que crece con el tiempo y no tiene tope acaba siendo un problema si está embebido. Las reseñas de un libro popular, los eventos de actividad, los mensajes de un chat, el historial de préstamos. Aunque hoy sean tres, dentro de cinco años serán miles. Y hay un coste que no se ve venir: MongoDB, al actualizar un documento que ha crecido, puede tener que reescribirlo entero en disco. Un documento de 8 MB al que se le añade un elemento de 200 bytes cuesta reescribir 8 MB.

Un criterio auxiliar muy práctico

Si el hijo no tiene sentido sin el padre y desaparece con él, casi siempre va embebido. Una dirección sin su socio no significa nada: dentro. Una reseña, en cambio, tiene identidad propia —se enlaza, se vota, se denuncia, aparece en el perfil de su autor—: fuera.

  1. Uno a pocos, uno a muchos, uno a muchísimos

La forma más rápida de aplicar los seis criterios es clasificar la relación por su cardinalidad. Es la regla práctica más citada del modelado documental y funciona sorprendentemente bien.

Tipo Cardinalidad orientativa Recomendación Ejemplo en BiblioRed
Uno a pocos Hasta ~100, con tope natural Embeber el objeto completo Un material y sus 3–8 etiquetas; un material y sus datos de portada; un socio y sus 2 direcciones
Uno a muchos Cientos o miles, con crecimiento moderado Referenciar, y guardar en el padre un subconjunto de los hijos más relevantes Un material y sus reseñas; un material y sus ejemplares
Uno a muchísimos Decenas de miles o sin límite Referenciar desde el hijo, nunca guardar la lista en el padre Un socio y sus eventos de actividad; un material y sus consultas

La diferencia entre las dos últimas filas es sutil y crucial. En uno a muchos, el padre puede guardar la lista de identificadores de sus hijos, porque cabe. En uno a muchísimos, esa lista sería un array de 40.000 elementos que crece cada día: el enlace debe ir solo en el hijo, que apunta hacia arriba.

Aplicado a BiblioRed, caso por caso:

Material y etiquetas → embeber. Un material tiene entre tres y ocho etiquetas, no crecen sin control, se muestran siempre con la ficha y no se consultan por separado (la búsqueda por etiqueta se resuelve con un índice sobre el array embebido, no con otra colección). Los seis criterios apuntan a embeber.

Material y reseñas → referenciar, con subconjunto. Las reseñas crecen sin tope, se consultan por separado (perfil del socio, moderación), cambian a menudo (votos útiles, ediciones, respuestas) y son la entidad principal de la consulta C2. Van a su propia colección. Pero la consulta C1 —la más frecuente del sistema— quiere la ficha con sus mejores reseñas en un solo acceso, así que el documento del material guarda además una copia de las cinco mejores. Esto es el patrón de subconjunto del apartado 7.

Material y ejemplares → ni una cosa ni otra. Los ejemplares físicos, con su estado y su sucursal, se quedan en PostgreSQL. Están íntimamente ligados a los préstamos, que son transaccionales. El catálogo de MongoDB guarda solo un contador denormalizado de disponibilidad para poder pintar "3 disponibles en Centro" sin consultar la otra base. Reconocer que un dato no debe migrar es también una decisión de modelado.

Socio y actividad → uno a muchísimos. Cada evento es un documento (o mejor, un elemento dentro de un documento de agrupación, apartado 8) que apunta al socio. El documento del socio no guarda ninguna lista de eventos.

  1. Duplicación controlada y cómo mantenerla coherente

En el módulo 2 la redundancia era el enemigo. En el modelado documental es una herramienta. Pero es una herramienta con filo, y hay que cogerla por el mango.

La duplicación se justifica cuando el coste de mantenerla es menor que el coste de reconstruir el dato en cada lectura. Y ese cálculo depende casi siempre de una sola pregunta: ¿con qué frecuencia cambia el dato duplicado, comparado con la frecuencia con que se lee?

Hay tres categorías de dato duplicado, y se tratan de forma distinta:

Categoría A: datos inmutables por naturaleza

No cambian nunca, así que duplicarlos es gratis.

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

El ISBN de un libro y su año de publicación son fijos. Cópialos sin remordimiento.

Categoría B: datos históricos, que deben congelarse

Aquí la duplicación no es una optimización: es corrección semántica.

{
  "_id": "RES-1001",
  "socio": { "socio_id": 14, "nombre_mostrado": "Marta Alsina" },
  "fecha": "2026-03-14T10:25:00Z"
}

Si Marta cambia de apellido en 2027, ¿hay que reescribir sus 34 reseñas? Depende de qué represente el campo:

  • Si es "el nombre que se muestra ahora", sí: hay que propagarlo.
  • Si es "quién firmó esto en marzo de 2026", no: es un dato histórico y reescribirlo sería falsificarlo.

Es la misma distinción que en una factura: el precio del producto en el momento de la venta se copia en la línea de factura y no se toca nunca más, aunque el catálogo cambie mañana. Decidir explícitamente en qué categoría cae cada campo duplicado —y escribirlo en la documentación del proyecto— evita discusiones y errores más adelante.

Categoría C: datos vivos que deben propagarse

Son los peligrosos. El título de un material aparece en sus reseñas; si el bibliotecario lo corrige, hay reseñas con el título viejo.

Tres estrategias, con su coste:

Estrategia Cómo funciona Cuándo usarla
Propagación inmediata Al cambiar el original, un updateMany actualiza todas las copias El dato cambia rara vez y las copias son pocas
Propagación diferida Se encola el cambio y un proceso lo aplica en segundo plano Muchas copias; se tolera un desfase de minutos
Sin propagación, con relectura La copia es solo una pista; la vista crítica relee el original El desfase es inaceptable en algún punto concreto
// Propagación inmediata: el bibliotecario corrige un título con una errata
db.catalogo.updateOne(
  { _id: "MAT-0331" },
  { $set: { titulo: "El mapa del tiempo" } }
)

// ...y las copias en las reseñas se actualizan a continuación
db.resenas.updateMany(
  { material_id: "MAT-0331" },
  { $set: { titulo_material: "El mapa del tiempo" } }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
{ acknowledged: true, matchedCount: 47, modifiedCount: 47 }

Y aquí aparece con toda su crudeza el precio de NoSQL que anunciamos en 03-01: esas dos operaciones no son atómicas entre sí. Si el proceso se cae entre la primera y la segunda, el catálogo tiene el título nuevo y 47 reseñas el viejo. En PostgreSQL este problema sencillamente no existiría, porque el título estaría en un solo sitio.

Las defensas disponibles: envolver ambas escrituras en una transacción de MongoDB (posible desde 2018, con coste de rendimiento), o —más habitual— diseñar el sistema para que el desfase temporal sea tolerable y programar un proceso periódico de reconciliación que detecte y corrija las divergencias.

Regla de oro: duplica solo lo que se muestra, nunca lo que se usa para decidir. Duplicar el título de un material para pintarlo en una lista es razonable. Duplicar su precio o su estado de disponibilidad para tomar una decisión de negocio a partir de la copia, no.

  1. Patrón: referencia extendida

Problema. Referenciar es correcto, pero obliga a una segunda consulta para mostrar cuatro datos del documento referenciado.

Solución. Junto a la referencia, copiar los pocos campos que la vista necesita. Ni todos ni ninguno: los que se pintan.

// SIN el patrón: dos consultas por cada reseña mostrada
db.resenas.find({ material_id: "MAT-0331" })
db.catalogo.findOne({ _id: "MAT-0331" })   // solo para saber el título y la portada

// CON referencia extendida: una sola consulta
db.resenas.findOne({ _id: "RES-1001" })
{
  "_id": "RES-1001",
  "material": {
    "material_id": "MAT-0331",
    "titulo": "El mapa del tiempo",
    "portada": "/img/catalogo/0331-s.webp",
    "tipo": "libro"
  },
  "socio": { "socio_id": 14, "nombre_mostrado": "Marta Alsina" },
  "puntuacion": 5,
  "texto": "Una novela que juega con el tiempo sin marear al lector."
}

Criterio de selección de campos: copia lo que sea estable y se muestre. El título y el tipo son estables; la portada cambia rara vez. No copies la sinopsis (larga y editable) ni el número de ejemplares disponibles (cambia cada préstamo). Para eso está la referencia.

Cuándo aplicarlo: es el patrón más usado y más útil del modelado documental. Cualquier lista que muestre elementos de dos colecciones es candidata.

  1. Patrón: subconjunto

Problema. El documento del material podría contener todas sus reseñas, pero un título popular tiene 800 y la ficha solo muestra las cinco mejores. Embeber 800 documentos para pintar cinco es cargar 160 veces más datos de los necesarios en la consulta más frecuente del sistema.

Solución. Guardar en el padre una copia del subconjunto que la vista necesita, y el conjunto completo en su propia colección.

{
  "_id": "MAT-0331",
  "titulo": "El mapa del tiempo",
  "valoracion": { "media": 4.3, "total_resenas": 812 },
  "resenas_destacadas": [
    { "resena_id": "RES-1001", "nombre": "Marta Alsina", "puntuacion": 5,
      "extracto": "Una novela que juega con el tiempo sin marear al lector.", "votos_utiles": 41 },
    { "resena_id": "RES-1244", "nombre": "Nuria Bastos", "puntuacion": 5,
      "extracto": "La ambientación victoriana está muy cuidada.", "votos_utiles": 33 },
    { "resena_id": "RES-1533", "nombre": "Iván Pereda", "puntuacion": 4,
      "extracto": "Se disfruta más si conoces la novela de Wells.", "votos_utiles": 28 }
  ]
}

Con eso, la consulta C1 —la ficha completa, la más frecuente del portal— es un solo findOne. Quien pulse "ver las 812 reseñas" hará una segunda consulta a resenas, pero eso solo lo hace una fracción de los visitantes.

Cómo se mantiene el subconjunto. Cada vez que una reseña sube de votos, se comprueba si debería entrar en el destacado:

// Recalcular el destacado de un material tras un cambio en sus reseñas
const top = db.resenas.find(
  { material_id: "MAT-0331", estado: "publicada", spoiler: false },
  { _id: 1, "socio.nombre_mostrado": 1, puntuacion: 1, texto: 1, votos_utiles: 1 }
).sort({ votos_utiles: -1 }).limit(3).toArray()

db.catalogo.updateOne(
  { _id: "MAT-0331" },
  { $set: { resenas_destacadas: top.map(r => ({
      resena_id: r._id,
      nombre: r.socio.nombre_mostrado,
      puntuacion: r.puntuacion,
      extracto: r.texto.substring(0, 140),
      votos_utiles: r.votos_utiles
  })) } }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }

Este recálculo no tiene por qué ser inmediato: puede ejecutarse cada pocos minutos en segundo plano. Que una reseña tarde diez minutos en aparecer en el destacado no molesta a nadie, y a cambio se evita ejecutar el recálculo en cada voto.

Cuándo aplicarlo: siempre que la vista principal necesite solo los N primeros de una colección grande.

  1. Patrón: agrupación (bucket)

Problema. El registro de actividad genera 17 millones de eventos al año. Un documento por evento significa 17 millones de documentos diminutos, cada uno con la sobrecarga de su _id, su entrada de índice y sus metadatos internos. En muchos casos, la sobrecarga pesa más que el dato.

Solución. Agrupar los eventos de una misma entidad y un mismo periodo en un solo documento contenedor.

{
  "_id": "ACT-14-2026-08-02",
  "socio_id": 14,
  "dia": "2026-08-02",
  "sucursal_id": 1,
  "num_eventos": 4,
  "primer_evento": "2026-08-02T09:14:02Z",
  "ultimo_evento": "2026-08-02T09:31:55Z",
  "eventos": [
    { "t": "2026-08-02T09:14:02Z", "tipo": "busqueda", "termino": "julio verne" },
    { "t": "2026-08-02T09:14:31Z", "tipo": "ficha", "material_id": "MAT-0331" },
    { "t": "2026-08-02T09:22:10Z", "tipo": "filtro", "campo": "idioma", "valor": "ca" },
    { "t": "2026-08-02T09:31:55Z", "tipo": "ficha", "material_id": "MAT-0412" }
  ]
}

La inserción de un evento se convierte en una única operación que crea el contenedor si no existe y añade el evento si ya existe:

db.actividad.updateOne(
  { _id: "ACT-14-2026-08-02" },
  {
    $push: { eventos: { t: new Date(), tipo: "ficha", material_id: "MAT-0508" } },
    $inc:  { num_eventos: 1 },
    $max:  { ultimo_evento: new Date() },
    $setOnInsert: { socio_id: 14, dia: "2026-08-02", sucursal_id: 1 }
  },
  { upsert: true }
)
{
  acknowledged: true,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedId: null
}

upsert: true es la clave: si el documento del día no existe, se crea; si existe, se actualiza. $setOnInsert pone los campos fijos solo en la creación, para no reescribirlos cada vez.

Las cifras del cambio, con las estimaciones de BiblioRed:

Un documento por evento Con agrupación por socio y día
Documentos al año ~17.000.000 ~624.000 (12.000 socios × ~52 días activos)
Entradas de índice por evento 1 o más ~0,04
Espacio de sobrecarga Muy alto Bajo
"Actividad del socio 14 el 2 de agosto" Buscar N documentos Un findOne
Escritura de un evento insertOne updateOne con upsert

Cómo elegir el tamaño del contenedor. Por socio y día es lo natural en BiblioRed. Pero un usuario muy intensivo podría generar cientos de eventos diarios, así que hay que poner un tope: cuando un contenedor llega a, por ejemplo, 500 eventos, se abre otro (ACT-14-2026-08-02-2). Un contenedor sin tope se convierte en un array ilimitado, que es justo el anti-patrón del apartado siguiente.

Cuándo aplicarlo: series de eventos, telemetría, mediciones, registros de auditoría. Es la respuesta documental al problema que en 03-02 le adjudicamos a Cassandra, y la razón de que BiblioRed pueda resolverlo sin desplegar otra base.

  1. Patrón: valor atípico (outlier)

Problema. El 99,8 % de los materiales de BiblioRed tiene menos de 50 reseñas, y para ellos embeberlas sería perfecto. Pero tres o cuatro superventas tienen miles. Si se diseña para el caso extremo, se penaliza a los 39.996 materiales normales; si se diseña para el caso normal, los cuatro extremos rompen el sistema.

Solución. Diseñar para el caso común y marcar los excepcionales con una bandera que active un camino alternativo.

{
  "_id": "MAT-0412",
  "titulo": "Los pilares de la Tierra",
  "resenas": [ "...las primeras 50, embebidas..." ],
  "resenas_desbordadas": true,
  "total_resenas": 1240
}
// La aplicación consulta según la bandera
const mat = db.catalogo.findOne({ _id: "MAT-0412" })

let resenas = mat.resenas
if (mat.resenas_desbordadas) {
  resenas = db.resenas.find({ material_id: mat._id })
                      .sort({ votos_utiles: -1 }).limit(50).toArray()
}

Coste: la aplicación tiene dos caminos de lectura, y eso es complejidad real que hay que documentar y probar. Por eso este patrón se aplica solo cuando la distribución es realmente asimétrica y el caso extremo es una minoría diminuta. Si el 20 % de los materiales desborda, no hay valor atípico: hay un mal diseño y toca referenciar para todos.

  1. Patrón: campo calculado

Problema. La ficha de un material muestra su puntuación media. Calcularla en cada visita significa recorrer sus 812 reseñas con una agregación, para una página que se pide miles de veces al día.

Solución. Guardar el resultado ya calculado en el documento y actualizarlo cuando cambien los datos de origen.

{
  "_id": "MAT-0331",
  "titulo": "El mapa del tiempo",
  "valoracion": {
    "media": 4.3,
    "total_resenas": 812,
    "suma_puntuaciones": 3492,
    "distribucion": { "1": 12, "2": 31, "3": 88, "4": 264, "5": 417 },
    "actualizado": "2026-08-02T09:00:00Z"
  }
}

Fíjate en suma_puntuaciones: guardar la suma además de la media permite actualizarla incrementalmente, sin recorrer nada.

// Llega una reseña nueva con puntuación 5
db.catalogo.updateOne(
  { _id: "MAT-0331" },
  {
    $inc: {
      "valoracion.total_resenas": 1,
      "valoracion.suma_puntuaciones": 5,
      "valoracion.distribucion.5": 1
    },
    $currentDate: { "valoracion.actualizado": true }
  }
)

// La media se recalcula a partir de dos números, no de 813 documentos
db.catalogo.updateOne(
  { _id: "MAT-0331" },
  [ { $set: { "valoracion.media": {
        $round: [ { $divide: ["$valoracion.suma_puntuaciones", "$valoracion.total_resenas"] }, 2 ] } } } ]
)

db.catalogo.findOne({ _id: "MAT-0331" }, { _id: 0, valoracion: 1 })
{
  valoracion: {
    media: 4.3,
    total_resenas: 813,
    suma_puntuaciones: 3497,
    distribucion: { '1': 12, '2': 31, '3': 88, '4': 264, '5': 418 },
    actualizado: ISODate('2026-08-02T09:47:22.108Z')
  }
}

Riesgo: el campo calculado puede desviarse de la realidad si alguna escritura falla o si se borra una reseña sin descontarla. Defensa habitual: un proceso nocturno que recalcula desde cero y corrige. Un contador que solo sube nunca vuelve solo a su sitio.

Cuándo aplicarlo: cuando la relación lecturas/escrituras es muy alta. Aquí es de miles a uno; el patrón se paga solo.

  1. Anti-patrones que hay que reconocer

11.1 Arrays sin cota

El anti-patrón número uno, y el más fácil de cometer.

{
  "_id": "MAT-0412",
  "titulo": "Los pilares de la Tierra",
  "consultas": [ "...41.238 elementos y subiendo..." ]
}

Qué pasa, en orden de aparición: el documento crece hasta acercarse al límite de 16 MB; cada $push obliga a reescribir un documento cada vez mayor; las lecturas transfieren megabytes para usar dos campos; los índices sobre el array se disparan de tamaño; y un día una escritura falla con BSONObjectTooLarge y no hay arreglo rápido.

Señal de alarma: si no puedes decir el número máximo de elementos que tendrá un array, no lo embebas. Referencia o agrupa.

11.2 Documentos gigantes

Aunque no haya arrays ilimitados, un documento puede engordar por acumulación: la sinopsis completa, la portada en base64, el texto extraído del PDF, el histórico de cambios... todo dentro de la ficha del material.

Consecuencia: cada lectura de la ficha —para mostrar el título y la portada en miniatura— transfiere el documento entero desde el disco a la memoria y de ahí a la red. La caché del servidor se llena de datos que nadie mira, y se desalojan documentos que sí se usan.

Regla: los datos grandes que se consultan rara vez van a su propia colección, o directamente fuera de la base de datos —las imágenes, a un almacén de objetos o a un sistema de ficheros, con la URL en el documento—.

11.3 Colecciones masivas de documentos diminutos

El extremo contrario: 17 millones de documentos de 80 bytes. La sobrecarga por documento (identificador, entradas de índice, metadatos) supera al dato útil, los índices no caben en memoria y las consultas por rango obligan a leer millones de documentos dispersos.

Solución: el patrón de agrupación del apartado 8.

11.4 Usar MongoDB como si fuera relacional

Es el anti-patrón más caro porque no da la cara: el sistema funciona, simplemente funciona peor de lo que funcionaría PostgreSQL.

// Cinco colecciones normalizadas y una tubería que las cose con $lookup
db.resenas.aggregate([
  { $lookup: { from: "socios",    localField: "socio_id",    foreignField: "_id", as: "socio" } },
  { $lookup: { from: "catalogo",  localField: "material_id", foreignField: "_id", as: "material" } },
  { $lookup: { from: "autores",   localField: "material.autor_id", foreignField: "_id", as: "autor" } },
  { $lookup: { from: "etiquetas", localField: "etiqueta_ids", foreignField: "_id", as: "etiquetas" } },
  { $unwind: "$socio" }, { $unwind: "$material" }
])

Ese código es un esquema relacional escrito en MongoDB, y hereda lo peor de los dos mundos: la lentitud de unir sin las optimizaciones de un planificador relacional maduro, y la falta de integridad referencial de la base documental. Si tu diseño acaba aquí, la conclusión correcta no es "hay que optimizar la tubería": es "este dominio quería PostgreSQL".

$lookup es legítimo para informes ocasionales y procesos por lotes. No lo es como mecanismo habitual de la ruta de lectura principal.

Anti-patrón Síntoma que verás Corrección
Array sin cota Documentos que crecen sin parar; escrituras lentas Referenciar o agrupar
Documento gigante Lecturas que transfieren mucho para usar poco Sacar los datos grandes
Documentos diminutos masivos Índices enormes, consultas por rango lentas Patrón de agrupación
Relacional disfrazado $lookup en todas las consultas Rediseñar o volver a SQL

  1. Validación de esquema con $jsonSchema

El esquema flexible es una ventaja mientras el equipo es disciplinado. MongoDB permite recuperar parte de la red de seguridad de forma voluntaria y gradual, que es justo lo que se necesita: reglas fuertes donde importan, libertad donde conviene.

db.createCollection("resenas", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["material_id", "socio", "puntuacion", "texto", "fecha", "estado", "esquema_v"],
      properties: {
        esquema_v:   { bsonType: "int", minimum: 1, description: "versión del documento" },
        material_id: { bsonType: "string", pattern: "^MAT-[0-9]{4}$" },
        socio: {
          bsonType: "object",
          required: ["socio_id", "nombre_mostrado"],
          properties: {
            socio_id:        { bsonType: "int", minimum: 1 },
            nombre_mostrado: { bsonType: "string", maxLength: 80 }
          }
        },
        puntuacion: { bsonType: "int", minimum: 1, maximum: 5 },
        texto:      { bsonType: "string", minLength: 10, maxLength: 4000 },
        etiquetas:  { bsonType: "array", maxItems: 10, items: { bsonType: "string" } },
        estado:     { enum: ["publicada", "pendiente", "oculta", "denunciada"] },
        fecha:      { bsonType: "date" }
      }
    }
  },
  validationLevel: "moderate",
  validationAction: "error"
})

Prueba de que funciona:

db.resenas.insertOne({
  material_id: "MAT-0331",
  socio: { socio_id: 14, nombre_mostrado: "Marta Alsina" },
  puntuacion: 9,                       // fuera del rango 1–5
  texto: "Corta",                      // menos de 10 caracteres
  fecha: "2026-08-02",                 // cadena, no fecha
  estado: "publicada",
  esquema_v: 1
})
MongoServerError: Document failed validation

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

Tres errores capturados en la escritura, que es exactamente donde queríamos. El de la fecha como cadena es el más valioso: es el error de la lección 03-01 que arruina toda agregación posterior, y aquí no llega ni a entrar.

Los dos parámetros de comportamiento:

Parámetro Valor Efecto
validationLevel strict Valida todas las inserciones y todas las actualizaciones
moderate Valida las inserciones y solo las actualizaciones de documentos que ya cumplían
off No valida
validationAction error Rechaza la escritura
warn La acepta y anota un aviso en el registro

Estrategia recomendada para una colección que ya tiene datos: empezar con validationAction: "warn" para descubrir cuántos documentos incumplirían sin romper nada, corregirlos, y solo entonces pasar a error. Y usar moderate mientras queden documentos antiguos por migrar, para no bloquear las actualizaciones de lo que aún no cumple.

Qué no hace $jsonSchema, para no crear falsas expectativas: no comprueba que material_id apunte a un material existente. La integridad referencial sigue sin existir. Valida forma, no referencias.

  1. Versionado de documentos y evolución del esquema

En una base relacional, cambiar la forma de los datos es un ALTER TABLE que afecta a todas las filas a la vez, con su bloqueo y su ventana. En una documental hay una alternativa mucho más cómoda: no migrar nada y convivir con varias versiones.

La técnica es sencilla y consiste en un campo:

{ "_id": "RES-0450", "esquema_v": 1, "puntuacion": 5, "texto": "..." }
{ "_id": "RES-1001", "esquema_v": 2, "puntuacion": 5, "texto": "...",
  "etiquetas": ["novela histórica"], "votos_utiles": 41, "spoiler": false }

La aplicación lee el campo y sabe qué esperar:

function normalizarResena(doc) {
  if (doc.esquema_v === 1) {
    return { ...doc, etiquetas: [], votos_utiles: 0, spoiler: false, esquema_v: 2 }
  }
  return doc
}

Tres estrategias de migración, en orden de agresividad:

Estrategia Cómo funciona Cuándo
Perezosa (lazy) El documento se actualiza a la versión nueva la próxima vez que se escribe Lo habitual: coste cero, migración progresiva
En segundo plano Un proceso recorre la colección por lotes y va actualizando Cuando conviene terminar en un plazo, sin parar el servicio
Masiva Un updateMany sobre toda la colección Solo si son pocos documentos o hay ventana de parada
// Migración perezosa: al añadir un campo nuevo, se actualiza la versión
db.resenas.updateOne(
  { _id: "RES-0450" },
  { $set: { etiquetas: [], votos_utiles: 0, spoiler: false, esquema_v: 2 } }
)

// Migración en segundo plano, por lotes de 1000
db.resenas.updateMany(
  { esquema_v: 1 },
  { $set: { etiquetas: [], votos_utiles: 0, spoiler: false, esquema_v: 2 } }
)
{ acknowledged: true, matchedCount: 12483, modifiedCount: 12483 }
// Comprobar cómo va la migración
db.resenas.aggregate([ { $group: { _id: "$esquema_v", n: { $sum: 1 } } }, { $sort: { _id: 1 } } ])
[ { _id: 2, n: 12483 } ]

La advertencia imprescindible: convivir con varias versiones tiene un coste, y es el código que las gestiona. Si el equipo acumula seis versiones activas, la función de normalización se convierte en un laberinto de condicionales que nadie se atreve a tocar. Convive con dos, quizá tres, y cierra las migraciones: cuando el recuento de una versión antigua llega a cero, borra su rama de código.

  1. Los índices, brevemente

Todo lo dicho sobre modelado supone que las consultas se resuelven eficientemente, y eso depende de los índices tanto en MongoDB como en PostgreSQL.

La buena noticia es que la lógica es la misma que aprenderás en la lección 06-03: un índice es una estructura auxiliar —normalmente un árbol B— que evita recorrer todos los datos; acelera las lecturas, ralentiza las escrituras, ocupa espacio, y el orden de los campos en un índice compuesto determina qué consultas puede servir.

Las particularidades documentales, en cuatro líneas:

  • Se puede indexar cualquier campo, aunque esté anidado: db.resenas.createIndex({ "socio.socio_id": 1 }).
  • Se puede indexar el contenido de un array (índice multiclave): una entrada por elemento. Es lo que hace rápida la búsqueda por etiqueta.
  • El índice TTL borra documentos automáticamente pasado un tiempo, y es lo que BiblioRed usará para caducar la actividad a los dos años.
  • explain() es el equivalente de EXPLAIN en SQL y dice si la consulta ha usado un índice o ha recorrido la colección entera.
db.resenas.createIndex({ material_id: 1, votos_utiles: -1 })
db.resenas.createIndex({ etiquetas: 1 })
db.actividad.createIndex({ dia: 1 }, { expireAfterSeconds: 63072000 })  // 2 años
material_id_1_votos_utiles_-1
etiquetas_1
dia_1

No profundizamos más: los índices tienen su lección propia en el módulo 6.

  1. Entregable: el diseño final de las colecciones de BiblioRed

Aquí está el resultado de aplicar todo lo anterior. Tres colecciones en la base bibliored de MongoDB, cada decisión justificada.

15.1 Colección catalogo

Raíz del agregado: el material. Responde a: C1 (ficha completa) y C4 (búsqueda).

{
  "_id": "MAT-0331",
  "esquema_v": 2,
  "tipo": "libro",
  "titulo": "El mapa del tiempo",
  "titulo_normalizado": "el mapa del tiempo",
  "idioma": "es",
  "anio_publicacion": 2008,
  "sinopsis": "En el Londres de 1896, un joven aristócrata busca la manera de viajar al pasado...",
  "portada": { "grande": "/img/catalogo/0331-g.webp", "miniatura": "/img/catalogo/0331-s.webp" },
  "autores": [
    { "autor_id": 77, "nombre_completo": "Félix J. Palma", "rol": "autor" }
  ],
  "etiquetas": ["novela histórica", "ciencia ficción", "victoriano", "premiado"],
  "metadatos": {
    "isbn": "9788401339097",
    "editorial": "Ediciones Vallmar",
    "paginas": 612,
    "encuadernacion": "tapa dura"
  },
  "disponibilidad": {
    "total_ejemplares": 6,
    "por_sucursal": { "1": 2, "2": 1, "3": 2, "4": 1 },
    "actualizado": "2026-08-02T06:00:00Z"
  },
  "valoracion": {
    "media": 4.3,
    "total_resenas": 812,
    "suma_puntuaciones": 3492,
    "distribucion": { "1": 12, "2": 31, "3": 88, "4": 264, "5": 417 }
  },
  "resenas_destacadas": [
    { "resena_id": "RES-1001", "nombre": "Marta Alsina", "puntuacion": 5,
      "extracto": "Una novela que juega con el tiempo sin marear al lector.", "votos_utiles": 41 },
    { "resena_id": "RES-1244", "nombre": "Nuria Bastos", "puntuacion": 5,
      "extracto": "La ambientación victoriana está muy cuidada.", "votos_utiles": 33 }
  ],
  "alta": "2024-11-03T10:00:00Z",
  "activo": true
}

Y un documento de otro tipo, en la misma colección, para que se vea la heterogeneidad:

{
  "_id": "MAT-0802",
  "esquema_v": 2,
  "tipo": "revista",
  "titulo": "Vallmar Cultural",
  "titulo_normalizado": "vallmar cultural",
  "idioma": "ca",
  "anio_publicacion": 2026,
  "portada": { "miniatura": "/img/catalogo/0802-s.webp" },
  "etiquetas": ["cultura local", "hemeroteca"],
  "metadatos": {
    "issn": "2604-1188",
    "numero": 42,
    "volumen": 7,
    "periodicidad": "mensual"
  },
  "disponibilidad": { "total_ejemplares": 4, "por_sucursal": { "1": 1, "2": 1, "3": 1, "4": 1 } },
  "valoracion": { "media": 0, "total_resenas": 0, "suma_puntuaciones": 0 },
  "resenas_destacadas": [],
  "alta": "2026-05-11T09:04:00Z",
  "activo": true
}
Decisión Justificación
_id natural "MAT-0331" Legible en registros y URL; ahorra un índice adicional
metadatos como subdocumento libre Aísla lo específico de cada tipo de material; añadir "cómic" en 2027 no toca el resto del documento
autores embebido con nombre Uno a pocos + referencia extendida: la ficha muestra el nombre sin ir a otra colección
etiquetas como array de cadenas Uno a pocos con tope; índice multiclave resuelve C4
resenas_destacadas Patrón de subconjunto: C1 se resuelve con un solo findOne
valoracion con suma y distribución Patrón de campo calculado, actualizable con $inc sin recorrer nada
disponibilidad denormalizada Copia de solo lectura de PostgreSQL, refrescada cada hora; la verdad sigue en ejemplares
titulo_normalizado Sin tildes ni mayúsculas, para búsquedas insensibles a acentos
Sinopsis embebida Es texto de unos pocos kilobytes y se muestra en la misma ficha: no justifica otra colección
Portadas como rutas, no como binarios Anti-patrón de documento gigante evitado: las imágenes viven fuera

Índices: { titulo_normalizado: 1 }, { etiquetas: 1 }, { tipo: 1, "valoracion.media": -1 }, { "autores.autor_id": 1 }.

15.2 Colección resenas

Raíz del agregado: la reseña. Responde a: C2, C3 y C5.

{
  "_id": "RES-1001",
  "esquema_v": 2,
  "material": {
    "material_id": "MAT-0331",
    "titulo": "El mapa del tiempo",
    "tipo": "libro",
    "portada": "/img/catalogo/0331-s.webp"
  },
  "socio": {
    "socio_id": 14,
    "nombre_mostrado": "Marta Alsina",
    "sucursal_id": 1
  },
  "puntuacion": 5,
  "texto": "Una novela que juega con el tiempo sin marear al lector. La ambientación victoriana está muy cuidada y los tres actos se sostienen solos.",
  "etiquetas": ["novela histórica", "ciencia ficción"],
  "spoiler": false,
  "estado": "publicada",
  "votos_utiles": 41,
  "votantes": [15, 16, 22, 31],
  "respuesta_bibliotecario": {
    "sucursal_id": 1,
    "texto": "Si te ha gustado, en la sucursal Centro tenemos la continuación disponible.",
    "fecha": "2026-03-16T09:10:00Z"
  },
  "fecha": "2026-03-14T10:25:00Z",
  "editada": null
}
Decisión Justificación
Colección propia, no embebida en catalogo Uno a muchos sin tope, con vida propia (perfil, moderación) y alta volatilidad
material como referencia extendida Cuatro campos estables permiten pintar el perfil del socio (C3) sin consultar catalogo
socio.nombre_mostrado duplicado Categoría B: es "quién firmó esto", dato histórico congelado a propósito
votos_utiles + votantes embebidos Un voto debe cambiar contador y lista atómicamente: van dentro del agregado
votantes con solo el socio_id Array acotado en la práctica; si un día desbordara, pasaría a colección propia (valor atípico)
respuesta_bibliotecario como subdocumento opcional Cero o una por reseña; el campo simplemente no existe si no la hay
estado con valores cerrados Permite moderar sin borrar; validado con enum en $jsonSchema
etiquetas embebidas Uno a pocos con tope de 10 impuesto por la validación

Índices: { "material.material_id": 1, votos_utiles: -1 }, { "socio.socio_id": 1, fecha: -1 }, { estado: 1, fecha: -1 }, { etiquetas: 1 }.

15.3 Colección actividad

Raíz del agregado: el conjunto de eventos de un socio en un día. Responde a: C6, C7 y C8.

{
  "_id": "ACT-14-2026-08-02",
  "esquema_v": 1,
  "socio_id": 14,
  "dia": "2026-08-02T00:00:00Z",
  "sucursal_id": 1,
  "num_eventos": 5,
  "primer_evento": "2026-08-02T09:14:02Z",
  "ultimo_evento": "2026-08-02T09:41:07Z",
  "eventos": [
    { "t": "2026-08-02T09:14:02Z", "tipo": "busqueda", "termino": "julio verne", "resultados": 7 },
    { "t": "2026-08-02T09:14:31Z", "tipo": "ficha",    "material_id": "MAT-0331" },
    { "t": "2026-08-02T09:22:10Z", "tipo": "filtro",   "campo": "idioma", "valor": "ca" },
    { "t": "2026-08-02T09:31:55Z", "tipo": "ficha",    "material_id": "MAT-0412" },
    { "t": "2026-08-02T09:41:07Z", "tipo": "reserva",  "material_id": "MAT-0412" }
  ]
}
Decisión Justificación
Patrón de agrupación por socio y día De ~17 M documentos anuales a ~624 K; C7 se resuelve con un findOne
_id compuesto "ACT-<socio>-<fecha>" Determinista: permite el upsert sin buscar antes
Nombres de campo cortos (t) dentro de eventos En un array de miles de elementos, los nombres de campo se repiten en cada uno y pesan
Contadores num_eventos, primer_evento, ultimo_evento Responden sin abrir el array
Tope de 500 eventos por contenedor Impide el anti-patrón de array sin cota; al superarlo se abre -2
sucursal_id copiado del socio Permite el informe por sucursal sin consultar PostgreSQL
Índice TTL sobre dia a 2 años La actividad antigua se borra sola; caducidad como propiedad del dato
Sin referencia a resenas ni catalogo La actividad es un registro inmutable: no necesita coherencia con nada

Índices: { socio_id: 1, dia: -1 }, { dia: 1 } con expireAfterSeconds: 63072000, { "eventos.material_id": 1 }.

15.4 El mapa completo

flowchart TD
    subgraph PG["PostgreSQL — biblioredb (la verdad transaccional)"]
        T["sucursales · socios · autores · libros<br/>ejemplares · prestamos · reservas"]
    end
    subgraph MG["MongoDB — bibliored (contenido y actividad)"]
        C["catalogo<br/>_id MAT-nnnn"]
        R["resenas<br/>_id RES-nnnn"]
        A["actividad<br/>_id ACT-socio-fecha"]
    end
    T -->|sincronización horaria:<br/>disponibilidad| C
    R -->|patrón subconjunto:<br/>resenas_destacadas| C
    R -.->|referencia extendida:<br/>material_id| C
    A -.->|referencia:<br/>material_id, socio_id| C

Fíjate en el estilo de las flechas: las continuas son copias de datos que hay que mantener coherentes; las discontinuas son referencias que la aplicación resuelve cuando hace falta. Cada flecha continua es una responsabilidad de coherencia que alguien debe asumir en el código, y por eso conviene que sean pocas y estén documentadas. Aquí hay exactamente dos.

Errores Comunes y Consejos

Error 1: empezar dibujando entidades. Es el reflejo que traemos del modelo relacional y aquí conduce a colecciones normalizadas cosidas con $lookup. Empieza siempre por la lista de consultas.

Error 2: embeber "porque es lo que se hace en MongoDB". Embeber es correcto para uno a pocos. Para uno a muchos y uno a muchísimos es una bomba de relojería que estalla en producción, no en desarrollo.

Error 3: duplicar sin decidir la categoría del dato. Cada campo duplicado debe estar clasificado: inmutable, histórico congelado o vivo con propagación. Si no lo escribes, en seis meses nadie sabrá si hay que actualizarlo.

Error 4: confiar en que el esquema flexible se documenta solo. No se documenta. Escribe el $jsonSchema aunque lo pongas en warn: es documentación ejecutable del contrato de la colección.

Error 5: probar el diseño solo con datos de juguete. Un diseño con 200 documentos siempre parece bueno. Genera 500.000 documentos sintéticos con la distribución real esperada —incluidos los valores atípicos— y mide antes de dar por bueno nada.

Error 6: olvidar que la aplicación es ahora la responsable. No hay FOREIGN KEY que impida una reseña sobre un material inexistente. Si el borrado de un material debe arrastrar sus reseñas, ese CASCADE lo escribes tú.

Consejo 1: escribe la ficha de decisión de cada colección. Raíz del agregado, consultas que sirve, qué se embebe y por qué, qué se duplica y de qué categoría es, índices. Media página por colección que ahorra semanas.

Consejo 2: nombra los campos de forma consistente y corta donde se repiten mucho. Dentro de un array de miles de elementos, t en lugar de timestamp ahorra megabytes reales. Fuera de ahí, prioriza la legibilidad.

Consejo 3: pon el campo esquema_v desde el primer documento. Cuesta cuatro bytes y el día que lo necesites —y lo necesitarás— te ahorrará una migración masiva a ciegas.

Consejo 4: revisa el diseño cuando cambien las consultas, no cuando cambie el dominio. Es el corolario del apartado 1 y el aviso más importante de la lección: en el mundo documental, un cambio en cómo se usan los datos puede obligar a rediseñar aunque los datos sean los mismos.

Ejercicios

Ejercicio 1

BiblioRed quiere añadir clubes de lectura: grupos con nombre, sucursal de reunión, entre 8 y 25 socios miembros, un libro asignado cada mes y un hilo de comentarios por cada libro leído (unos 30–60 comentarios por libro, y el club puede llevar años en marcha). Decide para cada relación si embebes o referencias, justificándolo con los seis criterios del apartado 3, y escribe el documento de ejemplo de la colección clubes.

Ejercicio 2

Este documento tiene cuatro problemas de diseño. Identifícalos, di qué anti-patrón o mal criterio representa cada uno y propón el diseño corregido.

{
  "_id": ObjectId("..."),
  "socio_id": 14,
  "nombre": "Marta Alsina",
  "email": "[email protected]",
  "foto_perfil_base64": "iVBORw0KGgoAAAANSUhEUgAAB...(1,8 MB)...",
  "historial_prestamos": [ "...318 préstamos desde 2019, uno por elemento..." ],
  "eventos_navegacion": [ "...11.402 eventos, uno por elemento..." ],
  "resenas_escritas": [ "...34 reseñas con su texto completo duplicado..." ]
}

Ejercicio 3

Escribe la validación $jsonSchema de la colección actividad diseñada en el apartado 15.3, exigiendo: socio_id entero positivo obligatorio; dia de tipo fecha obligatorio; num_eventos entero entre 0 y 500; eventos array obligatorio de máximo 500 elementos, donde cada elemento tenga obligatoriamente t (fecha) y tipo (uno de busqueda, ficha, filtro, reserva, descarga). Después explica por qué convendría desplegarla con validationAction: "warn" antes que con "error".

Soluciones

Solución 1

Club y sus miembros → embeber. Cardinalidad de 8 a 25, con tope natural impuesto por el propio reglamento del club (criterio 1: pocos). Se muestran siempre con la ficha del club y no se consultan por separado (criterio 2). Cambian poco: alguien entra o sale unas cuantas veces al año (criterio 3). Ocupan unos pocos kilobytes (criterio 4). El crecimiento está acotado por el máximo de plazas (criterio 5). Y las altas y bajas deben ser coherentes con el recuento de plazas libres, lo que favorece la atomicidad del mismo documento (criterio 6). Seis de seis a favor de embeber, con referencia extendida al nombre del socio.

Club y su calendario de lecturas → embeber, pero vigilando. Doce libros al año. A los cinco años son sesenta elementos: sigue siendo uno a pocos y cabe de sobra. Se muestra entero en la ficha del club ("qué hemos leído"). Se embebe, y se deja anotado que si un club superara los ~200 elementos convendría separar el histórico antiguo.

Club y comentarios de cada libro → referenciar. Aquí el criterio 5 manda: 40 comentarios por libro × 12 libros al año × varios años es crecimiento sin tope (criterio 1 y 5). Además tienen vida propia —se moderan, se responden, se enlazan— (criterio 2), cambian con frecuencia (criterio 3) y no necesitan cambiar atómicamente con el club (criterio 6). Colección propia comentarios_club, con el subconjunto de los tres últimos embebido en el club para poder mostrar "última actividad" sin una segunda consulta.

{
  "_id": "CLUB-007",
  "esquema_v": 1,
  "nombre": "Los martes de Vallmar",
  "sucursal_id": 3,
  "dia_reunion": "martes",
  "hora_reunion": "19:00",
  "plazas": 25,
  "activo": true,
  "miembros": [
    { "socio_id": 14, "nombre_mostrado": "Marta Alsina", "alta": "2025-09-02T00:00:00Z", "rol": "coordinadora" },
    { "socio_id": 16, "nombre_mostrado": "Nuria Bastos", "alta": "2025-10-07T00:00:00Z", "rol": "miembro" },
    { "socio_id": 15, "nombre_mostrado": "Iván Pereda",  "alta": "2026-01-13T00:00:00Z", "rol": "miembro" }
  ],
  "num_miembros": 3,
  "calendario": [
    { "mes": "2026-06", "material_id": "MAT-0331", "titulo": "El mapa del tiempo",       "estado": "leido" },
    { "mes": "2026-07", "material_id": "MAT-0412", "titulo": "Los pilares de la Tierra", "estado": "leido" },
    { "mes": "2026-08", "material_id": "MAT-0508", "titulo": "La sombra del faro",       "estado": "en curso" }
  ],
  "ultimos_comentarios": [
    { "comentario_id": "COM-4471", "socio_id": 16, "nombre_mostrado": "Nuria Bastos",
      "material_id": "MAT-0412", "extracto": "El capítulo de la catedral merece releerse.",
      "fecha": "2026-07-28T20:14:00Z" }
  ],
  "total_comentarios": 187,
  "creado": "2025-09-02T00:00:00Z"
}

Solución 2

# Problema Anti-patrón o criterio incumplido Corrección
1 foto_perfil_base64 de 1,8 MB dentro del documento Documento gigante: cada lectura del socio, aunque solo se quiera el nombre, transfiere 1,8 MB y desaloja la caché Guardar la imagen en un almacén de objetos o en el sistema de ficheros y dejar solo la ruta: "foto": "/img/socios/14.webp"
2 historial_prestamos con 318 elementos y creciendo Array sin cota + dato que no debería estar aquí: los préstamos son transaccionales y viven en PostgreSQL Eliminarlo del documento. Si se necesita un resumen para el perfil, un campo calculado: "estadisticas": { "total_prestamos": 318, "ultimo": "2026-07-19" }
3 eventos_navegacion con 11.402 elementos Array sin cota en su forma más grave: crece cada día y sin límite. Es uno a muchísimos Colección actividad con patrón de agrupación (apartado 15.3). El documento del socio no guarda ninguna lista
4 resenas_escritas con el texto completo duplicado Duplicación de categoría C mal aplicada: el texto es voluminoso, editable y ya vive en resenas; toda edición obligaría a actualizar dos sitios Referenciar. Si el perfil necesita mostrar las últimas, aplicar subconjunto con solo resena_id, título del material, puntuación y extracto
{
  "_id": 14,
  "esquema_v": 2,
  "nombre_mostrado": "Marta Alsina",
  "email": "[email protected]",
  "sucursal_id": 1,
  "foto": "/img/socios/14.webp",
  "preferencias": { "idioma": "es", "avisos_email": true },
  "estadisticas": {
    "total_prestamos": 318,
    "total_resenas": 34,
    "ultimo_prestamo": "2026-07-19T00:00:00Z",
    "actualizado": "2026-08-02T06:00:00Z"
  },
  "resenas_recientes": [
    { "resena_id": "RES-1001", "material_id": "MAT-0331", "titulo": "El mapa del tiempo",
      "puntuacion": 5, "extracto": "Una novela que juega con el tiempo sin marear...",
      "fecha": "2026-03-14T10:25:00Z" }
  ]
}

Un detalle deliberado: _id es 14, el mismo identificador que el socio_id de PostgreSQL. Cuando una entidad existe en las dos bases, compartir el identificador es la decisión que menos disgustos da.

Solución 3

db.runCommand({
  collMod: "actividad",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["socio_id", "dia", "num_eventos", "eventos", "esquema_v"],
      properties: {
        esquema_v:   { bsonType: "int", minimum: 1 },
        socio_id:    { bsonType: "int", minimum: 1 },
        dia:         { bsonType: "date" },
        sucursal_id: { bsonType: "int", minimum: 1, maximum: 4 },
        num_eventos: { bsonType: "int", minimum: 0, maximum: 500 },
        eventos: {
          bsonType: "array",
          maxItems: 500,
          items: {
            bsonType: "object",
            required: ["t", "tipo"],
            properties: {
              t:           { bsonType: "date" },
              tipo:        { enum: ["busqueda", "ficha", "filtro", "reserva", "descarga"] },
              termino:     { bsonType: "string", maxLength: 200 },
              material_id: { bsonType: "string", pattern: "^MAT-[0-9]{4}$" },
              campo:       { bsonType: "string" },
              valor:       { bsonType: "string" },
              resultados:  { bsonType: "int", minimum: 0 }
            }
          }
        }
      }
    }
  },
  validationLevel: "moderate",
  validationAction: "warn"
})
{ ok: 1 }

Fíjate en dos cosas del diseño de la validación. Primero, maxItems: 500 convierte en regla comprobada por el servidor el tope que en el apartado 15.3 era solo una convención: el array sin cota deja de ser posible por descuido. Segundo, los campos termino, material_id, campo y valor no están en required: cada tipo de evento usa unos y no otros, y esa heterogeneidad es precisamente lo que hace que este dato viva en MongoDB.

Por qué desplegar primero con warn. La colección ya tiene datos escritos antes de que existiera la validación, y es prácticamente seguro que algunos incumplen: eventos antiguos con t guardada como cadena, un tipo que se llamaba "consulta" antes de renombrarse, contenedores generados en pruebas con más de 500 elementos. Con validationAction: "error" esas escrituras empezarían a fallar de golpe en producción y en la ruta de escritura más frecuente del sistema (C6, el registro de eventos, con exigencia de menos de 10 ms). Con warn, MongoDB acepta la escritura y anota el incumplimiento en el registro: durante unos días se recogen los avisos, se cuantifica el problema, se corrigen los documentos antiguos y las rutas de código que los generan, y solo entonces se pasa a error con la certeza de que nada se romperá.

Y validationLevel: "moderate" complementa la estrategia: mientras dure la limpieza, las actualizaciones de documentos que ya incumplían no se bloquean, así que el proceso de corrección puede trabajar sin pelearse con su propia validación.

Conclusión

Esta ha sido la lección de diseño, y su contenido se resume así:

  • El orden se invierte: en el modelo relacional se modela el dominio y luego se consulta; en el documental se parte de la lista de consultas y se diseñan documentos que las resuelvan en un solo acceso. El mismo dominio admite diseños distintos según su uso.
  • Un agregado se lee junto, se escribe junto y tiene una raíz. Y sobre todo: es la frontera de la atomicidad. Lo que está dentro del documento cambia de una pieza; lo que está fuera necesita transacción o tolerancia al desfase.
  • Embeber o referenciar se decide con seis criterios: cardinalidad, si el hijo se consulta por separado, volatilidad, tamaño frente al límite de 16 MB, crecimiento acotado o ilimitado, y necesidad de atomicidad conjunta.
  • Uno a pocos → embeber. Uno a muchos → referenciar con subconjunto en el padre. Uno a muchísimos → referenciar solo desde el hijo, sin lista en el padre.
  • La duplicación es una herramienta, no un error, y cada campo duplicado pertenece a una categoría: inmutable (gratis), histórico congelado (correcto por semántica) o vivo (requiere propagación inmediata, diferida o relectura). Duplica lo que se muestra, nunca lo que se usa para decidir.
  • Cinco patrones: referencia extendida (copiar los pocos campos que se pintan), subconjunto (los N primeros en el padre), agrupación (series de eventos en contenedores por periodo, con upsert y $setOnInsert), valor atípico (bandera para la minoría desbordada) y campo calculado (guardar el agregado y mantenerlo con $inc).
  • Cuatro anti-patrones: arrays sin cota, documentos gigantes, colecciones masivas de documentos diminutos y MongoDB usado como base relacional a base de $lookup —cuyo diagnóstico correcto suele ser "esto quería PostgreSQL"—.
  • $jsonSchema recupera de forma voluntaria y gradual parte de la red de seguridad perdida: tipos, rangos, campos obligatorios, patrones y valores cerrados, con validationLevel y validationAction regulables. No valida referencias: la integridad referencial sigue siendo cosa de la aplicación.
  • El versionado con esquema_v permite evolucionar sin migración masiva, con estrategias perezosa, en segundo plano o masiva. Convive con dos o tres versiones y cierra las migraciones.
  • Y el entregable: catalogo (raíz el material, con metadatos libres por tipo, valoracion calculada, resenas_destacadas como subconjunto y disponibilidad copiada de PostgreSQL), resenas (raíz la reseña, con material y socio como referencias extendidas y votos embebidos por atomicidad) y actividad (raíz el conjunto socio-día, con patrón de agrupación, _id determinista, tope de 500 eventos e índice TTL a dos años).

Ya tenemos las dos mitades del curso construidas: biblioredb en PostgreSQL y bibliored en MongoDB, cada una con el diseño que le corresponde. En la lección 03-04, Comparación entre Bases de Datos Relacionales y No Relacionales, las ponemos frente a frente dimensión por dimensión y añadimos las piezas teóricas que hemos ido aplazando: el teorema CAP sin el malentendido habitual y su refinamiento PACELC, el contraste entre ACID y BASE, qué significa realmente la consistencia eventual para el lector que acaba de publicar una reseña, los niveles de consistencia ajustables de MongoDB, y el matiz decisivo de que la frontera se ha difuminado —PostgreSQL guarda documentos con jsonb y MongoDB tiene transacciones multidocumento—. Cerraremos con una guía de decisión honesta, los mitos más repetidos desmontados y el nombre de la arquitectura a la que ha llegado BiblioRed: la persistencia políglota.

Fundamentos de Bases de Datos

Módulo 1: Introducción a las Bases de Datos

Módulo 2: Bases de Datos Relacionales

Módulo 3: Bases de Datos No Relacionales

Módulo 4: Diseño de Esquemas

Módulo 5: Normalización

Módulo 6: Transacciones, Rendimiento y Seguridad

Módulo 7: Ejercicios Prácticos

Módulo 8: Casos de Estudio

Módulo 9: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados