Skip to content

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 ​

SQLMongoDB
Base de datosBase de datos
TablaColección
FilaDocumento (JSON/BSON)
ColumnaCampo
id INT AUTO_INCREMENT_id: ObjectId automático
JOINDocumentos 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/tienda
bash
npm i mongoose

Conexió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 y select para 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 ​

  1. Crea un modelo Usuario con nombre (requerido), email (requerido, único, en minúsculas, con formato válido) y rol (cliente o admin, por defecto cliente).
  2. Escribe una consulta que devuelva los 5 productos más caros de la categoría electronica con stock mayor a 0, sólo con nombre y precio.
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 + model definen la estructura; timestamps agrega fechas automáticamente.
  • CRUD: create, find, findById, findByIdAndUpdate (new, runValidators), findByIdAndDelete.
  • populate resuelve referencias y aggregate genera reportes.

Academia Web — aprende, copia y construye.