Ya tenemos conexión y modelos. Toca mover datos. CRUD son las cuatro operaciones que sostienen cualquier aplicación con estado: crear, leer, actualizar y borrar. Con Mongoose cada una tiene varias formas de escribirse, y no todas son equivalentes: unas validan y otras no, unas devuelven el documento anterior y otras el nuevo, unas ejecutan la consulta y otras solo la construyen.

Esta lección recorre las cuatro con sus trampas reales, traduce los errores de Mongoose a la jerarquía del módulo 6, y culmina con el momento anunciado desde la primera lección del módulo: src/catalogo-datos.js se retira y src/repositorios/eventos.js ocupa su lugar con la misma firma pública, sin que los controladores ni el dominio se enteren.

Contenido

  1. Crear documentos
  2. Errores de clave duplicada y la jerarquía del módulo 6
  3. Leer: Query perezosos, proyecciones y lean()
  4. Ordenar, limitar y paginar
  5. Operadores de consulta esenciales
  6. Actualizar: runValidators, $set, $inc
  7. Borrar y borrado lógico
  8. El repositorio: jubilando catalogo-datos.js
  9. POST /pedidos contra la base de datos
  10. Errores de Mongoose y su estado HTTP

Crear documentos

Hay dos caminos, y la diferencia no es estética.

// Camino 1: instanciar y guardar. Permite manipular el documento antes de escribir.
const evento = new Evento({
  eventoId: 'evt-001', titulo: 'Concierto de Otono', sala: 'Teatro Almendra',
  organizadorId: 'org-almendra', categoria: 'musica', estado: 'publicado', duracionMinutos: 95,
  sesiones: [
    { sesionId: 'ses-001-1', fechaHora: new Date('2026-10-03T20:00:00Z'), aforo: 400, vendidas: 312, precioCentimos: 2500 },
    { sesionId: 'ses-001-2', fechaHora: new Date('2026-10-04T19:00:00Z'), aforo: 400, vendidas: 289, precioCentimos: 2500 },
  ],
});
// Todavia no hay nada en la base: el documento vive en memoria.
const guardado = await evento.save(); // valida, dispara pre('save') y escribe

// Camino 2: crear directamente. Equivale a new + save en una linea.
const otro = await Evento.create({ eventoId: 'evt-002', /* ... */ });
// Insercion masiva: una sola ida y vuelta para muchos documentos.
const varios = await Evento.insertMany([documentoA, documentoB], { ordered: false });

save() y create() devuelven el documento ya persistido, con su _id, sus timestamps y sus virtuals. insertMany es mucho más rápido que un bucle de create, pero por defecto es ordenado: si el tercer documento falla, los dos primeros ya están escritos y los siguientes no se intentan; con { ordered: false } intenta todos y te informa de los fallidos, que es lo que querrás en una semilla. Cuál usar: new + save() te deja aplicar lógica entre construir y escribir (calcular el total, generar códigos); create() es más directo cuando los datos ya vienen completos.

Errores de clave duplicada y la jerarquía del módulo 6

eventoId y codigo tienen índice único. Si insertas un duplicado, MongoDB responde con E11000 duplicate key error. Si no lo traduces, el manejador del módulo 6 lo tratará como un bug y devolverá un 500. Pero no es un bug: es un conflicto de estado operativo, y merece un 409.

// src/repositorios/errores-mongo.js
'use strict';

const { ConflictoDeEstado, ErrorDeValidacion } = require('../errores.js');

/**
 * Traduce un error de Mongoose/MongoDB a la jerarquia de la aplicacion. Si no lo
 * reconoce lo devuelve tal cual: sera un 500, y bien esta, porque significa que
 * es un fallo que no habiamos previsto.
 */
function traducirErrorDeMongo(error) {
  // Clave duplicada: violacion de un indice unico -> 409.
  if (error.code === 11000) {
    const campo = Object.keys(error.keyPattern ?? {})[0] ?? 'desconocido';
    return new ConflictoDeEstado(`Ya existe un registro con el mismo ${campo}`, {
      codigo: 'ESTADO_INVALIDO', detalles: { campo },
    });
  }
  // Validacion del esquema: uno o varios campos incumplen las reglas -> 400.
  if (error.name === 'ValidationError') {
    return new ErrorDeValidacion('El documento no cumple el esquema', {
      codigo: 'DATOS_INVALIDOS',
      detalles: Object.entries(error.errors).map(([campo, f]) => ({ campo, mensaje: f.message })),
    });
  }
  // Identificador con forma invalida ('hola' no es un ObjectId) -> 400, no 500.
  if (error.name === 'CastError') {
    return new ErrorDeValidacion(`El valor de '${error.path}' no es valido`, {
      codigo: 'DATOS_INVALIDOS', detalles: { campo: error.path, valor: String(error.value) },
    });
  }
  return error;
}

