Episode 3 — NodeJS MongoDB Backend Architecture / 3.8 — Database Basics MongoDB

3.8.e — Mongoose ODM

In one sentence: Mongoose is an Object Document Mapper (ODM) for MongoDB and Node.js that provides schema-based data modeling, built-in validation, type casting, query building, and middleware hooks -- turning MongoDB's schema-less flexibility into a structured, predictable development experience.


< 3.8.d -- MongoDB Data Types and Documents | 3.8.f -- CRUD Operations with Mongoose >


Table of Contents

  1. What Is an ODM?
  2. Why Mongoose Over the Native Driver?
  3. Installing and Connecting
  4. Connection Events and Options
  5. Schema Definition
  6. Schema Types in Detail
  7. Built-in Validators
  8. Custom Validators
  9. Schema Options
  10. Models — From Schema to Collection
  11. Timestamps
  12. Virtuals
  13. Instance Methods and Static Methods
  14. Middleware (Hooks)
  15. Indexes
  16. Key Takeaways
  17. Explain-It Challenge

1. What Is an ODM?

An Object Document Mapper maps between JavaScript objects in your code and MongoDB documents in your database. It is the NoSQL equivalent of an ORM (Object Relational Mapper) used with SQL databases.

┌──────────────────────────────────────────────────────────────┐
│                    ODM LAYER (Mongoose)                        │
│                                                              │
│  JavaScript Object          MongoDB Document                 │
│  ┌──────────────┐           ┌──────────────┐                 │
│  │ {            │   <--->   │ {            │                 │
│  │   name,      │  Mongoose │   name,      │                 │
│  │   email,     │  handles  │   email,     │                 │
│  │   age        │  mapping  │   age,       │                 │
│  │ }            │           │   _id,       │                 │
│  └──────────────┘           │   __v        │                 │
│                             └──────────────┘                 │
│                                                              │
│  Schema defines structure   Collection stores documents      │
│  Validation runs before save                                 │
│  Type casting happens automatically                          │
└──────────────────────────────────────────────────────────────┘

ODM vs ORM

FeatureODM (Mongoose)ORM (Sequelize, Prisma)
Database typeNoSQL (MongoDB)SQL (PostgreSQL, MySQL)
Maps toDocuments in collectionsRows in tables
SchemaDefined in app codeDefined in DB + migrations
Joinspopulate() (application-level)SQL JOINs (database-level)
FlexibilitySemi-structured dataStrict tabular structure

2. Why Mongoose Over the Native Driver?

MongoDB provides an official Node.js driver (mongodb package). Mongoose adds structure on top of it.

FeatureNative DriverMongoose
Schema enforcementNone (insert anything)Schema with types and validation
ValidationManualBuilt-in + custom validators
Type castingManualAutomatic ("25" -> 25 for Number fields)
Middleware hooksNonepre/post hooks on save, validate, remove, etc.
Query helpersBasicChainable query builder with helpers
Population (joins)Manual $lookup.populate() with one line
VirtualsNot availableComputed properties from existing fields
Learning curveLowerSlightly higher, but more productive

Rule of thumb: Use Mongoose for application development (schemas, validation, middleware). Use the native driver for data scripts, migrations, or when you need maximum performance with no overhead.


3. Installing and Connecting

npm install mongoose

Basic connection

const mongoose = require('mongoose');

mongoose.connect('mongodb://localhost:27017/myapp')
  .then(() => console.log('Connected to MongoDB'))
  .catch(err => console.error('Connection failed:', err));

Connection with environment variables (recommended)

const mongoose = require('mongoose');
require('dotenv').config();

async function connectDB() {
  try {
    await mongoose.connect(process.env.MONGODB_URI);
    console.log('MongoDB connected successfully');
  } catch (error) {
    console.error('MongoDB connection error:', error.message);
    process.exit(1);  // Exit if DB connection fails
  }
}

connectDB();
# .env
MONGODB_URI=mongodb://localhost:27017/myapp
# or Atlas:
# MONGODB_URI=mongodb+srv://user:pass@cluster.xxxxx.mongodb.net/myapp

4. Connection Events and Options

Listening to connection events

const db = mongoose.connection;

db.on('connected', () => console.log('Mongoose connected'));
db.on('error', (err) => console.error('Mongoose error:', err));
db.on('disconnected', () => console.log('Mongoose disconnected'));

// Graceful shutdown
process.on('SIGINT', async () => {
  await mongoose.connection.close();
  console.log('Mongoose connection closed (app termination)');
  process.exit(0);
});

Common connection options

mongoose.connect(process.env.MONGODB_URI, {
  dbName: 'myapp',                // specify database name explicitly
  maxPoolSize: 10,                // max concurrent connections (default: 100)
  serverSelectionTimeoutMS: 5000, // timeout for finding a server
  socketTimeoutMS: 45000,         // timeout for socket inactivity
  family: 4                       // use IPv4
});

5. Schema Definition

A Schema defines the shape of documents in a MongoDB collection. It specifies fields, types, defaults, and validation rules.

const mongoose = require('mongoose');
const { Schema } = mongoose;

const userSchema = new Schema({
  // Required string
  name: {
    type: String,
    required: [true, 'Name is required'],
    trim: true,
    minlength: [2, 'Name must be at least 2 characters'],
    maxlength: [50, 'Name cannot exceed 50 characters']
  },

  // Unique email with validation
  email: {
    type: String,
    required: [true, 'Email is required'],
    unique: true,
    lowercase: true,
    trim: true,
    match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email']
  },

  // Number with range
  age: {
    type: Number,
    min: [0, 'Age cannot be negative'],
    max: [150, 'Age seems unrealistic']
  },

  // Enum (predefined values)
  role: {
    type: String,
    enum: {
      values: ['user', 'admin', 'moderator'],
      message: '{VALUE} is not a valid role'
    },
    default: 'user'
  },

  // Boolean with default
  isActive: {
    type: Boolean,
    default: true
  },

  // Date with default
  joinedAt: {
    type: Date,
    default: Date.now    // function reference, not invocation
  },

  // Nested object (embedded document)
  address: {
    street: String,
    city: String,
    state: String,
    zip: String
  },

  // Array of strings
  hobbies: [String],

  // Array of embedded documents
  education: [{
    school: String,
    degree: String,
    year: Number
  }],

  // Reference to another collection
  createdBy: {
    type: Schema.Types.ObjectId,
    ref: 'User'
  }
});

6. Schema Types in Detail

Mongoose supports the following schema types:

Schema TypeJavaScript TypeExample
Stringstring'Alice'
Numbernumber25, 3.14
Booleanbooleantrue, false
DateDate objectnew Date(), Date.now
BufferBufferBinary data
ObjectIdObjectIdSchema.Types.ObjectId
ArrayArray[String], [{ name: String }]
Decimal128Decimal128Schema.Types.Decimal128 (financial data)
MapMapDynamic key-value pairs
MixedAnySchema.Types.Mixed (no validation)

Type casting examples

// Mongoose automatically casts values to the schema type
const userSchema = new Schema({ age: Number, name: String, isActive: Boolean });

// These all work:
await User.create({ age: '25' });       // '25' cast to 25
await User.create({ name: 123 });       // 123 cast to '123'
await User.create({ isActive: 'true' }); // 'true' cast to true
await User.create({ isActive: 0 });     // 0 cast to false

The Mixed type (escape hatch)

const settingsSchema = new Schema({
  preferences: Schema.Types.Mixed   // accepts any structure
});

// No validation, no casting -- use sparingly
const settings = await Settings.create({
  preferences: { theme: 'dark', notifications: { email: true } }
});

// IMPORTANT: Mongoose cannot detect changes to Mixed fields
// You must call markModified() before save
settings.preferences.theme = 'light';
settings.markModified('preferences');
await settings.save();

7. Built-in Validators

String validators

const schema = new Schema({
  name: {
    type: String,
    required: true,         // field must exist and not be empty
    minlength: 2,           // minimum character count
    maxlength: 100,         // maximum character count
    trim: true,             // strip leading/trailing whitespace
    lowercase: true,        // convert to lowercase before saving
    uppercase: true,        // convert to uppercase before saving
    enum: ['a', 'b', 'c'], // must be one of these values
    match: /^[a-z]+$/       // must match this regex
  }
});

Number validators

const schema = new Schema({
  age: {
    type: Number,
    required: true,
    min: [0, 'Age must be positive'],
    max: [150, 'Invalid age']
  }
});

All-type validators

ValidatorApplies ToDescription
requiredAll typesField must be present and not null/undefined/empty-string
defaultAll typesDefault value if field is missing
validateAll typesCustom validation function
min / maxNumber, DateRange limits
minlength / maxlengthStringCharacter count limits
enumString, NumberWhitelist of allowed values
matchStringRegex pattern
trimStringStrip whitespace (sanitizer, not validator)
lowercase / uppercaseStringCase conversion (sanitizer)
uniqueAll typesCreates a unique index (not technically a validator)

Note: unique is not a Mongoose validator -- it creates a MongoDB unique index. It does not run during validation. Duplicate key errors are thrown by MongoDB, not Mongoose validators.


8. Custom Validators

Inline custom validator

const userSchema = new Schema({
  phone: {
    type: String,
    validate: {
      validator: function(value) {
        return /^\+?[\d\s-]{10,15}$/.test(value);
      },
      message: props => `${props.value} is not a valid phone number`
    }
  }
});

Async custom validator (e.g., check uniqueness)

const userSchema = new Schema({
  email: {
    type: String,
    required: true,
    validate: {
      validator: async function(value) {
        const existingUser = await mongoose.model('User').findOne({ email: value });
        // If editing an existing user, exclude their own document
        if (existingUser && existingUser._id.toString() !== this._id?.toString()) {
          return false;
        }
        return true;
      },
      message: 'Email already in use'
    }
  }
});

Multiple validators on one field

const productSchema = new Schema({
  price: {
    type: Number,
    required: [true, 'Price is required'],
    validate: [
      {
        validator: (v) => v >= 0,
        message: 'Price cannot be negative'
      },
      {
        validator: (v) => v <= 1000000,
        message: 'Price exceeds maximum allowed value'
      }
    ]
  }
});

9. Schema Options

Pass options as the second argument to new Schema():

const userSchema = new Schema({
  name: String,
  email: String
}, {
  timestamps: true,              // adds createdAt and updatedAt
  versionKey: '__v',             // version key name (default: '__v')
  collection: 'app_users',      // custom collection name
  toJSON: { virtuals: true },   // include virtuals in JSON output
  toObject: { virtuals: true }, // include virtuals in object output
  strict: true,                  // reject fields not in schema (default)
  strictQuery: true,             // reject query filters not in schema
  id: true                       // add virtual `id` getter for `_id`
});

Common schema options

OptionDefaultDescription
timestampsfalseAuto-manage createdAt and updatedAt
collectionModel name (lowercase, pluralized)Custom collection name
stricttrueDrop fields not defined in schema
strictQuerytrueFilter out query conditions on unknown fields
versionKey'__v'Field name for document version tracking
toJSON{}Transform options when calling .toJSON()
toObject{}Transform options when calling .toObject()

10. Models -- From Schema to Collection

A Model is a compiled version of a Schema. It provides the interface for CRUD operations on the collection.

// Schema -> Model
const userSchema = new Schema({ name: String, email: String });
const User = mongoose.model('User', userSchema);

// Model name: 'User'
// Collection name: 'users' (lowercase, pluralized automatically)

How naming works

mongoose.model() first argumentResulting collection name
'User'users
'Person'people
'BlogPost'blogposts
'Category'categories

To override automatic pluralization:

const userSchema = new Schema({ name: String }, { collection: 'app_users' });
const User = mongoose.model('User', userSchema);
// Collection: 'app_users' (not 'users')

Model file organization (recommended)

project/
├── models/
│   ├── User.js
│   ├── Post.js
│   └── Comment.js
├── routes/
├── app.js
└── package.json
// models/User.js
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true, trim: true },
  email: { type: String, required: true, unique: true, lowercase: true },
  role: { type: String, enum: ['user', 'admin'], default: 'user' }
}, { timestamps: true });

