Patrones de modelado de datos para bases de datos documentales
Modelado de datos práctico para MongoDB y bases documentales: incrustar frente a referenciar, desnormalización y patrones de evolución de esquema.

Las bases de datos documentales almacenan los datos de forma distinta a las relacionales. No hay JOINs, claves foráneas ni validación de esquema por defecto. No es una limitación, sino un conjunto diferente de compensaciones. El modelo de documentos está optimizado para leer objetos completos en una sola consulta, a costa de patrones de escritura más complejos.
La mayoría de los problemas con bases de datos documentales vienen de modelar datos a lo relacional. Normalizar todo en colecciones separadas y luego hacer "JOINs" en el código de aplicación anula la ventaja. El enfoque correcto es modelar según tus patrones de acceso.
Incrustación vs. referenciación
La decisión fundamental al modelar documentos: ¿los datos relacionados deben vivir dentro del documento padre (incrustados) o en una colección separada (referenciados)?
// ❌ Sobre-normalizado — pensamiento relacional en una base de datos documental
// Tres colecciones, tres consultas para construir una vista de página
const user = await db.users.findOne({ _id: userId });
const address = await db.addresses.findOne({ userId });
const preferences = await db.preferences.findOne({ userId });
// Ensamblado en el código de la aplicación:
const profile = { ...user, address, preferences };// ✅ Incrustado — una sola lectura devuelve el objeto completo
// Una colección, una consulta
const profile = await db.users.findOne({ _id: userId });
// Estructura del documento:
{
_id: "user123",
name: "Alice Chen",
email: "alice@example.com",
address: {
street: "123 Main St",
city: "Portland",
state: "OR",
zip: "97201"
},
preferences: {
theme: "dark",
language: "en",
notifications: {
email: true,
push: false
}
}
}Incrusta cuando:
- Los datos relacionados siempre aparecen con el padre (la dirección pertenece al usuario)
- Los datos incrustados no crecen sin límite
- Casi nunca necesitas los datos incrustados de forma independiente
Cuándo referenciar
Referenciar es correcto cuando los datos incrustados crecerían sin límite, cuando los mismos datos aparecen en múltiples contextos, o cuando el documento incrustado excedería el límite de 16 MB de tamaño de MongoDB.
// ❌ Incrustar arrays sin límite — el documento crece para siempre
{
_id: "user123",
name: "Alice",
orders: [
{ orderId: "ord1", total: 59.99, items: [...] },
{ orderId: "ord2", total: 129.50, items: [...] },
// ... 10,000 pedidos más durante 5 años
// El documento excede 16 MB, las consultas se ralentizan
]
}// ✅ Referenciado — pedidos en colección separada
// Colección de usuarios:
{
_id: "user123",
name: "Alice",
email: "alice@example.com"
}
// Colección de pedidos — indexada por userId para búsquedas rápidas:
{
_id: "ord456",
userId: "user123",
total: 59.99,
status: "delivered",
createdAt: ISODate("2021-06-01"),
items: [
{ productId: "prod1", name: "Widget", quantity: 2, price: 29.99 }
]
}
// Consulta: pedidos recientes de un usuario
const orders = await db.orders
.find({ userId: "user123" })
.sort({ createdAt: -1 })
.limit(10);Referencia cuando:
- Los datos relacionados crecen sin límite (pedidos, logs, comentarios)
- Los datos relacionados tienen su propio ciclo de vida (los productos existen independientemente de los pedidos)
- Necesitas consultar los datos relacionados de forma independiente (todos los pedidos mayores a $100)
El patrón de subconjunto
Cuando necesitas algunos datos incrustados para lecturas frecuentes pero el conjunto completo es demasiado grande, incrusta un subconjunto y referencia la colección completa.
// Documento de producto con las 10 reseñas más recientes incrustadas
{
_id: "prod789",
name: "Wireless Headphones",
price: 79.99,
rating: 4.3,
reviewCount: 2847,
// Incrusta reseñas recientes para la página del producto
recentReviews: [
{
userId: "user1",
userName: "Bob",
rating: 5,
text: "Great sound quality",
createdAt: ISODate("2021-06-07")
},
{
userId: "user2",
userName: "Carol",
rating: 4,
text: "Good but pricey",
createdAt: ISODate("2021-06-05")
}
// ... hasta 10 reseñas recientes
]
}
// Colección completa de reseñas — para paginación, búsqueda y análisis
{
_id: "rev123",
productId: "prod789",
userId: "user1",
userName: "Bob",
rating: 5,
text: "Great sound quality",
createdAt: ISODate("2021-06-07"),
helpful: 23,
verified: true
}// Página de producto: una sola consulta devuelve producto + reseñas recientes
const product = await db.products.findOne({ _id: productId });
// product.recentReviews ya está ahí — no se necesita JOIN
// Página "Ver todas las reseñas": consulta paginada en la colección de reseñas
const allReviews = await db.reviews
.find({ productId })
.sort({ createdAt: -1 })
.skip(page * pageSize)
.limit(pageSize);La compensación es la complejidad de escritura. Cuando se agrega una nueva reseña, actualizas tanto la colección de reseñas como el array recentReviews del producto. Es aceptable porque las reseñas se leen mucho más a menudo de lo que se escriben.
El patrón de referencia extendida
Almacena una copia de los campos de acceso frecuente de los documentos referenciados para evitar búsquedas en consultas comunes.
// ❌ La orden referencia userId — necesita una segunda consulta para el nombre del usuario
{
_id: "ord456",
userId: "user123", // Hay que buscar el usuario para mostrar su nombre
total: 59.99
}
// Mostrar "Pedido de Alice Chen" requiere:
const order = await db.orders.findOne({ _id: orderId });
const user = await db.users.findOne({ _id: order.userId });
const display = `Order by ${user.name}`;// ✅ Referencia extendida — copia los campos que necesitas para mostrar
{
_id: "ord456",
userId: "user123",
userName: "Alice Chen", // Copiado del documento de usuario
userEmail: "alice@example.com", // Copiado para recibos por correo electrónico
total: 59.99,
createdAt: ISODate("2021-06-08")
}
// Una sola consulta devuelve todo lo necesario para mostrar
const order = await db.orders.findOne({ _id: orderId });
const display = `Order by ${order.userName}`; // Sin segunda consultaLos campos copiados están desnormalizados — pueden quedar desactualizados si el usuario cambia su nombre. Es aceptable para pedidos (el nombre al momento de la compra es lo que importa), pero sería un problema para una pantalla de chat en tiempo real.
Validación de esquema
Las bases de datos documentales no validan esquemas por defecto, pero MongoDB admite validación con JSON Schema para evitar que datos incorrectos entren en las colecciones.
// Crear colección con validación de esquema
await db.createCollection('users', {
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['name', 'email', 'createdAt'],
properties: {
name: {
bsonType: 'string',
minLength: 1,
maxLength: 200,
},
email: {
bsonType: 'string',
pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$',
},
role: {
enum: ['admin', 'user', 'moderator'],
},
address: {
bsonType: 'object',
properties: {
street: { bsonType: 'string' },
city: { bsonType: 'string' },
zip: { bsonType: 'string', pattern: '^[0-9]{5}$' },
},
},
createdAt: {
bsonType: 'date',
},
},
},
},
validationAction: 'error', // Rechazar documentos inválidos
validationLevel: 'strict', // Validar todas las inserciones y actualizaciones
});La validación de esquema detecta problemas de calidad de datos a nivel de base de datos. Incluso si el código de aplicación tiene un error que envía datos mal formados, la base de datos los rechaza.
Evolución del esquema
Los documentos evolucionan a medida que cambian las funcionalidades. A diferencia de las migraciones relacionales que alteran tablas, los esquemas de documentos evolucionan mediante patrones de migración a nivel de aplicación.
// Patrón de versión de esquema — manejar múltiples versiones en código
interface UserV1 {
_id: string;
name: string; // campo de nombre único
email: string;
}
interface UserV2 {
_id: string;
firstName: string; // nombre dividido en dos campos
lastName: string;
email: string;
schemaVersion: 2;
}
type User = UserV1 | UserV2;
// La función de lectura maneja ambas versiones
function normalizeUser(doc: User): NormalizedUser {
if ('schemaVersion' in doc && doc.schemaVersion === 2) {
return {
firstName: doc.firstName,
lastName: doc.lastName,
email: doc.email,
};
}
// V1: divide el campo de nombre único
const [firstName, ...rest] = doc.name.split(' ');
return {
firstName,
lastName: rest.join(' ') || '',
email: doc.email,
};
}
// Migración perezosa: actualiza documentos a medida que se leen
async function getUser(id: string): Promise<NormalizedUser> {
const doc = await db.users.findOne({ _id: id });
const normalized = normalizeUser(doc);
// Si el documento es de formato antiguo, actualízalo oportunistamente
if (!('schemaVersion' in doc)) {
await db.users.updateOne(
{ _id: id },
{
$set: {
firstName: normalized.firstName,
lastName: normalized.lastName,
schemaVersion: 2,
},
$unset: { name: '' },
}
);
}
return normalized;
}La migración perezosa actualiza documentos a medida que se accede a ellos. Con el tiempo, la mayoría migra al nuevo esquema. Para documentos raramente accedidos, un trabajo en segundo plano puede barrer los documentos antiguos restantes.
Conclusiones clave
- Incrusta datos que van juntos — si siempre necesitas la dirección con el usuario, ponlos en el mismo documento
- Referencia relaciones uno-a-muchos sin límite — pedidos, logs y comentarios pertenecen a colecciones separadas
- Usa el patrón de subconjunto para datos calientes — incrusta reseñas recientes en el producto y pagina el conjunto completo desde una colección separada
- Desnormaliza para rendimiento de lectura — copia campos mostrados frecuentemente para evitar búsquedas, acepta la complejidad de escritura
- Agrega validación de esquema — las bases de datos documentales no deberían significar sin-esquema; valida a nivel de base de datos
- Evoluciona los esquemas perezosamente — maneja múltiples versiones en código, migra documentos a medida que se leen


