En la lección anterior decidimos el modelo de datos de Escena Viva y dejamos preparada la frontera src/repositorios/. Ahora bajamos al código: conectaremos la aplicación a MongoDB, escribiremos el módulo de conexión que se integra con el apagado ordenado de src/servidor.js, y traduciremos el diagrama de la lección 07-01 a esquemas y modelos de Mongoose en src/modelos/.

Esta lección no escribe consultas todavía —eso es la 07-03—. Escribe la estructura: qué forma tienen los documentos, qué reglas hacen cumplir, qué campos derivados exponen y qué índices los sostienen.

Contenido

  1. Qué es MongoDB: documentos, colecciones y ObjectId
  2. Qué es Mongoose y si merece la pena
  3. Conexión, arranque y apagado ordenado
  4. El esquema Evento con sesiones incrustadas
  5. Tipos y opciones de esquema
  6. Pedido, Entrada y un Usuario mínimo
  7. Validadores de esquema frente a validación zod
  8. _id frente a identificadores de negocio
  9. Métodos, estáticos y virtuals
  10. Middleware de esquema y toJSON
  11. Índices declarados

Qué es MongoDB: documentos, colecciones y ObjectId

MongoDB guarda documentos en colecciones. Un documento es una estructura parecida a un objeto JavaScript, con campos anidados y arrays. Una colección es el equivalente laxo de una tabla: un contenedor que no exige que todos sus documentos tengan la misma forma.

El formato interno es BSON (Binary JSON): una codificación binaria que añade tipos que JSON no tiene y que a nosotros nos importan.

Tipo BSON Equivalente JS Por qué importa en Escena Viva
ObjectId Objeto de 12 bytes Identificador por defecto de todo documento
Date Date fechaHora se guarda como fecha real, no como texto
Int32 / Int64 number aforo, vendidas, precioCentimos son enteros de verdad
Decimal128 / Binary — / Buffer Decimal exacto (no lo usamos: vamos en céntimos) y buffers del M3

Todo documento tiene _id único en su colección. Si no lo das, MongoDB genera un ObjectId: 12 bytes que combinan marca de tiempo, identificador de máquina/proceso y un contador. Dos consecuencias prácticas: son únicos sin coordinación central y son aproximadamente ordenables por fecha de creación, porque los primeros 4 bytes son el instante en segundos.

En texto se ve como 507f1f77bcf86cd799439011: 24 caracteres hexadecimales. Ojo con esto, porque pasar una cadena que no tiene esa forma a una consulta por _id produce un CastError, y ese error hay que traducirlo a un 400, no dejar que se convierta en un 500.

Qué es Mongoose y si merece la pena

Mongoose es un ODM: se sitúa sobre el controlador oficial mongodb y aporta esquema (la forma esperada de tus documentos), validación antes de escribir, tipado y conversión automática, y funcionalidad de modelo: hooks pre/post, métodos, virtuals y populate.

¿Merece la pena frente al controlador nativo? La respuesta honesta: depende de cuánta disciplina puedas garantizar sin ayuda. Con el controlador nativo escribes exactamente la consulta que se ejecuta y no hay sorpresas de rendimiento, pero cada campo obligatorio, cada valor por defecto y cada conversión de tipo es código tuyo repetido en cada punto de escritura. Mongoose centraliza eso y hace que un documento mal formado sea difícil de crear por accidente. El coste es una capa de indirección: hay que saber que find() devuelve un Query perezoso, que lean() cambia lo que recibes y que un pre('save') no se dispara en un updateOne. Para un dominio con invariantes como el nuestro la balanza se inclina hacia Mongoose; para un script de ingesta de un millón de documentos, bajaría al controlador nativo sin dudarlo.

Se instala con npm install mongoose; incluye el controlador mongodb como dependencia, así que no hay que instalarlo aparte.

Conexión, arranque y apagado ordenado

La URL entra por el único sitio autorizado a leer process.env: src/config/index.js.

// src/config/index.js (fragmento nuevo)
const mongodbUrl = process.env.MONGODB_URL ?? 'mongodb://localhost:27017/escena_viva';

