Skip to content

Mongoose con Node ​

Mongoose es la librería más usada para trabajar con MongoDB desde Node.js. Agrega lo que MongoDB deja en tus manos: esquemas con tipos y validaciones, modelos con métodos cómodos, relaciones con populate, middleware (hooks), campos virtuales y marcas de tiempo automáticas. En esta lección aprenderás todo eso y construirás un proyecto completo: la API REST de la tienda con Express y Mongoose, lista para desplegar.

Concepto ​

txt
Tu código (Express)
      │  Producto.find({ categoria: "Libros" })
      ▼
┌───────────────┐   valida, convierte tipos, aplica defaults, ejecuta hooks
│   Mongoose    │
│ (Schema/Model)│
└──────┬────────┘
       │  usa el driver oficial "mongodb"
       ▼
┌───────────────┐
│   MongoDB     │
└───────────────┘
ConceptoQué esAnalogía
SchemaLa definición de la forma de los documentos: campos, tipos, reglasEl plano de una casa
ModelUna "clase" construida con el schema, ligada a una colecciónLa constructora que usa el plano
DocumentUna instancia del modelo (un documento concreto)Una casa construida

¿Driver oficial o Mongoose? El driver mongodb es más ligero y flexible; Mongoose te da estructura y validación. Para APIs y apps con un modelo de datos claro, Mongoose ahorra mucho código y errores.

Instalación y conexión ​

bash
mkdir tienda-api && cd tienda-api
npm init -y
npm pkg set type=module
npm install mongoose express dotenv
npm install --save-dev nodemon
env
# .env
MONGODB_URI="mongodb+srv://tienda_app:CLAVE@tienda-cluster.ab1cd.mongodb.net/tienda?retryWrites=true&w=majority"
PORT=3000
js
// src/db.js
import mongoose from 'mongoose';

export async function conectarDB(uri) {
  mongoose.set('strictQuery', true);          // ignora campos no definidos en los filtros
  mongoose.set('sanitizeFilter', true);       // protege contra inyección de operadores ($ne, $gt...)

  mongoose.connection.on('connected', () => console.log('MongoDB conectado'));
  mongoose.connection.on('error', (err) => console.error('Error de MongoDB:', err.message));
  mongoose.connection.on('disconnected', () => console.warn('MongoDB desconectado'));

  await mongoose.connect(uri, {
    maxPoolSize: 10,                          // conexiones máximas
    serverSelectionTimeoutMS: 5000            // falla rápido si no encuentra el servidor
  });
}

Mongoose mantiene un pool de conexiones compartido: conecta una sola vez al iniciar la app y reutiliza esa conexión en todas las peticiones.

Schemas y modelos ​

js
// src/models/Producto.js
import mongoose from 'mongoose';

const productoSchema = new mongoose.Schema(
  {
    nombre: {
      type: String,
      required: [true, 'El nombre es obligatorio'],
      trim: true,
      minlength: [2, 'El nombre debe tener al menos 2 caracteres'],
      maxlength: 100
    },
    slug: { type: String, unique: true, lowercase: true },
    categoria: {
      type: String,
      enum: { values: ['Electrónica', 'Libros', 'Hogar', 'Deportes'], message: 'Categoría inválida: {VALUE}' },
      index: true
    },
    precio: { type: Number, required: true, min: [0, 'El precio no puede ser negativo'] },
    stock: {
      type: Number,
      default: 0,
      min: 0,
      validate: { validator: Number.isInteger, message: 'El stock debe ser un entero' }
    },
    etiquetas: { type: [String], default: [] },
    atributos: { type: mongoose.Schema.Types.Mixed, default: {} },   // libre (atributos variables)
    activo: { type: Boolean, default: true }
  },
  {
    timestamps: true,                          // agrega createdAt y updatedAt automáticamente
    toJSON: { virtuals: true },                // incluir virtuals al convertir a JSON
    toObject: { virtuals: true }
  }
);

