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
- What Is an ODM?
- Why Mongoose Over the Native Driver?
- Installing and Connecting
- Connection Events and Options
- Schema Definition
- Schema Types in Detail
- Built-in Validators
- Custom Validators
- Schema Options
- Models — From Schema to Collection
- Timestamps
- Virtuals
- Instance Methods and Static Methods
- Middleware (Hooks)
- Indexes
- Key Takeaways
- 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
| Feature | ODM (Mongoose) | ORM (Sequelize, Prisma) |
|---|---|---|
| Database type | NoSQL (MongoDB) | SQL (PostgreSQL, MySQL) |
| Maps to | Documents in collections | Rows in tables |
| Schema | Defined in app code | Defined in DB + migrations |
| Joins | populate() (application-level) | SQL JOINs (database-level) |
| Flexibility | Semi-structured data | Strict 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.
| Feature | Native Driver | Mongoose |
|---|---|---|
| Schema enforcement | None (insert anything) | Schema with types and validation |
| Validation | Manual | Built-in + custom validators |
| Type casting | Manual | Automatic ("25" -> 25 for Number fields) |
| Middleware hooks | None | pre/post hooks on save, validate, remove, etc. |
| Query helpers | Basic | Chainable query builder with helpers |
| Population (joins) | Manual $lookup | .populate() with one line |
| Virtuals | Not available | Computed properties from existing fields |
| Learning curve | Lower | Slightly 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 Type | JavaScript Type | Example |
|---|---|---|
String | string | 'Alice' |
Number | number | 25, 3.14 |
Boolean | boolean | true, false |
Date | Date object | new Date(), Date.now |
Buffer | Buffer | Binary data |
ObjectId | ObjectId | Schema.Types.ObjectId |
Array | Array | [String], [{ name: String }] |
Decimal128 | Decimal128 | Schema.Types.Decimal128 (financial data) |
Map | Map | Dynamic key-value pairs |
Mixed | Any | Schema.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
| Validator | Applies To | Description |
|---|---|---|
required | All types | Field must be present and not null/undefined/empty-string |
default | All types | Default value if field is missing |
validate | All types | Custom validation function |
min / max | Number, Date | Range limits |
minlength / maxlength | String | Character count limits |
enum | String, Number | Whitelist of allowed values |
match | String | Regex pattern |
trim | String | Strip whitespace (sanitizer, not validator) |
lowercase / uppercase | String | Case conversion (sanitizer) |
unique | All types | Creates a unique index (not technically a validator) |
Note:
uniqueis 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
| Option | Default | Description |
|---|---|---|
timestamps | false | Auto-manage createdAt and updatedAt |
collection | Model name (lowercase, pluralized) | Custom collection name |
strict | true | Drop fields not defined in schema |
strictQuery | true | Filter 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 argument | Resulting 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
| Hook | Trigger | Common Use |
|---|---|---|
pre('save') | Before doc.save() | Hash passwords, set defaults |
post('save') | After doc.save() | Logging, notifications |
pre('validate') | Before validation | Conditional field setup |
pre('find') | Before Model.find() | Auto-filter (soft delete) |
pre('findOneAndUpdate') | Before findOneAndUpdate() | Set updatedAt, validate |
pre('deleteOne') | Before deletion | Cascade 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
- Mongoose is an ODM that adds schemas, validation, type casting, middleware, and query helpers on top of MongoDB's native driver.
- Schemas define the structure, types, and validation rules for documents in a collection.
- Models compile schemas into constructors that provide CRUD methods (
create,find,findByIdAndUpdate, etc.). - Validators enforce data integrity:
required,min/max,enum,match, and custom functions. - Timestamps (
{ timestamps: true }) auto-managecreatedAtandupdatedAt. - Virtuals are computed properties that exist in memory but are never stored in the database.
- Instance methods operate on individual documents; static methods operate on the entire model/collection.
- Middleware hooks (
pre/post) let you run logic at specific lifecycle stages (e.g., hash password before save). - Type casting happens automatically -- Mongoose converts
"25"to25for Number fields. - Organize models in a
models/directory, one file per model, and export withmodule.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 >