if (!/^mongodb(\+srv)?:\/\//.test(mongodbUrl)) {
  throw new Error('MONGODB_URL debe empezar por mongodb:// o mongodb+srv://');
}

const configuracion = Object.freeze({
  mongodbUrl, // ...junto a los campos anteriores del modulo 6
  mongodbTiempoEspera: Number(process.env.MONGODB_TIEMPO_ESPERA ?? 5000),
});

Validar al arrancar es deliberado: si la URL está mal queremos enterarnos al lanzar el proceso, no en la primera petición de un cliente.

// src/db/conexion.js
'use strict';

const mongoose = require('mongoose');
const { configuracion } = require('../config/index.js');

// Mongoose 7+ no permite consultar por campos no declarados en el esquema.
mongoose.set('strictQuery', true);

let conexionActiva = null;

/** Abre la conexion. Es idempotente: si ya hay una, la reutiliza. */
async function conectar({ url = configuracion.mongodbUrl } = {}) {
  if (conexionActiva) return conexionActiva;
  registrarEventos();
  await mongoose.connect(url, {
    // Si no encuentra servidor en este tiempo, rechaza en vez de esperar siempre.
    serverSelectionTimeoutMS: configuracion.mongodbTiempoEspera,
    // Pool de sockets reutilizados: operaciones simultaneas contra el motor.
    maxPoolSize: 10,
    minPoolSize: 1,
  });
  conexionActiva = mongoose.connection;
  return conexionActiva;
}

async function desconectar() {
  if (!conexionActiva) return;
  await mongoose.disconnect();
  conexionActiva = null;
}

function registrarEventos() {
  const { connection } = mongoose;
  // No registramos la URL completa: puede llevar credenciales.
  connection.on('connected', () => console.log(`[db] conectado a '${connection.name}'`));
  connection.on('error', (error) => console.error('[db] error:', error.message));
  connection.on('disconnected', () => console.warn('[db] desconectado; reintentara'));
}

module.exports = { conectar, desconectar };

Tres detalles que no son adorno. serverSelectionTimeoutMS: sin él, una URL equivocada deja el arranque colgado sin explicación. El pool: Mongoose no abre una conexión por consulta, mantiene un conjunto de sockets; si maxPoolSize fuera 1, serializarías toda la aplicación. Los eventos: tras un disconnected el controlador reintenta automáticamente y encola las operaciones pendientes; no necesitas escribir reconexión, pero sí registrar el evento para no quedarte ciego durante una caída.

crearAplicacion() sigue siendo una factoría pura que no toca la red. La conexión pertenece al proceso, y el proceso lo gobierna src/servidor.js:

// src/servidor.js (fragmento adaptado)
async function arrancarServidor() {
  await conectar(); // 1. Primero la base: sin datos no tiene sentido dar trafico.
  const servidor = crearAplicacion().listen(configuracion.puerto);

  async function apagar(senal) {
    console.log(`[http] recibida ${senal}, apagando de forma ordenada`);
    // 2. Dejamos de aceptar peticiones y, solo cuando las que estan en vuelo
    //    han terminado, 3. cerramos la conexion con la base de datos.
    servidor.close(async () => {
      await desconectar();
      process.exit(0);
    });
  }

  process.on('SIGTERM', () => apagar('SIGTERM'));
  process.on('SIGINT', () => apagar('SIGINT'));
  return servidor;
}

El orden importa y es simétrico. Al arrancar: base primero, HTTP después, para que ninguna petición llegue a una aplicación sin datos. Al apagar: HTTP primero, base después, porque cerrar la conexión con peticiones en vuelo produciría errores en compras ya en curso. La conexión es un recurso del proceso, con su mismo ciclo de vida.

El esquema Evento con sesiones incrustadas

// src/modelos/evento.js
'use strict';

const mongoose = require('mongoose');

const { Schema } = mongoose;
const ESTADOS_EVENTO = ['borrador', 'publicado', 'finalizado'];

const esquemaSesion = new Schema(
  {
    // Identificador de negocio, con el formato ses-NNN-M que ya valida zod.
    sesionId: { type: String, required: true, match: /^ses-\d{3}-\d+$/ },
    fechaHora: { type: Date, required: true },
    aforo: { type: Number, required: true, min: 1 },
    vendidas: { type: Number, required: true, default: 0, min: 0 },
    precioCentimos: { type: Number, required: true, min: 0 },
  },
  // Los subdocumentos no necesitan _id: los identifica sesionId.
  { _id: false },
);

