🏠 Home / Hub

🐨 MongoDB — Lesson 7: Mongoose ODM

Mongoose is the most popular Node.js ODM (Object Document Mapper) for MongoDB. It adds schemas, validation, middleware, virtuals, and type casting on top of the native driver — making your data layer predictable and your code organized.

1. Installation and Connection

# Install mongoose
npm install mongoose

# Also install dotenv for environment variables
npm install dotenv
// db.js — connection module
const mongoose = require('mongoose')

const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.MONGODB_URI, {
      // These options are now defaults in Mongoose 6+
      // But explicit for clarity:
    })
    console.log(`MongoDB Connected: ${conn.connection.host}`)
  } catch (err) {
    console.error('MongoDB connection error:', err.message)
    process.exit(1)
  }
}

module.exports = connectDB

// In your app.js / server.js:
require('dotenv').config()
const connectDB = require('./db')
connectDB()

// Connection events
mongoose.connection.on('connected', () => console.log('Connected'))
mongoose.connection.on('error', (err) => console.error(err))
mongoose.connection.on('disconnected', () => console.log('Disconnected'))

// Graceful disconnect on app shutdown
process.on('SIGINT', async () => {
  await mongoose.connection.close()
  process.exit(0)
})

2. Schema Definition

const { Schema, model, Types } = require('mongoose')

// All Mongoose Schema Types:
const exampleSchema = new Schema({
  // Basic types
  name:       String,
  age:        Number,
  active:     Boolean,
  birthday:   Date,

  // Reference to another document
  userId:     Types.ObjectId,

  // Arrays
  tags:       [String],               // array of strings
  scores:     [Number],               // array of numbers

  // Mixed — accepts any data
  metadata:   Schema.Types.Mixed,

  // Buffer — binary data
  avatar:     Buffer,

  // Map — dynamic keys
  settings:   { type: Map, of: String },

  // Embedded sub-document
  address: {
    street:   String,
    city:     String,
    zip:      String
  }
})

Schema Options (Validators and Modifiers)

const userSchema = new Schema({
  username: {
    type: String,
    required: [true, 'Username is required'],
    unique: true,
    trim: true,                   // remove leading/trailing whitespace
    lowercase: true,             // store as lowercase
    minlength: [3, 'Min 3 chars'],
    maxlength: [30, 'Max 30 chars'],
    match: [/^[a-zA-Z0-9_]+$/, 'Alphanumeric only']
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true
  },
  password: {
    type: String,
    required: true,
    minlength: 8,
    select: false               // never include in query results by default
  },
  age: {
    type: Number,
    min: [0, 'Age cannot be negative'],
    max: [120, 'Invalid age']
  },
  role: {
    type: String,
    enum: {
      values: ['admin', 'author', 'reader'],
      message: '{VALUE} is not a valid role'
    },
    default: 'reader'
  },
  loginCount: {
    type: Number,
    default: 0
  },
  lastLogin: {
    type: Date,
    default: null
  },
  website: {
    type: String,
    validate: {
      validator: (v) => /^https?:\/\/.*/.test(v),
      message: 'Website must be a valid URL starting with http/https'
    }
  },
  authorId: {
    type: Types.ObjectId,
    ref: 'User'                  // for .populate()
  }
}, {
  timestamps: true,             // auto-adds createdAt and updatedAt
  toJSON: { virtuals: true },   // include virtuals when converting to JSON
  toObject: { virtuals: true }
})

3. Creating Models

// models/User.js
const mongoose = require('mongoose')

const userSchema = new mongoose.Schema({
  username: { type: String, required: true, unique: true },
  email:    { type: String, required: true, unique: true, lowercase: true },
  password: { type: String, required: true, select: false },
  role:     { type: String, enum: ['admin', 'author', 'reader'], default: 'reader' }
}, { timestamps: true })

// Model name: 'User' → collection: 'users' (auto-pluralized + lowercased)
const User = mongoose.model('User', userSchema)

module.exports = User

4. CRUD with Mongoose

const User = require('./models/User')

// ---- CREATE ----

// Method 1: new + save()
const user = new User({
  username: 'alice_dev',
  email: 'alice@example.com',
  password: 'hashedpassword'
})
await user.save()

// Method 2: create() — shortcut for new + save
const user2 = await User.create({
  username: 'bob_dev',
  email: 'bob@example.com',
  password: 'hashedpassword'
})

// Method 3: insertMany
await User.insertMany([
  { username: 'carol', email: 'carol@example.com', password: '...' },
  { username: 'dave',  email: 'dave@example.com',  password: '...' }
])

// ---- READ ----

// find() — returns array
const users = await User.find({ role: 'author' })

// findOne() — returns one document or null
const user = await User.findOne({ email: 'alice@example.com' })

// findById() — shortcut for findOne({ _id: id })
const user = await User.findById(req.params.id)

// With select — include (+) or exclude (-) fields
const user = await User.findById(id).select('username email role')
const user = await User.findById(id).select('-password -__v')

// ---- UPDATE ----

// findByIdAndUpdate — returns updated document
const updated = await User.findByIdAndUpdate(
  id,
  { $set: { role: 'author' } },
  { new: true, runValidators: true }
// new: true = return updated doc, runValidators = validate on update
)

// updateOne() / updateMany()
await User.updateOne({ email: 'old@example.com' }, { $set: { email: 'new@example.com' } })
await User.updateMany({ role: 'reader' }, { $set: { newsletterEnabled: true } })

// ---- DELETE ----

// findByIdAndDelete — returns deleted document
const deleted = await User.findByIdAndDelete(id)

// deleteOne() / deleteMany()
await User.deleteOne({ email: 'spam@example.com' })
await User.deleteMany({ active: false })

5. Middleware (Hooks)