// Índices
productoSchema.index({ categoria: 1, precio: 1 });
productoSchema.index({ nombre: 'text' }, { default_language: 'spanish' });

export const Producto = mongoose.model('Producto', productoSchema);
// Colección: "productos" (Mongoose pluraliza y pasa a minúsculas el nombre del modelo)

Tipos de Mongoose ​

TipoEjemplo
String{ type: String, trim: true, lowercase: true, match: /regex/ }
Number{ type: Number, min: 0, max: 100 }
Boolean{ type: Boolean, default: false }
Date{ type: Date, default: Date.now }
ObjectId{ type: mongoose.Schema.Types.ObjectId, ref: 'Cliente' }
Array[String], [{ cantidad: Number }]
Decimal128{ type: mongoose.Schema.Types.Decimal128 } (dinero exacto)
MixedCualquier cosa (sin validación)
Map{ type: Map, of: String } (claves dinámicas)
SubdocumentoOtro schema anidado

Validaciones ​

ValidadorAplica a
requiredTodos
min, maxNumber, Date
minlength, maxlength, match, enumString
validatePersonalizado (síncrono o asíncrono)
uniqueNo es un validador: crea un índice único (el error llega de MongoDB, código 11000)
js
// Validador personalizado asíncrono: el email no debe estar en una lista negra
email: {
  type: String,
  validate: {
    validator: async (valor) => !(await ListaNegra.exists({ email: valor })),
    message: 'Ese correo no está permitido'
  }
}

Las validaciones y los updates

Las validaciones se ejecutan en save() y create(). En updateOne, findByIdAndUpdate, etc., no se ejecutan por defecto: pasa la opción { runValidators: true }. Es uno de los errores más comunes con Mongoose.

Subdocumentos y referencias ​

js
// src/models/Cliente.js
import mongoose from 'mongoose';

const direccionSchema = new mongoose.Schema(
  {
    alias: { type: String, default: 'Casa' },
    calle: { type: String, required: true },
    ciudad: { type: String, required: true },
    cp: { type: String, required: true, match: [/^\d{5}$/, 'CP de 5 dígitos'] },
    principal: { type: Boolean, default: false }
  },
  { _id: false }                               // sin _id propio para cada dirección
);

const clienteSchema = new mongoose.Schema(
  {
    nombre: { type: String, required: true, trim: true },
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^\S+@\S+\.\S+$/, 'Email inválido']
    },
    direcciones: {
      type: [direccionSchema],                 // 1:pocos → embebido
      validate: [(arr) => arr.length <= 5, 'Máximo 5 direcciones']
    }
  },
  { timestamps: true }
);

export const Cliente = mongoose.model('Cliente', clienteSchema);
js
// src/models/Pedido.js
import mongoose from 'mongoose';

const itemSchema = new mongoose.Schema(
  {
    producto: { type: mongoose.Schema.Types.ObjectId, ref: 'Producto', required: true },
    nombre: { type: String, required: true },          // copia histórica
    precio: { type: Number, required: true, min: 0 },  // precio al momento de la compra
    cantidad: { type: Number, required: true, min: 1 }
  },
  { _id: false }
);

const pedidoSchema = new mongoose.Schema(
  {
    cliente: { type: mongoose.Schema.Types.ObjectId, ref: 'Cliente', required: true, index: true },
    items: {
      type: [itemSchema],
      validate: [(arr) => arr.length > 0, 'El pedido debe tener al menos un producto']
    },
    estado: {
      type: String,
      enum: ['pendiente', 'pagado', 'enviado', 'entregado', 'cancelado'],
      default: 'pendiente'
    },
    total: { type: Number, min: 0 }
  },
  { timestamps: true }
);

pedidoSchema.index({ cliente: 1, createdAt: -1 });

// Middleware: calcular el total antes de validar/guardar
pedidoSchema.pre('validate', function () {
  this.total = this.items.reduce((suma, it) => suma + it.precio * it.cantidad, 0);
});