module.exports = { traducirErrorDeMongo };

Fíjate dónde vive esta traducción: en la capa de repositorio, no en el controlador. Es coherente con la arquitectura de la lección 07-01: el controlador no debe saber que existe MongoDB, y por tanto tampoco qué es un E11000. El repositorio traduce del lenguaje del motor al de la aplicación.

Leer: Query perezosos, proyecciones y lean()

Aquí está la sorpresa que descoloca a casi todo el mundo la primera vez:

// Esto NO ejecuta ninguna consulta: construye un objeto Query.
const consulta = Evento.find({ sala: 'Teatro Almendra' });
console.log(consulta.constructor.name); // 'Query'

// Query tiene .then(), asi que await lo ejecuta: es un "thenable", no una
// promesa. Por eso se puede refinar antes de ejecutar.
const publicados = await consulta.where('estado').equals('publicado').sort({ titulo: 1 }).limit(20);

Que sea perezoso es una ventaja: puedes construir la consulta por partes según los filtros opcionales que llegan por query string y ejecutarla al final. La trampa es que una consulta sin await no hace nada y no da error. Regla: siempre await, o .exec(), que devuelve una promesa de verdad y mejora las trazas de pila.

const catalogo = await Evento.find({ estado: 'publicado' });     // array, vacio si no hay
const uno = await Evento.findOne({ eventoId: 'evt-001' });       // documento o null
const porId = await Evento.findById('507f1f77bcf86cd799439011'); // documento o null

Ninguna lanza si no encuentra nada: devuelven null o [], y convertir ese null en un RecursoNoEncontrado es trabajo del repositorio o del controlador. Con .orFail() lanza en vez de devolver null, lo que a veces es más cómodo.

Proyección: pedir solo los campos necesarios. Menos bytes, menos memoria, menos trabajo del motor.

// Inclusiva: solo estos campos (mas _id, salvo que lo excluyas).
await Evento.find({ estado: 'publicado' }, { eventoId: 1, titulo: 1, _id: 0 });
// Exclusiva: todo menos esto. No se pueden mezclar inclusion y exclusion
// (salvo con _id, que es la unica excepcion).
await Evento.find({}).select('-sesiones');

Ese último ejemplo importa: la portada del catálogo no necesita las 7 sesiones con su aforo y su precio, solo el título y la sala.

lean() devuelve objetos JavaScript planos en vez de documentos de Mongoose. Por defecto, Mongoose envuelve cada resultado en una instancia con seguimiento de cambios, getters, virtuals y métodos: construirla es caro. Un objeto lean no tiene save(), ni virtuals, ni conversión de getters —te llegan los tipos BSON crudos—, pero se construye casi gratis.

La regla: usa lean() en toda lectura que termine en res.json(); no lo uses cuando vayas a modificar el documento. En listados grandes la diferencia es de varias veces en tiempo y memoria. En Escena Viva hay un matiz: nuestro repositorio devuelve objetos del dominio, así que hará lean() y luego Evento.desdeJSON(...), más barato que hidratar dos veces.

Ordenar, limitar y paginar

// Paginacion clasica por desplazamiento.
const eventos = await Evento.find({ estado: 'publicado' })
  .sort({ titulo: 1 })
  .skip((pagina - 1) * porPagina)
  .limit(porPagina)
  .lean();

Para las primeras páginas es perfecto. Pero skip se degrada linealmente: para servir la página 5.000 el motor localiza y descarta 100.000 documentos antes de empezar a devolver; no los salta mágicamente, los recorre. Además, si alguien inserta o borra entre dos peticiones, verás elementos repetidos o te saltarás alguno, porque el desplazamiento se calcula sobre un conjunto que ha cambiado. La alternativa es la paginación por cursor (o keyset): en vez de "sáltate 100.000", dices "dame los siguientes a partir de este valor".

/** Paginacion por cursor sobre un campo ordenado y unico (aqui, el _id). */
async function paginarEventos({ despuesDe = null, limite = 20 } = {}) {
  const filtro = { estado: 'publicado' };
  // Solo documentos posteriores al ultimo visto: usa el indice, no descarta nada.
  if (despuesDe) filtro._id = { $gt: despuesDe };
  const documentos = await Evento.find(filtro).sort({ _id: 1 }).limit(limite).lean();
  return { documentos, siguiente: documentos.length === limite ? documentos.at(-1)._id : null };
}