module.exports = mongoose.model('User', userSchema);
// In route files:
const User = require('../models/User');

11. Timestamps

The timestamps option automatically manages createdAt and updatedAt fields.

const postSchema = new Schema({
  title: String,
  content: String
}, { timestamps: true });

const Post = mongoose.model('Post', postSchema);

const post = await Post.create({ title: 'Hello', content: 'World' });
console.log(post.createdAt);  // 2026-04-11T10:30:00.000Z
console.log(post.updatedAt);  // 2026-04-11T10:30:00.000Z (same on creation)

post.title = 'Updated Hello';
await post.save();
console.log(post.updatedAt);  // 2026-04-11T11:00:00.000Z (auto-updated)

Custom timestamp field names

const schema = new Schema({ name: String }, {
  timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }
});

12. Virtuals

Virtuals are computed properties that exist on documents but are NOT stored in MongoDB.

const userSchema = new Schema({
  firstName: String,
  lastName: String,
  birthDate: Date
});

// Virtual: full name
userSchema.virtual('fullName').get(function() {
  return `${this.firstName} ${this.lastName}`;
});

// Virtual: age (computed from birthDate)
userSchema.virtual('age').get(function() {
  if (!this.birthDate) return null;
  const diff = Date.now() - this.birthDate.getTime();
  return Math.floor(diff / (365.25 * 24 * 60 * 60 * 1000));
});