// Validador personalizado: la invariante central del dominio.
esquemaSesion.path('vendidas').validate(function comprobarAforo(valor) {
  return valor <= this.aforo;
}, 'Las entradas vendidas no pueden superar el aforo');

const esquemaEvento = new Schema(
  {
    eventoId: { type: String, required: true, unique: true, match: /^evt-\d{3}$/ },
    titulo: { type: String, required: true, trim: true, maxlength: 160 },
    sala: { type: String, required: true, trim: true, index: true },
    organizadorId: { type: String, required: true, index: true },
    categoria: { type: String, required: true, trim: true },
    estado: { type: String, required: true, enum: ESTADOS_EVENTO, default: 'borrador' },
    duracionMinutos: { type: Number, required: true, min: 1, max: 600 },
    sesiones: { type: [esquemaSesion], default: [] },
  },
  { timestamps: true },
);

const Evento = mongoose.model('Evento', esquemaEvento);

module.exports = { Evento, ESTADOS_EVENTO };

El esquema reproduce campo a campo lo que ya sabíamos del dominio: evt-001 "Concierto de Otono" en el Teatro Almendra, con org-almendra y las sesiones ses-001-1 y ses-001-2. Nada nuevo se inventa; se persiste lo que ya existía. Y { timestamps: true } añade createdAt y updatedAt automáticos: el principio del historial que echábamos de menos en el JSON.

Tipos y opciones de esquema

Opción Qué hace Ejemplo
type String, Number, Date, Boolean, ObjectId, arrays, subdocumentos fechaHora: Date
required Rechaza el guardado si falta aforo
default Valor si no se indica; admite función vendidas: 0
enum Lista cerrada de valores estado
min / max Rango numérico o de fechas precioCentimos: { min: 0 }
minlength / maxlength Longitud de cadena titulo hasta 160
match Expresión regular codigo: EV-\d{4}-\d{6}
trim / lowercase Normaliza antes de guardar email
unique / index No validan: declaran índices (único o normal) eventoId, sala
immutable Impide modificarlo tras crearlo codigo
timestamps Opción de esquema: createdAt/updatedAt Todos los modelos

La fila de unique merece énfasis: unique: true no valida nada en Mongoose. Le pide a MongoDB que cree un índice único, y quien rechaza el duplicado es el motor, con un error E11000 en tiempo de escritura, no un ValidationError. En la lección siguiente lo traduciremos a un 409.

Pedido, Entrada y un Usuario mínimo

// src/modelos/pedido.js
const ESTADOS_PEDIDO = ['pendiente', 'pagado', 'emitido', 'anulado'];

const esquemaPedido = new Schema(
  {
    // Referencia a otra coleccion: guarda el _id, no el usuario entero.
    usuarioId: { type: Schema.Types.ObjectId, ref: 'Usuario', required: true, index: true },
    eventoId: { type: String, required: true },
    sesionId: { type: String, required: true, index: true },
    cantidad: { type: Number, required: true, min: 1, max: 6 },
    totalCentimos: { type: Number, required: true, min: 0 },
    estado: { type: String, required: true, enum: ESTADOS_PEDIDO, default: 'pendiente' },
    canal: { type: String, required: true, enum: ['web', 'taquilla', 'telefono'] },
  },
  { timestamps: true },
);

const Pedido = mongoose.model('Pedido', esquemaPedido);
module.exports = { Pedido, ESTADOS_PEDIDO };
// src/modelos/entrada.js
const ESTADOS_ENTRADA = ['valida', 'usada', 'anulada'];

const esquemaEntrada = new Schema(
  {
    // El codigo impreso: EV-<anio>-<6 digitos>. Unico e inmutable.
    codigo: { type: String, required: true, unique: true, immutable: true, match: /^EV-\d{4}-\d{6}$/ },
    pedidoId: { type: Schema.Types.ObjectId, ref: 'Pedido', required: true, index: true },
    sesionId: { type: String, required: true, index: true },
    estado: { type: String, required: true, enum: ESTADOS_ENTRADA, default: 'valida' },
    // Momento del escaneo en la puerta; nulo mientras no se ha usado.
    usadaEn: { type: Date, default: null },
  },
  { timestamps: true },
);

