Tema
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 │
└───────────────┘| Concepto | Qué es | Analogía |
|---|---|---|
| Schema | La definición de la forma de los documentos: campos, tipos, reglas | El plano de una casa |
| Model | Una "clase" construida con el schema, ligada a una colección | La constructora que usa el plano |
| Document | Una 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 nodemonenv
# .env
MONGODB_URI="mongodb+srv://tienda_app:CLAVE@tienda-cluster.ab1cd.mongodb.net/tienda?retryWrites=true&w=majority"
PORT=3000js
// 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
| Tipo | Ejemplo |
|---|---|
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) |
Mixed | Cualquier cosa (sin validación) |
Map | { type: Map, of: String } (claves dinámicas) |
| Subdocumento | Otro schema anidado |
Validaciones
| Validador | Aplica a |
|---|---|
required | Todos |
min, max | Number, Date |
minlength, maxlength, match, enum | String |
validate | Personalizado (síncrono o asíncrono) |
unique | No 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() | |
|---|---|---|
| Tipo | Documento Mongoose | Objeto JS plano |
save(), métodos, virtuals | Sí | No |
| Rendimiento | Normal | Mayor |
| Uso | Leer para modificar | Leer 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}`);
});| Tipo | Operaciones | this es |
|---|---|---|
| Document middleware | save, validate, deleteOne (de documento) | El documento |
| Query middleware | find, findOne, updateOne, findOneAndUpdate, deleteMany... | La consulta |
| Aggregate middleware | aggregate | El 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.jsjson
{
"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
serverSelectionTimeoutMSy cierre ordenado enSIGTERM. MONGODB_URIen variables de entorno; usuario de base con permisos solo sobre su base.sanitizeFilteroexpress-mongo-sanitizecontra inyección de operadores; nunca pasesreq.bodycomo filtro.- Lista blanca de campos editables (evita asignación masiva) y
runValidators: trueen 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: falsey 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 (
mongodumpo 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
- Agrega al modelo
Productoun virtualdisponibleque devuelvatruesistock > 0 && activo. - 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. - Agrega a
Resenaun middlewarepost('save')que recalcule el promedio de calificación del producto y lo guarde enproducto.calificacion. - Crea el endpoint
GET /api/productos/reportes/por-categoriaque 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);uniquecrea un índice, no es un validador. - Usa
runValidators: trueen updates y.lean()en lecturas para responder rápido. populateresuelve referencias con consultas extra: úsalo con moderación.- Middleware
pre/postpara slugs, totales calculados y filtros globales; usafunction, no flechas. - La API de la tienda combina modelos, rutas con lista blanca de campos, transacciones, agregaciones y manejo centralizado de errores.