// Virtual setter
userSchema.virtual('fullName').set(function(value) {
  const parts = value.split(' ');
  this.firstName = parts[0];
  this.lastName = parts.slice(1).join(' ');
});

Including virtuals in output

By default, virtuals are not included in JSON.stringify() or .toObject():

const userSchema = new Schema({
  firstName: String,
  lastName: String
}, {
  toJSON: { virtuals: true },   // include in res.json()
  toObject: { virtuals: true }  // include in console.log()
});
const user = await User.findById(id);
console.log(user.fullName);         // 'Alice Johnson' -- works
console.log(JSON.stringify(user));   // includes fullName because of toJSON option

13. Instance Methods and Static Methods

Instance methods (operate on a specific document)

userSchema.methods.getPublicProfile = function() {
  return {
    id: this._id,
    name: this.name,
    email: this.email,
    role: this.role
  };
};

userSchema.methods.isAdmin = function() {
  return this.role === 'admin';
};

// Usage:
const user = await User.findById(id);
console.log(user.isAdmin());           // true or false
res.json(user.getPublicProfile());      // sanitized output

Static methods (operate on the Model/collection)

userSchema.statics.findByEmail = function(email) {
  return this.findOne({ email: email.toLowerCase() });
};

userSchema.statics.findAdmins = function() {
  return this.find({ role: 'admin' });
};