const Entrada = mongoose.model('Entrada', esquemaEntrada);
module.exports = { Entrada, ESTADOS_ENTRADA };

// --- src/modelos/usuario.js ---
const ROLES = ['asistente', 'organizador', 'administrador'];

const esquemaUsuario = new Schema(
  {
    email: { type: String, required: true, unique: true, lowercase: true, trim: true },
    nombre: { type: String, required: true, trim: true },
    rol: { type: String, required: true, enum: ROLES, default: 'asistente' },
    // Aqui NO hay contrasena. La autenticacion es el modulo 8 al completo:
    // hash, sesiones, JWT y control de acceso por rol.
  },
  { timestamps: true },
);

const Usuario = mongoose.model('Usuario', esquemaUsuario);
module.exports = { Usuario, ROLES };

Ese comentario en Usuario no es un despiste: es una decisión. El usuario existe para poder referenciarlo desde los pedidos y para que los roles ya estén modelados, pero no implementamos autenticación. Guardar contraseñas mal es peor que no guardarlas.

Validadores de esquema frente a validación zod

En el módulo 6 pusimos zod en el borde, con esquemaCrearPedido y el middleware validar que deja el resultado en req.datosValidados. Ahora aparece la validación del esquema de Mongoose. ¿No es duplicar trabajo? No: son capas distintas, con clientes distintos.

zod (borde HTTP) Mongoose (esquema)
Qué valida La forma del cuerpo de la petición La forma del documento antes de escribirlo
Quién la dispara Cada petición HTTP Cada save() o create(), venga de donde venga
Protege de Clientes que envían basura Bugs propios, scripts, migraciones, semillas
Error que produce ErrorDeValidacion → 400/422 ValidationError de Mongoose

La regla en una frase: zod valida lo que entra por la puerta; Mongoose valida lo que sale hacia el disco. Un script de importación nocturno no pasa por Express y por tanto no pasa por zod; si el esquema no validara, podría dejar vendidas: 9999 en una sesión de aforo 300. Y zod puede rechazar cosas que a Mongoose le dan igual (un campo extra, un canal desconocido) antes de gastar un viaje a la base. Por encima de ambas sigue el dominio: Sesion.vender() conserva su invariante en memoria. Tres capas, tres momentos, ninguna redundante.

_id frente a identificadores de negocio

Nuestros documentos tienen dos identificadores. El _id es técnico: único, generado sin coordinación, eficiente como clave de índice y destino de las referencias que usa populate. El identificador de negocio (evt-001, ses-001-1, EV-2026-000431) es semántico: aparece en URLs, correos, entradas impresas, los CSV del módulo 3 y los esquemas zod del módulo 6; y es estable frente a migraciones, porque si mañana movemos los datos a PostgreSQL los _id desaparecen pero evt-001 sigue siendo evt-001. Usar _id en URLs públicas te ata al motor y filtra información interna; usar solo el de negocio como _id es defendible, pero pierdes la ordenación temporal implícita. Conservar ambos, con índice único sobre el de negocio, es lo habitual en sistemas que esperan vivir años.

Métodos, estáticos y virtuals

Los virtuals reproducen los getters ricos del dominio del módulo 2: campos calculados que no se guardan.

// src/modelos/evento.js (ampliación)
esquemaSesion.virtual('libres').get(function () {
  return this.aforo - this.vendidas;
});

esquemaSesion.virtual('ocupacion').get(function () {
  return this.aforo === 0 ? 0 : Number((this.vendidas / this.aforo).toFixed(4));
});

esquemaSesion.virtual('agotada').get(function () {
  return this.vendidas >= this.aforo;
});

esquemaEvento.virtual('aforoTotal').get(function () {
  return this.sesiones.reduce((total, sesion) => total + sesion.aforo, 0);
});