// Virtual: número de piezas (no se guarda en la base)
pedidoSchema.virtual('piezas').get(function () {
  return this.items.reduce((suma, it) => suma + it.cantidad, 0);
});

export const Pedido = mongoose.model('Pedido', pedidoSchema);

CRUD con Mongoose ​

js
import { Producto } from './models/Producto.js';

// CREATE
const p = await Producto.create({ nombre: 'Bocina portátil', categoria: 'Electrónica', precio: 749, stock: 12 });
// o: const p = new Producto({...}); await p.save();

// READ
const todos = await Producto.find();
const libros = await Producto.find({ categoria: 'Libros' }).sort({ precio: 1 }).select('nombre precio');
const uno = await Producto.findById('66a2b7f1c4e5d6a7b8c9d0e1');      // null si no existe
const barato = await Producto.findOne({ precio: { $lt: 200 } });
const total = await Producto.countDocuments({ activo: true });
const existe = await Producto.exists({ slug: 'taza-termica' });

// Consultas encadenables
const pagina = await Producto.find({ precio: { $gte: 100, $lte: 1000 } })
  .sort({ precio: -1 })
  .skip(0)
  .limit(10)
  .lean();                                    // objetos JS planos (más rápido, sin métodos)

// UPDATE
await Producto.updateOne({ _id: p._id }, { $inc: { stock: -1 } });
const actualizado = await Producto.findByIdAndUpdate(
  p._id,
  { precio: 699 },                            // Mongoose lo convierte en $set
  { new: true, runValidators: true }          // devolver el nuevo y validar
);

// Modificar y guardar un documento (ejecuta validaciones y middleware de save)
const doc = await Producto.findById(p._id);
doc.etiquetas.push('oferta');
await doc.save();

// DELETE
await Producto.deleteOne({ _id: p._id });
await Producto.findByIdAndDelete(p._id);

lean(): cuándo usarlo ​

Por defecto, find devuelve documentos de Mongoose (con métodos como save(), getters, virtuals, seguimiento de cambios). Eso cuesta memoria y tiempo. Para lecturas de solo mostrar (listados de una API), usa .lean(): devuelve objetos planos hasta varias veces más rápido.

Sin lean()Con lean()
TipoDocumento MongooseObjeto JS plano
save(), métodos, virtualsSíNo
RendimientoNormalMayor
UsoLeer para modificarLeer para responder

populate: resolver referencias ​

populate reemplaza un ObjectId por el documento referenciado (Mongoose hace una consulta extra con $in, no un $lookup):

js
const pedido = await Pedido.findById(id)
  .populate('cliente', 'nombre email')          // solo esos campos
  .populate('items.producto', 'nombre categoria')
  .lean();

// {
//   _id: ..., estado: "pendiente", total: 1648,
//   cliente: { _id: ..., nombre: "Lucía Gómez", email: "lucia@correo.com" },
//   items: [ { producto: { _id: ..., nombre: "Teclado mecánico", categoria: "Electrónica" }, nombre: ..., precio: 1250, cantidad: 1 }, ... ]
// }

populate con moderación

Cada populate es otra consulta. Úsalo para pantallas de detalle; en listados grandes, prefiere guardar los campos necesarios en el documento (referencia extendida, como items.nombre) o usa una agregación con $lookup.

Middleware (hooks) ​

Funciones que se ejecutan antes (pre) o después (post) de ciertas operaciones:

js
// Generar el slug antes de guardar
productoSchema.pre('save', function () {
  if (this.isModified('nombre')) {
    this.slug = this.nombre
      .toLowerCase()
      .normalize('NFD').replace(/[̀-ͯ]/g, '')   // quitar acentos
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '');
  }
});

// Excluir productos inactivos de todas las búsquedas find*
productoSchema.pre(/^find/, function () {
  if (this.getOptions().incluirInactivos !== true) {
    this.where({ activo: true });
  }
});

