MongoDBMongoose ODM

Mongoose ODM

Mongoose is the most widely used Object Document Mapper (ODM) for MongoDB in Node.js. It wraps the native driver with schemas, validation, middleware, and a query builder API — trading a little flexibility and a small performance overhead for structure and developer ergonomics.

Connecting

JS
import mongoose from 'mongoose'

await mongoose.connect('mongodb://localhost:27017/shop', {
  serverSelectionTimeoutMS: 5000
})

mongoose.connection.on('error', (err) => console.error('Mongo error:', err))
Schemas and Models

A Schema defines the shape and rules for documents; a Model is the compiled constructor you actually query and save through.

JS
import mongoose, { Schema } from 'mongoose'

const userSchema = new Schema(
  {
    email: { type: String, required: true, unique: true, lowercase: true, trim: true },
    name: { type: String, required: true },
    age: { type: Number, min: 0, max: 150 },
    role: { type: String, enum: ['user', 'admin'], default: 'user' },
    tags: [String]
  },
  { timestamps: true } // adds createdAt / updatedAt automatically
)

const User = mongoose.model('User', userSchema)
SchemaTypes and Built-in Validation

SchemaType

Common Options

String

required, unique, minLength, maxLength, match, enum, trim, lowercase

Number

required, min, max

Date

required, min, max, default: Date.now

Boolean

required, default

ObjectId (Schema.Types.ObjectId)

ref (for populate)

Array ([Type])

default []

Mixed (Schema.Types.Mixed)

no validation — arbitrary shape

JS
const productSchema = new Schema({
  sku: { type: String, required: true, unique: true },
  price: {
    type: Number,
    required: true,
    validate: {
      validator: (v) => v >= 0,
      message: (props) => `${props.value} is not a valid price`
    }
  }
})
Queries

JS
const user = await User.findOne({ email: 'alice@example.com' })
const admins = await User.find({ role: 'admin' }).sort({ createdAt: -1 }).limit(20)

const created = await User.create({ email: 'bob@example.com', name: 'Bob' })

await User.updateOne({ _id: user._id }, { $set: { name: 'Alice Smith' } })
await User.deleteOne({ _id: user._id })

// Instance style — load, mutate, save (triggers validation + middleware)
const doc = await User.findById(user._id)
doc.name = 'Alice S.'
await doc.save()
Middleware (pre / post Hooks)

Middleware runs functions before or after schema operations — the classic use case is hashing a password before save, or logging after a delete.

JS
import bcrypt from 'bcrypt'

userSchema.pre('save', async function hashPassword(next) {
  if (!this.isModified('password')) return next()
  this.password = await bcrypt.hash(this.password, 10)
  next()
})

userSchema.post('save', function logCreated(doc) {
  console.log('Saved user:', doc._id.toString())
})

userSchema.pre(/^find/, function excludeDeleted() {
  this.where({ deletedAt: { $exists: false } })
})
Virtuals

Virtuals are computed fields that don't get persisted to MongoDB — derived values based on other fields.

JS
userSchema.virtual('fullName').get(function getFullName() {
  return `${this.firstName} ${this.lastName}`
})

// Include virtuals when converting to JSON (e.g. for API responses)
userSchema.set('toJSON', { virtuals: true })
populate() — Resolving References

Mongoose's ref + populate() is the ODM equivalent of a $lookup — it replaces stored ObjectIds with the referenced documents.

JS
const orderSchema = new Schema({
  customer: { type: Schema.Types.ObjectId, ref: 'User', required: true },
  items: [{ product: { type: Schema.Types.ObjectId, ref: 'Product' }, qty: Number }]
})
const Order = mongoose.model('Order', orderSchema)

const order = await Order.findById(orderId)
  .populate('customer', 'name email')       // only pull name + email
  .populate('items.product')
lean() — Skipping Hydration

By default, query results are hydrated into full Mongoose documents (with getters, virtuals, save(), change tracking). If you only need plain data — e.g. serializing an API response — .lean() skips that overhead and returns plain JS objects.

JS
const users = await User.find({ role: 'admin' }).lean()
// users are plain objects — faster, less memory, but no .save(), no virtuals,
// no getters/setters, no middleware on subsequent mutation
Tip
Use .lean() for read-heavy, display-only queries (API list endpoints, reports). Skip it when you need to call .save(), use virtuals, or rely on middleware.
Mongoose vs Native Driver

Mongoose

Native Driver

Schema enforcement

Yes, in application code

No (or via $jsonSchema validator)

Validation

Built-in, declarative

Manual, or DB-side validator

Middleware/hooks

Yes (pre/post)

No — write it yourself

populate() joins

Yes, convenient

Manual $lookup or app-side fetch

Performance overhead

Small (hydration, casting)

Minimal — closer to the wire protocol

Learning curve

Lower for teams used to ORMs

Requires understanding raw MongoDB API

Best for

Typical CRUD apps, fast iteration

High-throughput services, full control

Warning
Mongoose casts and coerces values to match SchemaTypes (e.g. a string"42" becomes the number 42) — this is convenient but can hide bugs if you rely on it instead of validating input at the API boundary.
Summary
  • Schemas + Models give you structure, validation, and casting on top of a schemaless database.

  • Middleware (pre/post) centralizes cross-cutting logic like hashing and soft-delete filtering.

  • Virtuals compute derived fields without storing them.

  • populate() resolves references declaratively; lean() skips hydration for read-only paths.

  • Reach for the native driver instead when you need maximum throughput or full control over the wire protocol.