El coste es constante independientemente de la profundidad y es estable ante inserciones. El precio: no puedes saltar a la página 37, solo avanzar. Para un catálogo con desplazamiento infinito o una API de consumo automático, es lo correcto.

Operadores de consulta esenciales

Operador Significado Ejemplo en Escena Viva
$eq / $ne Igual (implícito) / distinto { estado: { $ne: 'borrador' } }
$gt / $gte / $lt / $lte Comparaciones { precioCentimos: { $lte: 3000 } }
$in / $nin Está / no está en la lista { sala: { $in: ['Teatro Almendra', 'Auditorio Ribera'] } }
$and / $or Lógicos Publicados o del propio organizador
$regex / $exists Expresión regular / el campo existe Búsqueda por título
$expr Comparar dos campos entre sí vendidas < aforo
$elemMatch Condiciones sobre el mismo elemento de un array Sesiones en un rango
// 1. Eventos con alguna sesion en un rango de fechas.
// OJO: sin $elemMatch, las dos condiciones pueden cumplirlas sesiones DISTINTAS.
const rango = { $gte: new Date('2026-03-01'), $lt: new Date('2026-03-16') };
await Evento.find({ sesiones: { $elemMatch: { fechaHora: rango } } }).lean();

// 2. Eventos con alguna sesion con entradas libres: comparar dos campos del
// mismo documento exige $expr, porque el filtro normal compara con literales.
await Evento.find({
  estado: 'publicado',
  sesiones: { $elemMatch: { $expr: { $lt: ['$vendidas', '$aforo'] } } },
}).lean();

// 3. Busqueda por titulo insensible a mayusculas. Escapamos la entrada del
// usuario: un regex sin escapar es un vector de denegacion de servicio.
await Evento.find({ titulo: { $regex: escapar(termino), $options: 'i' } }).lean();

El punto 1 es un error clásico: { 'sesiones.fechaHora': { $gte: a, $lt: b } } no significa "una sesión dentro del rango", sino "alguna sesión posterior a a y alguna anterior a b", que puede cumplir un evento sin ninguna sesión en marzo.

Actualizar: runValidators, $set, $inc

// Sin traer el documento: rapido, pero no dispara pre('save').
// Devuelve { acknowledged, matchedCount, modifiedCount, upsertedId }.
await Evento.updateOne({ eventoId: 'evt-003' }, { $set: { estado: 'publicado' } });

// Actualizar y recibir el documento resultante.
const actualizado = await Evento.findByIdAndUpdate(
  id,
  { $set: { duracionMinutos: 120 } },
  { new: true, runValidators: true },
);

Las dos opciones son casi obligatorias:

  • new: true devuelve el documento después del cambio. Por defecto —y esto sorprende a todo el mundo— devuelve el anterior. Sin ella responderás al cliente con datos viejos.
  • runValidators: true ejecuta los validadores del esquema. Sin ella te saltas la validación por completo: podrías poner estado: 'inventado' aunque haya un enum, o aforo: -5 aunque haya min: 1. Es el agujero más grande y silencioso de Mongoose. Actívalo globalmente con mongoose.set('runValidators', true). Y ojo con una limitación real: al validar una actualización, this es la consulta, no el documento, así que un validador que compara vendidas con aforo no tiene acceso a aforo si no lo estás actualizando también. La invariante crítica no puede descansar solo ahí.
Operador Qué hace Uso en Escena Viva
$set / $unset Asigna / elimina un campo Cambiar estado
$inc Suma (o resta) atómicamente Incrementar vendidas
$push / $pull Añade / quita de un array Añadir o retirar una sesión
$addToSet / $min / $max Añade si no está / asigna si es menor o mayor Etiquetas, marcas históricas

Y aquí aparece la primera pieza del problema del aforo:

// Incremento ATOMICO del contador de una sesion concreta. El operador posicional $
// apunta al elemento del array que ha casado con el filtro.
await Evento.updateOne(
  { eventoId: 'evt-001', 'sesiones.sesionId': 'ses-001-1' },
  { $inc: { 'sesiones.$.vendidas': 2 } },
);