// Metodo de instancia: opera sobre UN documento.
esquemaEvento.methods.buscarSesion = function (sesionId) {
  return this.sesiones.find((sesion) => sesion.sesionId === sesionId) ?? null;
};

// Metodo estatico: opera sobre el modelo (la coleccion entera).
esquemaEvento.statics.buscarPorSala = function (sala) {
  return this.find({ sala, estado: 'publicado' }).sort({ titulo: 1 });
};

Dos avisos. Los virtuals no existen en la base de datos: no puedes filtrar ni ordenar por ocupacion, porque MongoDB no sabe que existe; para eso hace falta una agregación (lección 07-04) o guardarlo desnormalizado. Y lean() los elimina: si pides objetos planos por rendimiento, pierdes virtuals y métodos.

Sobre function frente a arrow: en métodos, virtuals y hooks hay que usar function, porque Mongoose vincula this al documento. Una arrow captura el this léxico del módulo y te dejará con undefined. Es el error número uno de quien empieza.

Middleware de esquema y toJSON

// src/modelos/pedido.js: se ejecuta antes de guardar; lanzar aborta la escritura.
esquemaPedido.pre('save', function (next) {
  if (this.isNew && this.totalCentimos === 0 && this.cantidad > 0) {
    return next(new Error('Un pedido con entradas no puede tener total cero'));
  }
  next();
});

Uso responsable de los hooks significa tres cosas:

  1. Nada de efectos externos. Enviar un correo o llamar a una API dentro de un pre('save') acopla la persistencia a un servicio remoto: un fallo de red impediría guardar. Eso va fuera, o al GestorDeVentas que ya tenemos.
  2. Recuerda qué hooks se disparan. pre('save') no se ejecuta en updateOne, findOneAndUpdate ni deleteMany, porque esas operaciones ocurren en el servidor sin materializar el documento. Si la lógica es innegociable, ponla en el esquema o en el repositorio. Y que sean rápidos: un hook lento se paga en cada escritura.
// Transformacion de salida, aplicable a los cuatro esquemas.
const configurarSalida = (esquema) => esquema.set('toJSON', {
  virtuals: true,     // incluye libres, ocupacion, agotada...
  versionKey: false,  // elimina __v
  transform(documento, plano) {
    plano.id = documento._id.toString();
    delete plano._id;
    return plano;
  },
});

El __v es el contador interno de versión de Mongoose: no aporta nada a un cliente HTTP y solo genera preguntas. Y exponer _id en bruto acopla tu contrato público al motor. Esta transformación es la frontera entre "cómo guardo" y "qué publico", la misma separación que defendían los repositorios.

Índices declarados

Un índice acelera lecturas y encarece escrituras. Se declaran por consulta prevista, no por capricho:

// Catalogo de una sala, ordenado: primero los filtros por igualdad, luego el
// campo de ordenacion.
esquemaEvento.index({ estado: 1, sala: 1, titulo: 1 });
// Rango de fechas. Al ser un campo de un array de subdocumentos, MongoDB crea
// un indice multiclave automaticamente.
esquemaEvento.index({ 'sesiones.fechaHora': 1 });
// Localizar el evento que contiene una sesion. Unico: un sesionId no se repite.
esquemaEvento.index({ 'sesiones.sesionId': 1 }, { unique: true });
Índice Consulta que acelera
eventoId (por unique) GET /eventos/evt-001
{ estado, sala, titulo } Catálogo del Teatro Almendra ordenado por título
sesiones.fechaHora "Qué hay en cartel del 1 al 15 de marzo"
sesiones.sesionId (único) Encontrar ses-001-2 para vender
Entrada.codigo (único) / Pedido.usuarioId Escaneo en puerta / "mis pedidos"

En desarrollo Mongoose crea estos índices al arrancar (autoIndex). En producción debe desactivarse (mongoose.set('autoIndex', false)) y crearlos en la migración correspondiente: construir un índice sobre millones de documentos durante el arranque puede bloquear la aplicación varios minutos. Lo retomamos en la lección 07-06.