// Registrar después de borrar
productoSchema.post('findOneAndDelete', function (doc) {
  if (doc) console.log(`Producto eliminado: ${doc.nombre}`);
});
TipoOperacionesthis es
Document middlewaresave, validate, deleteOne (de documento)El documento
Query middlewarefind, findOne, updateOne, findOneAndUpdate, deleteMany...La consulta
Aggregate middlewareaggregateEl pipeline

Funciones normales, no flechas

En schemas usa function () {} y no funciones flecha en middleware, virtuals y métodos: necesitas que this apunte al documento o a la consulta.

Métodos y estáticos ​

js
// Método de instancia: se llama sobre un documento
productoSchema.methods.hayStock = function (cantidad = 1) {
  return this.stock >= cantidad;
};

// Estático: se llama sobre el modelo
productoSchema.statics.buscarTexto = function (texto) {
  return this.find({ $text: { $search: texto } }, { score: { $meta: 'textScore' } })
    .sort({ score: { $meta: 'textScore' } });
};

// Uso
const prod = await Producto.findById(id);
if (prod.hayStock(3)) { /* ... */ }
const resultados = await Producto.buscarTexto('audifonos inalambricos');

(Define métodos, estáticos y middleware antes de llamar a mongoose.model(...).)

Transacciones con Mongoose ​

js
import mongoose from 'mongoose';

export async function crearPedido(clienteId, items) {
  const session = await mongoose.startSession();
  try {
    let pedido;
    await session.withTransaction(async () => {      // reintenta errores transitorios
      const lineas = [];
      for (const { productoId, cantidad } of items) {
        const producto = await Producto.findOneAndUpdate(
          { _id: productoId, stock: { $gte: cantidad }, activo: true },
          { $inc: { stock: -cantidad } },
          { new: true, session }
        );
        if (!producto) {
          throw Object.assign(new Error(`Sin stock suficiente del producto ${productoId}`), { status: 409 });
        }
        lineas.push({ producto: producto._id, nombre: producto.nombre, precio: producto.precio, cantidad });
      }
      [pedido] = await Pedido.create([{ cliente: clienteId, items: lineas }], { session });
    });
    return pedido;
  } finally {
    await session.endSession();
  }
}

Requiere un replica set (Atlas lo es; con Docker local inicia Mongo con --replSet rs0 y ejecuta rs.initiate()).

Proyecto: API REST de la tienda ​

Estructura ​

txt
tienda-api/
├── .env
├── package.json
└── src/
    ├── index.js              ← arranque: conexión + servidor
    ├── app.js                ← configuración de Express
    ├── db.js
    ├── models/
    │   ├── Producto.js
    │   ├── Cliente.js
    │   └── Pedido.js
    ├── routes/
    │   ├── productos.js
    │   ├── clientes.js
    │   └── pedidos.js
    ├── services/
    │   └── pedidos.js        ← crearPedido con transacción (arriba)
    └── middleware/
        └── errores.js
json
{
  "name": "tienda-api",
  "type": "module",
  "scripts": {
    "dev": "nodemon src/index.js",
    "start": "node src/index.js",
    "seed": "node src/seed.js"
  }
}

Arranque ​

js
// src/index.js
import 'dotenv/config';
import mongoose from 'mongoose';
import { conectarDB } from './db.js';
import { app } from './app.js';

const puerto = process.env.PORT ?? 3000;

await conectarDB(process.env.MONGODB_URI);
const servidor = app.listen(puerto, () => console.log(`API en http://localhost:${puerto}`));

// Cierre ordenado (Docker, PM2, Render envían SIGTERM)
process.on('SIGTERM', async () => {
  servidor.close();
  await mongoose.connection.close();
  process.exit(0);
});
js
// src/app.js
import express from 'express';
import { router as productos } from './routes/productos.js';
import { router as clientes } from './routes/clientes.js';
import { router as pedidos } from './routes/pedidos.js';
import { manejarErrores, noEncontrado } from './middleware/errores.js';

export const app = express();

app.use(express.json({ limit: '100kb' }));

app.get('/salud', (req, res) => res.json({ ok: true }));
app.use('/api/productos', productos);
app.use('/api/clientes', clientes);
app.use('/api/pedidos', pedidos);

app.use(noEncontrado);
app.use(manejarErrores);

Rutas de productos ​

js
// src/routes/productos.js
import { Router } from 'express';
import mongoose from 'mongoose';
import { Producto } from '../models/Producto.js';

export const router = Router();

const CAMPOS_EDITABLES = ['nombre', 'categoria', 'precio', 'stock', 'etiquetas', 'atributos', 'activo'];

function soloPermitidos(body) {
  return Object.fromEntries(Object.entries(body).filter(([k]) => CAMPOS_EDITABLES.includes(k)));
}

function validarId(req, res, next) {
  if (!mongoose.isValidObjectId(req.params.id)) {
    return res.status(400).json({ error: 'Id inválido' });
  }
  next();
}

// GET /api/productos?categoria=Libros&min=100&max=1000&q=texto&pagina=1&limite=10&orden=precio
router.get('/', async (req, res, next) => {
  try {
    const { categoria, min, max, q, etiqueta } = req.query;
    const pagina = Math.max(1, parseInt(req.query.pagina) || 1);
    const limite = Math.min(50, parseInt(req.query.limite) || 10);
    const ordenes = { precio: { precio: 1 }, '-precio': { precio: -1 }, nombre: { nombre: 1 }, recientes: { createdAt: -1 } };
    const orden = ordenes[req.query.orden] ?? { nombre: 1 };

    const filtro = {};
    if (categoria) filtro.categoria = String(categoria);
    if (etiqueta) filtro.etiquetas = String(etiqueta);
    if (min || max) {
      filtro.precio = {};
      if (min) filtro.precio.$gte = Number(min);
      if (max) filtro.precio.$lte = Number(max);
    }
    if (q) filtro.$text = { $search: String(q) };

    const [datos, total] = await Promise.all([
      Producto.find(filtro).sort(orden).skip((pagina - 1) * limite).limit(limite)
        .select('nombre slug categoria precio stock etiquetas').lean(),
      Producto.countDocuments(filtro)
    ]);

    res.json({ datos, pagina, limite, total, paginas: Math.ceil(total / limite) });
  } catch (err) { next(err); }
});

router.get('/:id', validarId, async (req, res, next) => {
  try {
    const producto = await Producto.findById(req.params.id).lean();
    if (!producto) return res.status(404).json({ error: 'Producto no encontrado' });
    res.json(producto);
  } catch (err) { next(err); }
});

router.post('/', async (req, res, next) => {
  try {
    const producto = await Producto.create(soloPermitidos(req.body));
    res.status(201).json(producto);
  } catch (err) { next(err); }
});

router.patch('/:id', validarId, async (req, res, next) => {
  try {
    const producto = await Producto.findByIdAndUpdate(req.params.id, soloPermitidos(req.body), {
      new: true,
      runValidators: true
    });
    if (!producto) return res.status(404).json({ error: 'Producto no encontrado' });
    res.json(producto);
  } catch (err) { next(err); }
});

// Borrado lógico: los productos con pedidos no se eliminan físicamente
router.delete('/:id', validarId, async (req, res, next) => {
  try {
    const producto = await Producto.findByIdAndUpdate(req.params.id, { activo: false }, { new: true });
    if (!producto) return res.status(404).json({ error: 'Producto no encontrado' });
    res.status(204).end();
  } catch (err) { next(err); }
});

soloPermitidos evita la asignación masiva: aunque alguien envíe { "_id": ..., "createdAt": ... } u otros campos, solo se guardan los permitidos.

Rutas de clientes y pedidos ​

js
// src/routes/clientes.js
import { Router } from 'express';
import { Cliente } from '../models/Cliente.js';
import { Pedido } from '../models/Pedido.js';

export const router = Router();

router.post('/', async (req, res, next) => {
  try {
    const { nombre, email, direcciones } = req.body;
    const cliente = await Cliente.create({ nombre, email, direcciones });
    res.status(201).json(cliente);
  } catch (err) { next(err); }
});

// Historial de pedidos de un cliente (sin populate: los items ya traen nombre y precio)
router.get('/:id/pedidos', async (req, res, next) => {
  try {
    const pedidos = await Pedido.find({ cliente: req.params.id })
      .sort({ createdAt: -1 })
      .select('estado total items.nombre items.cantidad createdAt')
      .lean();
    res.json(pedidos);
  } catch (err) { next(err); }
});
js
// src/routes/pedidos.js
import { Router } from 'express';
import { Pedido } from '../models/Pedido.js';
import { crearPedido } from '../services/pedidos.js';

export const router = Router();

router.post('/', async (req, res, next) => {
  try {
    const { clienteId, items } = req.body;   // items: [{ productoId, cantidad }]
    if (!clienteId || !Array.isArray(items) || items.length === 0) {
      return res.status(400).json({ error: 'clienteId e items son obligatorios' });
    }
    const limpios = items.map(({ productoId, cantidad }) => ({
      productoId: String(productoId),
      cantidad: Math.max(1, parseInt(cantidad) || 1)
    }));
    const pedido = await crearPedido(clienteId, limpios);
    res.status(201).json(pedido);
  } catch (err) { next(err); }
});

router.get('/:id', async (req, res, next) => {
  try {
    const pedido = await Pedido.findById(req.params.id)
      .populate('cliente', 'nombre email')
      .lean({ virtuals: true });
    if (!pedido) return res.status(404).json({ error: 'Pedido no encontrado' });
    res.json(pedido);
  } catch (err) { next(err); }
});

// Reporte: ventas por mes (agregación)
router.get('/reportes/ventas-mensuales', async (req, res, next) => {
  try {
    const reporte = await Pedido.aggregate([
      { $match: { estado: { $ne: 'cancelado' } } },
      { $group: {
          _id: { $dateToString: { format: '%Y-%m', date: '$createdAt', timezone: 'America/Mexico_City' } },
          pedidos: { $sum: 1 },
          ventas: { $sum: '$total' }
      } },
      { $sort: { _id: 1 } }
    ]);
    res.json(reporte);
  } catch (err) { next(err); }
});

(lean({ virtuals: true }) requiere el plugin mongoose-lean-virtuals; sin él, quita la opción y calcula piezas en el cliente.)

Orden de las rutas

Express evalúa las rutas en orden: /reportes/ventas-mensuales funciona aquí porque tiene dos segmentos y no coincide con /:id. Si tuvieras /reportes (un solo segmento), debería declararse antes de /:id.

Manejo centralizado de errores ​

js
// src/middleware/errores.js
export function noEncontrado(req, res) {
  res.status(404).json({ error: `Ruta no encontrada: ${req.method} ${req.originalUrl}` });
}

export function manejarErrores(err, req, res, next) {
  // Errores de validación de Mongoose
  if (err.name === 'ValidationError') {
    const errores = Object.fromEntries(
      Object.entries(err.errors).map(([campo, e]) => [campo, e.message])
    );
    return res.status(422).json({ error: 'Datos inválidos', errores });
  }
  // Id con formato inválido
  if (err.name === 'CastError') {
    return res.status(400).json({ error: `Valor inválido para ${err.path}` });
  }
  // Índice único (email, slug duplicados)
  if (err.code === 11000) {
    const campo = Object.keys(err.keyValue ?? {})[0];
    return res.status(409).json({ error: `Ya existe un registro con ese ${campo}` });
  }
  if (err.status) {
    return res.status(err.status).json({ error: err.message });
  }
  console.error(err);
  res.status(500).json({ error: 'Error interno del servidor' });
}