¿Por qué es un avance? Porque $inc no hace leer-modificar-escribir en tu proceso: le manda al motor la instrucción "suma 2 a este campo", y el motor la aplica bajo su propio bloqueo de documento. Dos peticiones simultáneas con $inc: 1 dejan el contador en +2, nunca en +1. Se acabó la pérdida de actualizaciones.

Pero no basta, y conviene verlo ahora para no llevarse una falsa sensación de seguridad: $inc suma incondicionalmente. Si quedaba una entrada y llegan dos compras, ambas suman y vendidas acaba valiendo aforo + 1. Hemos resuelto la pérdida de escrituras, no la comprobación de la condición. La pieza que falta —un filtro que forme parte de la misma operación atómica— llega en la lección 07-06.

Borrar y borrado lógico

await Evento.deleteOne({ eventoId: 'evt-009' });
await Evento.findByIdAndDelete(id);                 // devuelve el borrado, o null
const { deletedCount } = await Entrada.deleteMany({ estado: 'anulada' });

// Pero en Escena Viva casi nunca borramos pedidos ni entradas: los marcamos.
await Pedido.findByIdAndUpdate(pedidoId, { $set: { estado: 'anulado' } }, { new: true, runValidators: true });
await Entrada.updateMany({ pedidoId }, { $set: { estado: 'anulada' } });

Las razones son de negocio: contabilidad (un pedido cobrado y luego anulado debe seguir apareciendo en el cierre del mes), trazabilidad (con el idPeticion del módulo 6 podemos reconstruir qué pasó, pero no si el documento no existe), integridad referencial (sin claves foráneas, borrar un pedido deja entradas huérfanas) y errores humanos (un borrado lógico se revierte cambiando un campo; uno físico, restaurando una copia de seguridad, con suerte). El precio —todas las consultas deben filtrar por estado— es bajo y previsible.

El repositorio: jubilando catalogo-datos.js

Ha llegado el momento. src/catalogo-datos.js exponía obtenerCatalogo y obtenerEventoPorId leyendo datos/eventos.json. El nuevo módulo expone exactamente lo mismo, leyendo MongoDB.

// src/repositorios/eventos.js
'use strict';

const { Evento: ModeloEvento } = require('../modelos/evento.js');
const { Evento } = require('../dominio/evento.js');
const { traducirErrorDeMongo } = require('./errores-mongo.js');

/**
 * Convierte un documento plano de Mongo en una instancia del dominio.
 * Es la frontera: de aqui hacia arriba, nadie sabe que existe Mongo.
 */
function aDominio({ eventoId, sesiones, ...resto }) {
  return Evento.desdeJSON({
    ...resto,
    id: eventoId,
    sesiones: sesiones.map(({ sesionId, fechaHora, ...datos }) => ({
      ...datos, // aforo, vendidas, precioCentimos
      id: sesionId,
      fechaHora: fechaHora.toISOString(),
    })),
  });
}

/** Catalogo completo, como instancias del dominio. */
async function obtenerCatalogo() {
  try {
    // lean(): solo leemos y transformamos, no necesitamos documentos de Mongoose.
    const docs = await ModeloEvento.find({ estado: { $ne: 'borrador' } }).sort({ titulo: 1 }).lean();
    return docs.map(aDominio);
  } catch (error) {
    throw traducirErrorDeMongo(error);
  }
}

/** Un evento por su identificador de negocio, o null si no existe. */
async function obtenerEventoPorId(eventoId) {
  const documento = await ModeloEvento.findOne({ eventoId }).lean();
  return documento ? aDominio(documento) : null;
}

module.exports = { obtenerCatalogo, obtenerEventoPorId };

El cambio en los controladores es literalmente una línea: donde src/controladores/eventos.js hacía require('../catalogo-datos.js'), ahora hace require('../repositorios/eventos.js'). Eso es todo. listarEventos, cargarEvento, obtenerEvento y listarSesionesDelEvento siguen funcionando sin tocar una coma, porque siguen recibiendo instancias de Evento del dominio con sus getters ocupacion, agotado y recaudacionCentimos. Esto es lo que compra el patrón repositorio, y es la misma puerta por la que entrará Sequelize en la lección 07-05.

POST /pedidos contra la base de datos

El controlador ya recibe datos validados por zod en req.datosValidados. Lo que cambia es de dónde salen y adónde van.

// src/repositorios/pedidos.js (requires omitidos por brevedad)
'use strict';

// Genera un codigo de entrada EV-<anio>-<6 digitos>.
const generarCodigo = (anio, n) => `EV-${anio}-${String(n).padStart(6, '0')}`;