Errores Comunes y Consejos

  • Arrow functions en métodos, virtuals o hooks. this deja de ser el documento y todo devuelve undefined.
  • Creer que unique: true valida. Es un índice; el error llega como E11000, no como ValidationError.
  • Llamar a mongoose.connect en cada petición. La conexión es del proceso y tiene pool; conectar por petición agota los sockets del sistema.
  • Registrar la URL de conexión. Si lleva credenciales, acabas de filtrarlas. Registra connection.name.
  • Definir el mismo modelo dos veces. mongoose.model('Evento', ...) en dos ficheros lanza OverwriteModelError.
  • Consejo: empieza con required y enum generosos —es más barato relajar un esquema que limpiar datos incoherentes— y el dinero siempre entero y en céntimos.

Ejercicios

Ejercicio 1: modelo de sala

Escribe src/modelos/sala.js con salaId (único, formato sala-xxx), nombre, ciudad, aforoMaximo (entero ≥ 1) y activa (booleano, por defecto true). Añade un virtual etiqueta que devuelva "<nombre> (<ciudad>)", un estático buscarActivas(), timestamps y la transformación toJSON.

Ejercicio 2: validador cruzado

Añade a Entrada un validador que impida marcar usadaEn con una fecha cuando el estado sea anulada. Explica por qué no se dispararía con findByIdAndUpdate sin opciones.

Ejercicio 3: elegir índices

Decide el índice y el orden de sus campos para: (a) pedidos pagado de un usuario ordenados por fecha descendente; (b) entradas valida de una sesión; (c) eventos de un organizador en estado publicado.

Soluciones

Ejercicio 1.

// src/modelos/sala.js
const esquemaSala = new Schema(
  {
    salaId: { type: String, required: true, unique: true, match: /^sala-[a-z]+$/ },
    nombre: { type: String, required: true, trim: true },
    ciudad: { type: String, required: true, trim: true, index: true },
    aforoMaximo: { type: Number, required: true, min: 1 },
    activa: { type: Boolean, required: true, default: true },
  },
  { timestamps: true },
);

esquemaSala.virtual('etiqueta').get(function () {
  return `${this.nombre} (${this.ciudad})`;
});
esquemaSala.statics.buscarActivas = function () {
  return this.find({ activa: true }).sort({ nombre: 1 });
};
esquemaSala.set('toJSON', { virtuals: true, versionKey: false });

module.exports = { Sala: mongoose.model('Sala', esquemaSala) };

Ejercicio 2.

esquemaEntrada.path('usadaEn').validate(function comprobarUso(valor) {
  // Una entrada anulada nunca puede tener marca de uso.
  return !(this.estado === 'anulada' && valor !== null);
}, 'Una entrada anulada no puede registrar fecha de uso');

No se dispara porque las actualizaciones se ejecutan en el servidor sin construir el documento: Mongoose solo valida si le pasas { runValidators: true }, y aun así this es la consulta, no el documento, así que un validador que depende de otros campos puede no tener acceso a ellos.

Ejercicio 3. (a) { usuarioId: 1, estado: 1, createdAt: -1 }. (b) { sesionId: 1, estado: 1 }. (c) { organizadorId: 1, estado: 1 }. La regla se llama ESR: primero los campos comparados por igualdad (Equality), luego el de ordenación (Sort) y por último los de rango (Range).

Conclusión

Escena Viva ya tiene persistencia real. Sabes qué guarda MongoDB y cómo, qué añade Mongoose por encima y a qué precio, y cómo abrir la conexión desde la configuración validada e integrarla con el arranque y el apagado ordenado. Has traducido el diagrama de la lección anterior a cuatro modelos —Evento con sus sesiones incrustadas, Pedido, Entrada y un Usuario sin contraseñas a la espera del módulo 8— con sus tipos, validadores, virtuals, middleware, toJSON limpio e índices razonados. Y has visto por qué conviven tres capas de validación sin estorbarse: zod en el borde, el esquema antes del disco y el dominio en memoria.

En la lección siguiente empezamos a mover datos: las cuatro operaciones CRUD con sus trampas —Query perezosos, lean(), paginación, runValidators, $inc, borrado lógico—, la traducción de los errores de Mongoose a nuestra jerarquía del módulo 6, y el momento que esperábamos: jubilar src/catalogo-datos.js y sustituirlo por src/repositorios/eventos.js sin que los controladores se enteren.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados