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 BiblioRed —resenas, catalogo y actividad— con documentos de ejemplo y la justificación de cada decisión.
Contenido
- El cambio de mentalidad: del dominio a las consultas
- El agregado: unidad de lectura, escritura y atomicidad
- La decisión central: embeber o referenciar
- Uno a pocos, uno a muchos, uno a muchísimos
- Duplicación controlada y cómo mantenerla coherente
- Patrón: referencia extendida
- Patrón: subconjunto
- Patrón: agrupación (bucket)
- Patrón: valor atípico (outlier)
- Patrón: campo calculado
- Anti-patrones que hay que reconocer
- Validación de esquema con
$jsonSchema - Versionado de documentos y evolución del esquema
- Los índices, brevemente
- Entregable: el diseño final de las colecciones de BiblioRed
- Errores comunes y consejos
- Ejercicios
- Conclusión
- El cambio de mentalidad: del dominio a las consultas
En el módulo 2 seguimos, sin nombrarlo, un método muy concreto:
- Identificar las entidades del dominio: sucursales, socios, libros, ejemplares, préstamos.
- Darle a cada una su tabla, con sus claves y sus relaciones.
- Normalizar para eliminar la redundancia (lo formalizaremos en el módulo 5).
- 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:
- Enumerar las consultas que la aplicación necesita servir, con su frecuencia y su exigencia de latencia.
- Diseñar los documentos para que cada consulta frecuente se resuelva con un solo acceso.
- Aceptar la duplicación que haga falta para conseguirlo.
- 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.
- 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:
- Se lee junto: la aplicación casi nunca necesita una parte sin las demás.
- Se escribe junto: los cambios afectan al conjunto de forma coherente.
- 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.
- 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": "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.
- 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.
- 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.
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.
- 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.
- 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
})) } }
)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.
- 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 }
)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.
- 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.
- 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.
- 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 |
- Validación de esquema con
$jsonSchema
$jsonSchemaEl 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.
- 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-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 } }
)// Comprobar cómo va la migración
db.resenas.aggregate([ { $group: { _id: "$esquema_v", n: { $sum: 1 } } }, { $sort: { _id: 1 } } ])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.
- 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 deEXPLAINen 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ñosNo profundizamos más: los índices tienen su lección propia en el módulo 6.
- 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"
})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
upserty$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"—. $jsonSchemarecupera de forma voluntaria y gradual parte de la red de seguridad perdida: tipos, rangos, campos obligatorios, patrones y valores cerrados, convalidationLevelyvalidationActionregulables. No valida referencias: la integridad referencial sigue siendo cosa de la aplicación.- El versionado con
esquema_vpermite 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, conmetadatoslibres por tipo,valoracioncalculada,resenas_destacadascomo subconjunto ydisponibilidadcopiada de PostgreSQL),resenas(raíz la reseña, conmaterialysociocomo referencias extendidas y votos embebidos por atomicidad) yactividad(raíz el conjunto socio-día, con patrón de agrupación,_iddeterminista, 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
- Conceptos Básicos de Bases de Datos
- Tipos de Bases de Datos
- Historia y Evolución de las Bases de Datos
- Sistemas Gestores de Bases de Datos y Arquitectura
Módulo 2: Bases de Datos Relacionales
- Modelo Relacional
- Lenguaje SQL
- Operaciones Básicas en SQL
- Consultas Multitabla: JOIN y Subconsultas
- Agregación y Agrupación de Datos
- Integridad Referencial
Módulo 3: Bases de Datos No Relacionales
- Introducción a NoSQL
- Tipos de Bases de Datos NoSQL
- Modelado de Datos en NoSQL
- Comparación entre Bases de Datos Relacionales y No Relacionales
Módulo 4: Diseño de Esquemas
- Principios de Diseño de Esquemas
- Diagramas Entidad-Relación (ER)
- Transformación de Diagramas ER a Esquemas Relacionales
- Tipos de Datos y Restricciones
Módulo 5: Normalización
Módulo 6: Transacciones, Rendimiento y Seguridad
- Transacciones y Propiedades ACID
- Concurrencia y Niveles de Aislamiento
- Índices y Optimización de Consultas
- Seguridad, Permisos y Copias de Seguridad
Módulo 7: Ejercicios Prácticos
- Ejercicios de SQL
- Ejercicios de Diseño de Esquemas
- Ejercicios de Normalización
- Ejercicios de Consultas Avanzadas y Transacciones
Módulo 8: Casos de Estudio
- Caso de Estudio: Base de Datos Relacional
- Caso de Estudio: Base de Datos No Relacional
- Caso de Estudio: Persistencia Políglota