Datos de prueba ​

js
// src/seed.js
import 'dotenv/config';
import mongoose from 'mongoose';
import { conectarDB } from './db.js';
import { Producto } from './models/Producto.js';
import { Cliente } from './models/Cliente.js';
import { Pedido } from './models/Pedido.js';

await conectarDB(process.env.MONGODB_URI);
await Promise.all([Producto.deleteMany({}), Cliente.deleteMany({}), Pedido.deleteMany({})]);

// create() de uno en uno para que se ejecute el middleware que genera el slug
const productos = [];
for (const datos of [
  { nombre: 'Audífonos Bluetooth', categoria: 'Electrónica', precio: 899, stock: 25, etiquetas: ['audio'] },
  { nombre: 'Teclado mecánico', categoria: 'Electrónica', precio: 1250, stock: 10, etiquetas: ['gamer'] },
  { nombre: 'Libro Aprende SQL', categoria: 'Libros', precio: 349, stock: 40 },
  { nombre: 'Taza térmica', categoria: 'Hogar', precio: 199, stock: 50 }
]) {
  productos.push(await Producto.create(datos));
}

const ana = await Cliente.create({
  nombre: 'Ana López',
  email: 'ana@correo.com',
  direcciones: [{ calle: 'Av. Reforma 100', ciudad: 'Ciudad de México', cp: '06600', principal: true }]
});

await Pedido.create({
  cliente: ana._id,
  items: [
    { producto: productos[0]._id, nombre: productos[0].nombre, precio: productos[0].precio, cantidad: 1 },
    { producto: productos[2]._id, nombre: productos[2].nombre, precio: productos[2].precio, cantidad: 2 }
  ]
});   // total calculado por el middleware: 899 + 698 = 1597

console.log('Datos de prueba listos');
await mongoose.disconnect();

Probar la API ​

bash
npm run seed
npm run dev

curl "http://localhost:3000/api/productos?categoria=Electrónica&orden=-precio"
curl -X POST http://localhost:3000/api/productos \
  -H "Content-Type: application/json" \
  -d '{"nombre":"Bocina portátil","categoria":"Electrónica","precio":749,"stock":12}'

# Error de validación (precio negativo, categoría inválida) → 422 con el detalle por campo
curl -X POST http://localhost:3000/api/productos \
  -H "Content-Type: application/json" \
  -d '{"nombre":"X","categoria":"Juguetes","precio":-5}'

# Crear pedido (usa ids reales de tu base)
curl -X POST http://localhost:3000/api/pedidos \
  -H "Content-Type: application/json" \
  -d '{"clienteId":"ID_CLIENTE","items":[{"productoId":"ID_PRODUCTO","cantidad":2}]}'

Respuesta de validación:

json
{
  "error": "Datos inválidos",
  "errores": {
    "nombre": "El nombre debe tener al menos 2 caracteres",
    "categoria": "Categoría inválida: Juguetes",
    "precio": "El precio no puede ser negativo"
  }
}

Buenas prácticas de producción ​

Checklist

  • Una sola conexión al iniciar la app (pool), con serverSelectionTimeoutMS y cierre ordenado en SIGTERM.
  • MONGODB_URI en variables de entorno; usuario de base con permisos solo sobre su base.
  • sanitizeFilter o express-mongo-sanitize contra inyección de operadores; nunca pases req.body como filtro.
  • Lista blanca de campos editables (evita asignación masiva) y runValidators: true en updates.
  • .lean() en lecturas, select() para traer solo lo necesario, paginación con límite máximo.
  • Índices definidos en los schemas; en producción considera autoIndex: false y créalos en el despliegue (Model.syncIndexes()), porque construir índices al arrancar puede ser lento.
  • Errores de Mongoose traducidos a HTTP (422 validación, 400 cast, 409 duplicado) sin exponer detalles internos.
  • Transacciones para operaciones multi-documento críticas (pedidos e inventario).
  • Monitorea con Atlas (Performance Advisor, alertas) y haz respaldos (mongodump o los snapshots de Atlas).
  • Valida también la entrada de la API con una librería como Zod o Joi antes de llegar a Mongoose, para respuestas consistentes.

