Tema
MongoDB con Mongoose
MongoDB es una base de datos NoSQL que guarda documentos con forma de JSON. Es muy popular con Node porque los datos se ven igual en la base que en tu código. Mongoose agrega esquemas, validaciones y métodos útiles encima del driver oficial.
Concepto
| SQL | MongoDB |
|---|---|
| Base de datos | Base de datos |
| Tabla | Colección |
| Fila | Documento (JSON/BSON) |
| Columna | Campo |
id INT AUTO_INCREMENT | _id: ObjectId automático |
| JOIN | Documentos embebidos o populate |
Un documento de producto:
json
{
"_id": "6650f1c2a1b2c3d4e5f60718",
"nombre": "Audífonos BT",
"precio": 899,
"etiquetas": ["audio", "bluetooth"],
"proveedor": { "nombre": "SoundMX", "pais": "México" },
"creadoEn": "2026-05-24T10:00:00.000Z"
}MongoDB es flexible: cada documento puede tener campos distintos. Mongoose agrega un esquema para que esa flexibilidad no se convierta en caos.
¿Cuándo usar MongoDB? Catálogos con atributos variables, logs, contenido, prototipos rápidos. Para datos muy relacionales (contabilidad, inventarios con muchas relaciones) suele ser mejor SQL. Más detalles en el curso de MongoDB.
Preparar MongoDB
- Local con Docker:
bash
docker run -d --name mongo-dev -p 27017:27017 mongo:8- En la nube gratis: crea un clúster M0 en MongoDB Atlas, agrega tu IP en Network Access y copia la cadena de conexión.
env
# .env
MONGODB_URI="mongodb://localhost:27017/tienda"
# Atlas: mongodb+srv://usuario:password@cluster0.xxxxx.mongodb.net/tiendabash
npm i mongooseConexión
js
// src/db/mongo.js
import mongoose from 'mongoose'
export async function conectarMongo(uri) {
mongoose.connection.on('connected', () => console.log('✅ MongoDB conectado'))
mongoose.connection.on('error', (err) => console.error('❌ MongoDB:', err.message))
mongoose.connection.on('disconnected', () => console.warn('⚠️ MongoDB desconectado'))
await mongoose.connect(uri)
}js
// src/index.js
import app from './app.js'
import { conectarMongo } from './db/mongo.js'
await conectarMongo(process.env.MONGODB_URI) // si falla, la app no arranca
app.listen(process.env.PORT ?? 3000, () => console.log('API lista'))Esquemas y modelos
js
// src/models/producto.model.js
import { Schema, model } from 'mongoose'
const productoSchema = new Schema(
{
nombre: {
type: String,
required: [true, 'El nombre es obligatorio'],
trim: true,
minlength: 2,
maxlength: 100,
unique: true // crea un índice único (no es una validación)
},
precio: { type: Number, required: true, min: [0, 'El precio no puede ser negativo'] },
stock: { type: Number, default: 0, min: 0 },
categoria: {
type: String,
enum: { values: ['ropa', 'electronica', 'hogar'], message: 'Categoría inválida' }
},
etiquetas: [String],
// Documento embebido
proveedor: {
nombre: String,
pais: { type: String, default: 'México' }
},
activo: { type: Boolean, default: true }
},
{
timestamps: true, // agrega createdAt y updatedAt
// Al convertir a JSON: id en lugar de _id y sin __v
toJSON: {
virtuals: true,
versionKey: false,
transform: (doc, ret) => {
delete ret._id
return ret
}
}
}
)
// Campo calculado (no se guarda)
productoSchema.virtual('precioConIva').get(function () {
return +(this.precio * 1.16).toFixed(2)
})
// Índice para búsquedas por texto
productoSchema.index({ nombre: 'text', etiquetas: 'text' })
export const Producto = model('Producto', productoSchema) // colección "productos"CRUD
js
// src/services/productos.mongo.js
import { Producto } from '../models/producto.model.js'
// Crear
export function crear(datos) {
return Producto.create(datos) // valida con el esquema
}
// Listar con filtros, orden y paginación
export async function listar({ pagina = 1, limite = 10, categoria, q, min, max }) {
const filtro = { activo: true }
if (categoria) filtro.categoria = categoria
if (q) filtro.$text = { $search: q }
if (min !== undefined || max !== undefined) {
filtro.precio = {}
if (min !== undefined) filtro.precio.$gte = min
if (max !== undefined) filtro.precio.$lte = max
}
const [datos, total] = await Promise.all([
Producto.find(filtro)
.sort({ createdAt: -1 })
.skip((pagina - 1) * limite)
.limit(limite)
.lean(), // objetos planos: más rápido si sólo vas a leer
Producto.countDocuments(filtro)
])
return { datos, total, paginas: Math.ceil(total / limite) }
}
// Obtener por id
export function obtener(id) {
return Producto.findById(id)
}
// Actualizar: new devuelve el documento actualizado; runValidators aplica el esquema
export function actualizar(id, cambios) {
return Producto.findByIdAndUpdate(id, cambios, { new: true, runValidators: true })
}
// Eliminar
export function eliminar(id) {
return Producto.findByIdAndDelete(id)
}
// Operadores útiles
export function agregarEtiqueta(id, etiqueta) {
return Producto.findByIdAndUpdate(id, { $addToSet: { etiquetas: etiqueta } }, { new: true })
}
export function descontarStock(id, cantidad) {
// Atómico: sólo descuenta si hay suficiente stock
return Producto.findOneAndUpdate(
{ _id: id, stock: { $gte: cantidad } },
{ $inc: { stock: -cantidad } },
{ new: true }
)
}Operadores de consulta más usados: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $regex, $and, $or. De actualización: $set, $inc, $push, $pull, $addToSet, $unset.
Relaciones con populate
js
// src/models/pedido.model.js
import { Schema, model } from 'mongoose'
const pedidoSchema = new Schema(
{
cliente: { type: Schema.Types.ObjectId, ref: 'Usuario', required: true },
productos: [
{
producto: { type: Schema.Types.ObjectId, ref: 'Producto', required: true },
cantidad: { type: Number, min: 1, default: 1 },
precio: Number // precio al momento de la compra
}
],
estado: { type: String, enum: ['pendiente', 'pagado', 'enviado'], default: 'pendiente' }
},
{ timestamps: true }
)
export const Pedido = model('Pedido', pedidoSchema)js
const pedidos = await Pedido.find({ estado: 'pagado' })
.populate('cliente', 'nombre email') // sólo esos campos
.populate('productos.producto', 'nombre')Agregaciones: reportes
js
// Ventas totales por categoría
const reporte = await Producto.aggregate([
{ $match: { activo: true } },
{
$group: {
_id: '$categoria',
productos: { $sum: 1 },
valorInventario: { $sum: { $multiply: ['$precio', '$stock'] } },
precioPromedio: { $avg: '$precio' }
}
},
{ $sort: { valorInventario: -1 } }
])Integrarlo con Express
js
// src/controllers/productos.controller.js
import { isValidObjectId } from 'mongoose'
import * as servicio from '../services/productos.mongo.js'
export async function obtener(req, res) {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: 'Id inválido' })
}
const producto = await servicio.obtener(req.params.id)
if (!producto) return res.status(404).json({ error: 'Producto no encontrado' })
res.json(producto)
}
export async function crear(req, res) {
const producto = await servicio.crear(req.body)
res.status(201).json(producto)
}Y en el manejador de errores, traduce los errores de Mongoose:
js
// src/middlewares/errores.js (fragmento)
import mongoose from 'mongoose'
export function manejadorErrores(err, req, res, next) {
if (err instanceof mongoose.Error.ValidationError) {
const detalles = Object.values(err.errors).map((e) => ({ campo: e.path, mensaje: e.message }))
return res.status(400).json({ error: 'Datos inválidos', detalles })
}
if (err instanceof mongoose.Error.CastError) {
return res.status(400).json({ error: `Valor inválido para ${err.path}` })
}
if (err.code === 11000) {
return res.status(409).json({ error: 'Registro duplicado', campos: err.keyValue })
}
console.error(err)
res.status(500).json({ error: 'Error interno del servidor' })
}Buena práctica
- Valida en la entrada con zod y deja las validaciones del esquema como segunda línea de defensa.
- Usa
.lean()en consultas de sólo lectura yselectpara traer sólo los campos necesarios. - Crea índices para los campos por los que filtras u ordenas seguido.
Inyección NoSQL
Si haces Usuario.findOne({ email: req.body.email }) y alguien envía { "email": { "$ne": null } }, encontrará el primer usuario. Valida que los campos sean strings (zod lo resuelve) y considera mongoose.set('sanitizeFilter', true).
Error común
Pasar un id con formato inválido a findById lanza un CastError. Valida con isValidObjectId o manéjalo en el middleware de errores.
Ejercicios
- Crea un modelo
Usuarioconnombre(requerido),email(requerido, único, en minúsculas, con formato válido) yrol(clienteoadmin, por defectocliente). - Escribe una consulta que devuelva los 5 productos más caros de la categoría
electronicacon stock mayor a 0, sólo connombreyprecio.
Ver solución
js
// src/models/usuario.model.js
import { Schema, model } from 'mongoose'
const usuarioSchema = new 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']
},
rol: { type: String, enum: ['cliente', 'admin'], default: 'cliente' }
},
{ timestamps: true }
)
export const Usuario = model('Usuario', usuarioSchema)js
const top = await Producto.find({ categoria: 'electronica', stock: { $gt: 0 } })
.sort({ precio: -1 })
.limit(5)
.select('nombre precio')
.lean()Resumen
- MongoDB guarda documentos JSON en colecciones; Mongoose agrega esquemas y validación.
Schema+modeldefinen la estructura;timestampsagrega fechas automáticamente.- CRUD:
create,find,findById,findByIdAndUpdate(new,runValidators),findByIdAndDelete. populateresuelve referencias yaggregategenera reportes.