async function crearPedido({ usuarioId, sesionId, cantidad, canal }) {
  const evento = await Evento.findOne({ 'sesiones.sesionId': sesionId });
  if (!evento) {
    throw new RecursoNoEncontrado(`No existe la sesion ${sesionId}`, { codigo: 'SESION_NO_ENCONTRADA' });
  }
  const sesion = evento.sesiones.find((elemento) => elemento.sesionId === sesionId);
  const libres = sesion.aforo - sesion.vendidas;
  if (libres < cantidad) {
    throw new ConflictoDeEstado('No quedan entradas suficientes', {
      codigo: 'AFORO_INSUFICIENTE',
      detalles: { libres, solicitadas: cantidad },
    });
  }

  try {
    // ATENCION: entre la comprobacion de aforo y este $inc hay una ventana en la
    // que otra peticion puede colarse. Sigue existiendo sobreventa. Lo resolvemos
    // en la leccion 07-06 con una actualizacion atomica condicional.
    await Evento.updateOne(
      { eventoId: evento.eventoId, 'sesiones.sesionId': sesionId },
      { $inc: { 'sesiones.$.vendidas': cantidad } },
    );

    const pedido = await Pedido.create({
      usuarioId, eventoId: evento.eventoId, sesionId, cantidad, canal,
      totalCentimos: sesion.precioCentimos * cantidad,
      estado: 'pagado',
    });

    const anio = new Date().getUTCFullYear();
    const base = await Entrada.countDocuments();
    const entradas = await Entrada.insertMany(
      Array.from({ length: cantidad }, (unused, i) => ({
        codigo: generarCodigo(anio, base + i + 1), pedidoId: pedido._id, sesionId, estado: 'valida',
      })),
    );
    return { pedido, entradas };
  } catch (error) {
    throw traducirErrorDeMongo(error);
  }
}

module.exports = { crearPedido };

Deja ese comentario en mayúsculas donde está. Es el problema del curso, ahora con nombre y línea concreta: hemos ganado atomicidad por operación, pero no por conjunto, y además el pedido y las entradas pueden crearse sin que el aforo se haya reservado, o al revés. En la 07-06 esta función se reescribirá entera y el comentario desaparecerá.

Errores de Mongoose y su estado HTTP

Error Cuándo aparece Traducción Estado
ValidationError Un campo incumple el esquema DATOS_INVALIDOS 400
CastError findById('hola'), fecha no parseable DATOS_INVALIDOS 400
E11000 Índice único violado ESTADO_INVALIDO 409
DocumentNotFoundError orFail() sin resultado ..._NO_ENCONTRADO 404
MongoServerSelectionError / MongoNetworkError La base no responde SERVICIO_EXTERNO_CAIDO 503

El CastError merece atención especial porque es el fallo más frecuente en producción: un cliente pide GET /pedidos/12345 con un identificador que no es un ObjectId, Mongoose no puede convertirlo y lanza. Sin traducción, tu aplicación devuelve un 500 y tu panel de alertas se llena de errores que no son bugs: son peticiones mal formadas y merecen un 400. La tabla ESTADO_POR_CODIGO del módulo 6 hace el resto; solo hay que darle el código correcto.

Errores Comunes y Consejos

  • Olvidar runValidators: true. Tu enum y tus min dejan de existir en las actualizaciones.
  • Olvidar new: true. Devuelves el estado anterior al cambio y pasas media tarde buscando el bug en el frontend.
  • No usar await. Un Query sin ejecutar no falla: simplemente no pasa nada, o pasa a destiempo.
  • Hidratar documentos completos para solo serializarlos. lean() y una proyección son la optimización más barata que existe.
  • Paginar con skip en un catálogo grande. Funciona hasta que no funciona, y falla justo cuando tienes tráfico.
  • Interpolar entrada de usuario en un $regex. Un patrón como (a+)+$ puede colgar el motor. Escapa siempre.
  • Consejo: usa .orFail() cuando la ausencia sea un error real, y activa mongoose.set('debug', true) en desarrollo: verás cada consulta que se envía y descubrirás consultas que no sabías que hacías.

Ejercicios

Ejercicio 1: pedidos por usuario

Añade a src/repositorios/pedidos.js una función listarPedidosDeUsuario(usuarioId, { pagina, porPagina }) que devuelva los pedidos no anulados de un usuario, del más reciente al más antiguo, con proyección de sesionId, cantidad, totalCentimos, estado y createdAt, usando lean(). Indica qué índice necesita.