Middleware (hooks) are functions that run before (pre) or after (post) a specific event. Use them for password hashing, logging, cascading deletes, etc.

const bcrypt = require('bcrypt')

// pre('save') — hash password before saving
userSchema.pre('save', async function(next) {
  // 'this' refers to the document being saved
  if (!this.isModified('password')) return next()
  this.password = await bcrypt.hash(this.password, 12)
  next()
})

// pre('save') — set updatedAt manually (if not using timestamps)
userSchema.pre('save', function(next) {
  this.updatedAt = new Date()
  next()
})

// pre('find') — automatically exclude inactive users
userSchema.pre(/^find/, function(next) {
  // 'this' refers to the query
  this.where({ active: { $ne: false } })
  next()
})

// post('save') — log after saving
userSchema.post('save', function(doc) {
  console.log(`User saved: ${doc.username}`)
})

// pre('remove') — cascade delete user's posts
userSchema.pre('remove', async function(next) {
  await Post.deleteMany({ author: this._id })
  next()
})

// Instance method — compare passwords
userSchema.methods.comparePassword = async function(candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password)
}

// Static method — find by email
userSchema.statics.findByEmail = function(email) {
  return this.findOne({ email: email.toLowerCase() })
}

6. Virtuals

Virtuals are computed properties that are NOT stored in MongoDB. They exist only in JavaScript — computed on the fly.

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

userSchema.virtual('fullName').set(function(name) {
  const [first, ...rest] = name.split(' ')
  this.firstName = first
  this.lastName = rest.join(' ')
})

// Usage:
const user = await User.findById(id)
console.log(user.fullName)  // "Alice Johnson"

// Virtual for post comment count
postSchema.virtual('commentCount').get(function() {
  return this.comments.length
})

// Virtual populate — like a lookup without storing the reference
postSchema.virtual('author', {
  ref: 'User',
  localField: 'authorId',
  foreignField: '_id',
  justOne: true
})

// Enable virtuals in JSON output (in schema options):
// { toJSON: { virtuals: true }, toObject: { virtuals: true } }

7. Populate — Joining References

// Post schema with author reference
const postSchema = new mongoose.Schema({
  title: String,
  body: String,
  authorId: { type: Types.ObjectId, ref: 'User' },  // ref enables populate
  tags: [String]
}, { timestamps: true })

// Basic populate — replace authorId with full user document
const post = await Post.findById(id).populate('authorId')
// post.authorId is now a full User object, not just an ObjectId

// Populate with field selection
const post = await Post.findById(id)
  .populate('authorId', 'username email avatar -_id')

// Populate multiple fields
const post = await Post.findById(id)
  .populate('authorId', 'username')
  .populate('tags')

// Populate with object options
const post = await Post.findById(id).populate({
  path: 'authorId',
  select: 'username email',
  match: { active: true },    // only populate if user is active
  options: { lean: true }
})

// Nested populate — populate inside populated docs
const post = await Post.findById(id).populate({
  path: 'comments.userId',
  select: 'username avatar'
})

8. Query Chaining and lean()

// Mongoose query chaining
const users = await User
  .find({ active: true })
  .where('age').gte(18).lt(65)  // where chaining
  .where('role').in(['admin', 'author'])
  .sort('-createdAt')          // - prefix = descending
  .limit(10)
  .skip(0)
  .select('username email role createdAt')
  .populate('profile')

// .lean() — return plain JS objects instead of Mongoose documents
// Much faster — no hydration, no virtuals, no methods
const users = await User.find({ active: true }).lean()
// users is a plain array of JS objects — perfect for read-only APIs

// countDocuments in Mongoose
const total = await User.countDocuments({ active: true })

// Aggregation in Mongoose
const stats = await User.aggregate([
  { $group: { _id: '$role', count: { $sum: 1 } } }
])

9. Custom Validation

const postSchema = new mongoose.Schema({
  title: {
    type: String,
    required: [true, 'Title is required'],
    validate: {
      validator: function(v) {
        return v.length >= 5 && v.length <= 200
      },
      message: 'Title must be between 5 and 200 characters'
    }
  },
  slug: {
    type: String,
    validate: {
      validator: async function(slug) {
        // Async validator — check uniqueness in DB
        const post = await this.constructor.findOne({ slug })
        if (post && post._id.toString() !== this._id.toString()) {
          return false  // slug already taken by another post
        }
        return true
      },
      message: 'Slug already exists'
    }
  },
  tags: {
    type: [String],
    validate: {
      validator: (tags) => tags.length <= 10,
      message: 'Maximum 10 tags allowed'
    }
  }
})

// Handling validation errors
try {
  await post.save()
} catch (err) {
  if (err.name === 'ValidationError') {
    const messages = Object.values(err.errors).map(e => e.message)
    // ["Title is required", "Slug already exists"]
  }
}

10. Mongoose vs Native Driver

FeatureMongoose ODMNative MongoDB Driver
Schema validationBuilt-in, rich validatorsManual or $jsonSchema
Type castingAutomatic (string→ObjectId, etc)Manual — exact types required
Middlewarepre/post hooksNot available
VirtualsComputed fields on modelNot available
PopulateBuilt-in .populate()Manual $lookup aggregation
Query builderChainable .where().gte().lt()Raw query objects only
Plugin ecosystemLarge (mongoose-paginate, etc)Not applicable
PerformanceSlightly slower (hydration overhead)Faster (less abstraction)
Best forFull-stack apps, rapid developmentHigh-performance microservices
Learning curveSteeper (more concepts)Simpler (closer to mongosh)
When to use native driver: High-throughput microservices, bulk operations, complex aggregations, when you need full control and maximum performance. When to use Mongoose: Standard web apps, when schemas and validation help productivity, team consistency.

📌 Study Checklist