NodeJSMongoose ODM

Mongoose ODM

Mongoose is the de-facto ODM (Object-Document Mapper) for MongoDB in Node. It sits on top of the official driver and adds the things the driver lacks: schemas with validation, type casting, defaults, middleware hooks, virtuals, and relationship population. In short, it brings structure to a schemaless database — enforced in your app. This page introduces Mongoose's building blocks; the next two pages go deep on schemas and queries.

Connecting

Bash
npm install mongoose

db.js

JS
import mongoose from 'mongoose'

export async function connectDB() {
  await mongoose.connect(process.env.MONGODB_URI)   // ONCE at startup
  console.log('MongoDB connected')
}

// Mongoose maintains a single pooled connection internally.
mongoose.connection.on('error', (err) => console.error('Mongo error', err))
`mongoose.connect` once — Mongoose manages the pool and buffering
Call `mongoose.connect` a single time at boot. Mongoose pools connections like the driver, and it *buffers* model operations issued before the connection is ready, so you don't have to await connection before defining models. Listen on `connection` events for errors/disconnects in production. Never connect per request.
Schema → Model → Document

JS
import mongoose from 'mongoose'
const { Schema } = mongoose

// 1. Schema — the shape, types, and rules:
const userSchema = new Schema({
  name:  { type: String, required: true, trim: true },
  email: { type: String, required: true, unique: true, lowercase: true },
  age:   { type: Number, min: 0, max: 150 },
  role:  { type: String, enum: ['user', 'admin'], default: 'user' },
}, { timestamps: true })   // auto createdAt / updatedAt

// 2. Model — the interface to the 'users' collection:
const User = mongoose.model('User', userSchema)

// 3. Document — an instance you create/query:
const u = new User({ name: 'Ada', email: 'ADA@x.com' })
await u.save()             // validated, lowercased, timestamped, inserted
The three layers: Schema defines, Model accesses, Document is an instance
A **Schema** declares fields, types, and validation. A **Model** (`mongoose.model('User', schema)`) is the class you query through — it maps to a collection (Mongoose pluralizes/lowercases `'User'` → `users`). A **Document** is a single record instance with methods like `.save()`. This mirrors classes vs instances. Defining the schema is where Mongoose earns its keep over the raw driver.
What Mongoose adds over the driver

Feature

What it does

Schema validation

Rejects bad data before it hits the DB

Type casting

"42"42; string id → ObjectId automatically

Defaults

Fill missing fields (role: "user")

Middleware (hooks)

Run logic pre/post save, validate, remove

Virtuals

Computed properties not stored in the DB

Population

Resolve referenced documents (join-like)

Query helpers

Chainable, promise-based query builder

CRUD with a model

JS
// Create:
const user = await User.create({ name: 'Ada', email: 'ada@x.com' })

// Read — casting & null handling built in:
const all  = await User.find({ role: 'admin' }).sort('name').limit(20)
const one  = await User.findById(req.params.id)        // string id auto-cast
const byEmail = await User.findOne({ email: 'ada@x.com' })

// Update — returns the NEW doc, runs validators:
const updated = await User.findByIdAndUpdate(
  req.params.id,
  { name: 'Ada L.' },
  { new: true, runValidators: true },
)

// Delete:
await User.findByIdAndDelete(req.params.id)
`findByIdAndUpdate` returns the OLD doc and skips validators by default
Two defaults bite everyone: without `{ new: true }`, update methods return the document **as it was before** the change; and without `{ runValidators: true }`, your schema validations are **not** applied to updates (they run on `.save()` but not on `findOneAndUpdate` by default). Pass both options whenever you want the updated doc back *and* the update validated.
String ids are cast automatically — no manual ObjectId
Unlike the [raw driver](/nodejs/mongodb-driver), `User.findById(req.params.id)` accepts a string and casts it to `ObjectId` for you (throwing a catchable `CastError` on a malformed id, which your [error handler](/nodejs/error-middleware) can map to 400). This casting across all query fields is one of Mongoose's quiet conveniences.
Instance and static methods

JS
// Instance method — operates on one document:
userSchema.methods.isAdult = function () { return this.age >= 18 }

// Static method — operates on the model/collection:
userSchema.statics.findByEmail = function (email) {
  return this.findOne({ email: email.toLowerCase() })
}

const u = await User.findByEmail('ada@x.com')   // static
if (u.isAdult()) { /* ... */ }                  // instance
Attach behavior to the schema, not scattered helpers
Mongoose lets you co-locate domain logic with the model: `methods` for per-document behavior (`isAdult()`), `statics` for collection-level queries (`findByEmail()`). This keeps business logic next to the data shape it operates on, rather than in loose utility functions — a key reason teams prefer an ODM over the bare driver.
The trade-off
  • Pro — schema safety, validation, casting, hooks, population, and clean models out of the box.

  • Pro — far less boilerplate than the driver for typical CRUD.

  • Con — overhead and "magic"; complex aggregations may still drop to the driver.

  • Con — schema lives in code, so it can drift from existing data if not migrated.

Mongoose vs the driver — convenience vs control
Use Mongoose when you want structure and productivity (most apps). Use the [raw driver](/nodejs/mongodb-driver) when you need maximum control, minimal overhead, or heavy aggregation pipelines. They're not exclusive — you can even reach `mongoose.connection.db` for raw operations within a Mongoose app. The next pages detail [schema design](/nodejs/mongoose-schemas) and [querying/population](/nodejs/mongoose-queries).
Next
Design robust schemas with validation and relationships: [Mongoose Schemas & Models](/nodejs/mongoose-schemas).