// Usage:
const user = await User.findByEmail('alice@example.com');
const admins = await User.findAdmins();

Important: Do not use arrow functions for instance methods, static methods, or middleware. Arrow functions do not bind this, which Mongoose relies on.


14. Middleware (Hooks)

Middleware (also called hooks) are functions that run at specific stages of document or query lifecycle.

Pre-save middleware

const bcrypt = require('bcrypt');

userSchema.pre('save', async function(next) {
  // Only hash if password was modified
  if (!this.isModified('password')) return next();

  try {
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
    next();
  } catch (error) {
    next(error);
  }
});

Post-save middleware

userSchema.post('save', function(doc) {
  console.log(`User ${doc.name} was saved to the database`);
});

Pre-find middleware (query middleware)

// Automatically exclude inactive users from all find queries
userSchema.pre('find', function() {
  this.where({ isActive: true });
});

userSchema.pre('findOne', function() {
  this.where({ isActive: true });
});

Pre-remove middleware

userSchema.pre('deleteOne', { document: true, query: false }, async function() {
  // Clean up related data when a user is deleted
  await mongoose.model('Post').deleteMany({ author: this._id });
  await mongoose.model('Comment').deleteMany({ user: this._id });
});

Middleware types summary

HookTriggerCommon Use
pre('save')Before doc.save()Hash passwords, set defaults
post('save')After doc.save()Logging, notifications
pre('validate')Before validationConditional field setup
pre('find')Before Model.find()Auto-filter (soft delete)
pre('findOneAndUpdate')Before findOneAndUpdate()Set updatedAt, validate
pre('deleteOne')Before deletionCascade deletes, cleanup

15. Indexes

Indexes improve query performance. Define them in the schema:

const userSchema = new Schema({
  email: { type: String, unique: true },  // unique index
  name: String,
  role: String,
  createdAt: Date
});

// Single field index
userSchema.index({ name: 1 });            // ascending

// Compound index
userSchema.index({ role: 1, createdAt: -1 });  // role asc, date desc

// Text index (for search)
userSchema.index({ name: 'text', email: 'text' });

// TTL index (auto-delete after time)
userSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

Tip: Mongoose creates indexes automatically when the app starts (via ensureIndexes). In production, create indexes manually in MongoDB to avoid startup delays and lock contention.


16. Key Takeaways

  1. Mongoose is an ODM that adds schemas, validation, type casting, middleware, and query helpers on top of MongoDB's native driver.
  2. Schemas define the structure, types, and validation rules for documents in a collection.
  3. Models compile schemas into constructors that provide CRUD methods (create, find, findByIdAndUpdate, etc.).
  4. Validators enforce data integrity: required, min/max, enum, match, and custom functions.
  5. Timestamps ({ timestamps: true }) auto-manage createdAt and updatedAt.
  6. Virtuals are computed properties that exist in memory but are never stored in the database.
  7. Instance methods operate on individual documents; static methods operate on the entire model/collection.
  8. Middleware hooks (pre/post) let you run logic at specific lifecycle stages (e.g., hash password before save).
  9. Type casting happens automatically -- Mongoose converts "25" to 25 for Number fields.
  10. Organize models in a models/ directory, one file per model, and export with module.exports.

17. Explain-It Challenge

Can you explain to a friend: "What does Mongoose add on top of the native MongoDB driver, and why would you use it?" Walk through the journey from Schema definition to Model creation to saving a document, mentioning validation and middleware. If you can do this in under 2 minutes without notes, you have mastered this topic.


< 3.8.d -- MongoDB Data Types and Documents | 3.8.f -- CRUD Operations with Mongoose >