Ejercicio 2: buscador del catálogo

Escribe buscarEventos({ sala, categoria, desde, hasta, soloConEntradas }) que construya el filtro de forma incremental (añadiendo solo las condiciones recibidas) y devuelva los eventos publicados que casen. Usa $elemMatch para el rango de fechas y $expr para las entradas libres.

Ejercicio 3: anular un pedido

Implementa anularPedido(pedidoId) que compruebe que el pedido existe y está en estado pagado o emitido (si no, ConflictoDeEstado con ESTADO_INVALIDO), lo marque como anulado, marque sus entradas como anuladas y devuelva el aforo. Anota qué pasa si el proceso muere entre dos de esas operaciones.

Soluciones

Ejercicio 1.

async function listarPedidosDeUsuario(usuarioId, { pagina = 1, porPagina = 20 } = {}) {
  return Pedido.find({ usuarioId, estado: { $ne: 'anulado' } })
    .select('sesionId cantidad totalCentimos estado createdAt')
    .sort({ createdAt: -1 })
    .skip((pagina - 1) * porPagina)
    .limit(porPagina)
    .lean();
}

Índice: { usuarioId: 1, estado: 1, createdAt: -1 }, siguiendo la regla ESR — igualdad primero, ordenación después y en el mismo sentido.

Ejercicio 2.

async function buscarEventos({ sala, categoria, desde, hasta, soloConEntradas = false } = {}) {
  const filtro = { estado: 'publicado' };
  if (sala) filtro.sala = sala;
  if (categoria) filtro.categoria = categoria;

  const condiciones = {};
  if (desde) condiciones.fechaHora = { $gte: new Date(desde) };
  if (hasta) condiciones.fechaHora = { ...condiciones.fechaHora, $lte: new Date(hasta) };
  if (soloConEntradas) condiciones.$expr = { $lt: ['$vendidas', '$aforo'] };
  if (Object.keys(condiciones).length > 0) filtro.sesiones = { $elemMatch: condiciones };

  return Evento.find(filtro).sort({ titulo: 1 }).lean();
}

Ejercicio 3.

async function anularPedido(pedidoId) {
  const pedido = await Pedido.findById(pedidoId).orFail();
  if (!['pagado', 'emitido'].includes(pedido.estado)) {
    throw new ConflictoDeEstado(`Un pedido ${pedido.estado} no se puede anular`, { codigo: 'ESTADO_INVALIDO' });
  }
  pedido.estado = 'anulado';
  await pedido.save();
  await Entrada.updateMany({ pedidoId }, { $set: { estado: 'anulada' } });
  await Evento.updateOne(
    { eventoId: pedido.eventoId, 'sesiones.sesionId': pedido.sesionId },
    { $inc: { 'sesiones.$.vendidas': -pedido.cantidad } },
  );
  // Si el proceso muere entre estas tres escrituras, el estado queda incoherente:
  // pedido anulado con entradas validas, o aforo nunca devuelto. Cada operacion es
  // atomica por separado, pero el CONJUNTO no lo es. Eso son las transacciones (07-06).
  return pedido;
}

Conclusión

Ya sabes mover datos con Mongoose de verdad: crear con save() o create() sabiendo qué devuelve cada uno, leer entendiendo que un Query es perezoso y que lean() y las proyecciones son la optimización más rentable, paginar sin caer en la trampa de skip, filtrar con los operadores adecuados —incluidos $elemMatch y $expr, que evitan errores sutiles sobre arrays—, actualizar sin saltarte la validación y borrar lógicamente para no perder la historia. Y sabes traducir los errores del motor a la jerarquía del módulo 6 desde la capa correcta. Sobre todo, src/catalogo-datos.js está jubilado: src/repositorios/eventos.js ocupa su lugar con la misma firma, los controladores no se han enterado y el dominio sigue recibiendo sus instancias de Evento. La arquitectura de la lección 07-01 acaba de demostrar que servía para algo.

Queda una deuda anotada en mayúsculas dentro de crearPedido: $inc nos ha dado atomicidad por operación, pero no por conjunto, y la sobreventa sigue viva. En la lección siguiente aparcamos ese problema un poco más para atacar el modelado avanzado: incrustar frente a referenciar, populate y el problema N+1, desnormalización deliberada, el framework de agregación aplicado a informes reales de Escena Viva —que sustituirán a los que en el módulo 3 hacíamos leyendo CSV— e índices en serio, con explain() para ver si tus consultas los usan o están recorriendo la colección entera.

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