Ejercicios ​

  1. Agrega al modelo Producto un virtual disponible que devuelva true si stock > 0 && activo.
  2. Crea un modelo Resena (producto, cliente, calificación 1–5 entera, comentario de máximo 500 caracteres) con un índice único para que cada cliente reseñe un producto solo una vez.
  3. Agrega a Resena un middleware post('save') que recalcule el promedio de calificación del producto y lo guarde en producto.calificacion.
  4. Crea el endpoint GET /api/productos/reportes/por-categoria que devuelva, por categoría, el número de productos, el stock total y el precio promedio redondeado.
Ver solución
js
// 1 (en Producto.js, antes de mongoose.model)
productoSchema.virtual('disponible').get(function () {
  return this.stock > 0 && this.activo;
});
js
// 2 y 3 — src/models/Resena.js
import mongoose from 'mongoose';
import { Producto } from './Producto.js';

const resenaSchema = new mongoose.Schema(
  {
    producto: { type: mongoose.Schema.Types.ObjectId, ref: 'Producto', required: true },
    cliente: { type: mongoose.Schema.Types.ObjectId, ref: 'Cliente', required: true },
    calificacion: {
      type: Number, required: true, min: 1, max: 5,
      validate: { validator: Number.isInteger, message: 'Debe ser un entero' }
    },
    comentario: { type: String, maxlength: 500, trim: true }
  },
  { timestamps: true }
);

resenaSchema.index({ producto: 1, cliente: 1 }, { unique: true });

resenaSchema.post('save', async function () {
  const [stats] = await this.constructor.aggregate([
    { $match: { producto: this.producto } },
    { $group: { _id: '$producto', promedio: { $avg: '$calificacion' }, total: { $sum: 1 } } }
  ]);
  await Producto.updateOne(
    { _id: this.producto },
    { $set: { calificacion: { promedio: Math.round(stats.promedio * 10) / 10, total: stats.total } } },
    { strict: false }   // el campo no está en el schema de Producto; agrégalo ahí idealmente
  );
});

export const Resena = mongoose.model('Resena', resenaSchema);
js
// 4 — en routes/productos.js, ANTES de router.get('/:id', ...)
router.get('/reportes/por-categoria', async (req, res, next) => {
  try {
    const reporte = await Producto.aggregate([
      { $match: { activo: true } },
      { $group: {
          _id: { $ifNull: ['$categoria', 'Sin categoría'] },
          productos: { $sum: 1 },
          stock: { $sum: '$stock' },
          precioPromedio: { $avg: '$precio' }
      } },
      { $project: { productos: 1, stock: 1, precioPromedio: { $round: ['$precioPromedio', 2] } } },
      { $sort: { productos: -1 } }
    ]);
    res.json(reporte);
  } catch (err) { next(err); }
});

Resumen ​

  • Mongoose añade schemas, validación, modelos, populate, middleware y virtuals sobre el driver de MongoDB.
  • Define tipos y reglas en el Schema (required, min, enum, match, validate); unique crea un índice, no es un validador.
  • Usa runValidators: true en updates y .lean() en lecturas para responder rápido.
  • populate resuelve referencias con consultas extra: úsalo con moderación.
  • Middleware pre/post para slugs, totales calculados y filtros globales; usa function, no flechas.
  • La API de la tienda combina modelos, rutas con lista blanca de campos, transacciones, agregaciones y manejo centralizado de errores.

Academia Web — aprende, copia y construye.