What is Mongoose?
Mongoose is an Object Document Mapper (ODM) for MongoDB and Node.js. It provides a schema-based solution to model your application data and includes built-in type casting, validation, query building, business logic hooks and more.
Analogy: The Translator
Think of Mongoose as a skilled translator between two worlds: your JavaScript code and MongoDB. Similar to how a human translator helps people speaking different languages communicate effectively, Mongoose helps your structured JavaScript objects communicate seamlessly with MongoDB's document-based storage system.
Without a translator, you'd need to learn all the nuances and idioms of a foreign language. Similarly, without Mongoose, you'd need to handle all the low-level details of MongoDB operations yourself.
Why Use Mongoose?
- Schema Validation: Enforce structure in a schemaless database
- Type Casting: Automatically convert types where appropriate
- Query Building: Elegant, chainable API for building queries
- Middleware: Pre and post hooks for common operations
- Population: Join-like functionality for document references
- Virtuals: Computed properties not stored in the database
Real-World Application
Many popular platforms use MongoDB with Mongoose:
- Uber uses MongoDB for storing trip data and user information
- Forbes manages content and user data with MongoDB
- Netflix leverages MongoDB for content metadata
- Shopify uses MongoDB for parts of their e-commerce platform
In all these cases, ODMs like Mongoose help make the interaction with MongoDB more structured and predictable.
Setting Up Mongoose
Let's start by setting up a connection to MongoDB using Mongoose:
// Install mongoose
// npm install mongoose
// In your server.js or db.js file
const mongoose = require('mongoose');
// Connect to MongoDB - Local development connection
mongoose.connect('mongodb://localhost:27017/myapp', {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => console.log('MongoDB connected successfully'))
.catch(err => console.error('MongoDB connection error:', err));
// For production, you'd use an environment variable for your connection string
// mongoose.connect(process.env.MONGODB_URI, {...});
Connection Best Practices
- Always handle connection errors
- Use environment variables for connection strings
- Set appropriate connection options
- Consider connection pooling for production
Creating a Schema
Schemas are the building blocks of Mongoose. They define the structure of your documents, default values, validators, etc.
const mongoose = require('mongoose');
const { Schema } = mongoose;
// Define the schema
const userSchema = new Schema({
// Basic types
firstName: {
type: String,
required: true,
trim: true
},
lastName: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Please provide a valid email address']
},
age: {
type: Number,
min: [0, 'Age cannot be negative'],
max: [120, 'Age cannot be greater than 120']
},
isActive: {
type: Boolean,
default: true
},
// Dates
createdAt: {
type: Date,
default: Date.now
},
// Nested object
address: {
street: String,
city: String,
state: String,
zipCode: String
},
// Arrays
interests: [String],
// ObjectId reference to another model
company: {
type: Schema.Types.ObjectId,
ref: 'Company'
}
});
// Create the model from the schema
const User = mongoose.model('User', userSchema);
module.exports = User;
Analogy: The Blueprint
A Mongoose schema is like a blueprint for a house. The blueprint specifies what features the house should have, where rooms should be located, and what materials should be used. Similarly, a schema defines what fields a document should have, what type of data each field contains, and any constraints on that data.
Just as deviating from a house blueprint during construction can cause structural problems, deviating from your schema when creating documents can cause data inconsistencies.
Schema Types
Mongoose supports all the JSON types and a few additional ones:
- String: Text data
- Number: Integer or floating-point
- Date: JavaScript Date object
- Buffer: Binary data (e.g., images)
- Boolean: true/false values
- Mixed: Any data type (less validation)
- ObjectId: Unique identifier (like foreign keys)
- Array: Lists of items
- Decimal128: Precise decimal values (e.g., money)
- Map: Key-value pairs
CRUD Operations
Creating Documents
// Method 1: Create instance and save
const newUser = new User({
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
age: 30,
address: {
street: '123 Main St',
city: 'New York',
state: 'NY',
zipCode: '10001'
},
interests: ['programming', 'reading', 'hiking']
});
newUser.save()
.then(user => console.log('User created:', user))
.catch(err => console.error('Error creating user:', err));
// Method 2: Using create method
User.create({
firstName: 'Jane',
lastName: 'Smith',
email: 'jane.smith@example.com',
age: 28,
address: {
street: '456 Oak Ave',
city: 'San Francisco',
state: 'CA',
zipCode: '94102'
},
interests: ['design', 'travel', 'photography']
})
.then(user => console.log('User created:', user))
.catch(err => console.error('Error creating user:', err));
Reading Documents
// Find all users
User.find()
.then(users => console.log('All users:', users))
.catch(err => console.error('Error finding users:', err));
// Find by ID
User.findById('60d21b4667d0d8992e610c85')
.then(user => console.log('User by ID:', user))
.catch(err => console.error('Error finding user by ID:', err));
// Find one document that matches criteria
User.findOne({ email: 'john.doe@example.com' })
.then(user => console.log('User by email:', user))
.catch(err => console.error('Error finding user by email:', err));
// Find with conditions, select specific fields, sort, skip, and limit
User.find({ age: { $gte: 25 } }) // Age greater than or equal to 25
.select('firstName lastName email -_id') // Include these fields, exclude _id
.sort({ lastName: 1 }) // Sort by lastName ascending
.skip(10) // Skip first 10 results
.limit(5) // Return only 5 results
.then(users => console.log('Filtered users:', users))
.catch(err => console.error('Error finding filtered users:', err));
// Populate references (like SQL JOIN)
User.findById('60d21b4667d0d8992e610c85')
.populate('company') // Populates the company field with actual company document
.then(user => console.log('User with company details:', user))
.catch(err => console.error('Error finding user with company:', err));
Updating Documents
// Update one document
User.updateOne(
{ email: 'john.doe@example.com' }, // Filter
{ $set: { age: 31 } } // Update
)
.then(result => console.log('Update result:', result))
.catch(err => console.error('Error updating user:', err));
// Find and update (returns the updated document)
User.findOneAndUpdate(
{ email: 'jane.smith@example.com' }, // Filter
{ $set: {
'address.city': 'Los Angeles',
'address.state': 'CA',
'address.zipCode': '90001'
}}, // Update
{ new: true } // Options: return the updated document
)
.then(updatedUser => console.log('Updated user:', updatedUser))
.catch(err => console.error('Error finding and updating user:', err));
// Push to arrays
User.findByIdAndUpdate(
'60d21b4667d0d8992e610c85', // ID
{ $push: { interests: 'cooking' } }, // Add to interests array
{ new: true }
)
.then(updatedUser => console.log('Updated interests:', updatedUser.interests))
.catch(err => console.error('Error updating interests:', err));
Deleting Documents
// Delete one document
User.deleteOne({ email: 'john.doe@example.com' })
.then(result => console.log('Delete result:', result))
.catch(err => console.error('Error deleting user:', err));
// Find and delete (returns the deleted document)
User.findOneAndDelete({ email: 'jane.smith@example.com' })
.then(deletedUser => console.log('Deleted user:', deletedUser))
.catch(err => console.error('Error finding and deleting user:', err));
// Delete many documents
User.deleteMany({ age: { $lt: 18 } }) // Delete all users under 18
.then(result => console.log('Bulk delete result:', result))
.catch(err => console.error('Error bulk deleting users:', err));
Middleware (Hooks)
Mongoose middleware allows you to execute code before or after certain events. This is useful for data validation, logging, business rule enforcement, etc.
const userSchema = new Schema({
// Schema definition...
});
// Pre-save hook
userSchema.pre('save', function(next) {
// 'this' refers to the document being saved
console.log(`Saving user: ${this.firstName} ${this.lastName}`);
// You can modify the document
this.updatedAt = new Date();
// You can perform validation
if (this.age < 0) {
return next(new Error('Age cannot be negative'));
}
// Continue with the save operation
next();
});
// Post-save hook
userSchema.post('save', function(doc) {
// 'doc' is the saved document
console.log(`User saved: ${doc.firstName} ${doc.lastName}`);
// You can't modify the document at this point (it's already saved)
// But you can perform other operations (logging, notifications, etc.)
});
// Hooks are available for many operations
userSchema.pre('find', function() {
// 'this' is a query object, not a document
this.startTime = Date.now();
});
userSchema.post('find', function(result) {
console.log(`Query took ${Date.now() - this.startTime}ms`);
});
Real-World Use Cases for Middleware
- Password Hashing: Hash passwords before saving to database
- Auto-updating timestamps: Update createdAt/updatedAt fields
- Data Sanitization: Trim whitespace, normalize emails
- Validation: Complex validation beyond schema capabilities
- Logging: Record all changes to sensitive data
- Business Rules: Enforce complex business logic
- Cascading Deletes: Delete related documents when a parent is deleted
Validation
Mongoose provides built-in validation as well as custom validation capabilities:
const productSchema = new Schema({
name: {
type: String,
required: [true, 'Product name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [100, 'Name cannot exceed 100 characters']
},
price: {
type: Number,
required: true,
min: [0, 'Price cannot be negative'],
validate: {
validator: function(value) {
// Custom validation logic
return value % 0.01 === 0; // Ensure price has at most 2 decimal places
},
message: props => `${props.value} is not a valid price. Prices can have at most 2 decimal places.`
}
},
sku: {
type: String,
required: true,
unique: true,
validate: {
validator: async function(value) {
// Asynchronous validation (e.g., checking uniqueness)
const count = await mongoose.models.Product.countDocuments({ sku: value });
return count === 0;
},
message: props => `SKU ${props.value} is already in use`
}
},
category: {
type: String,
enum: {
values: ['Electronics', 'Clothing', 'Books', 'Home', 'Food'],
message: '{VALUE} is not a supported category'
}
}
});
Validation Best Practices
- Use schema validation as your first line of defense
- Add custom validators for complex rules
- Implement pre-save hooks for cross-field validation
- Add meaningful error messages to help debug issues
- Remember that unique is not a validator but an index
- Validate on the client side too, but never trust client validation alone
Virtuals & Methods
Virtuals are properties that are not stored in MongoDB but can be accessed like normal properties.
// Add a virtual for full name
userSchema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
// Virtuals with setters
userSchema.virtual('fullName').set(function(name) {
const parts = name.split(' ');
this.firstName = parts[0];
this.lastName = parts.slice(1).join(' ');
});
// Example usage
const user = new User({ firstName: 'John', lastName: 'Doe' });
console.log(user.fullName); // "John Doe"
user.fullName = 'Jane Smith';
console.log(user.firstName); // "Jane"
console.log(user.lastName); // "Smith"
// Instance methods - available on documents
userSchema.methods.getInitials = function() {
return this.firstName[0] + this.lastName[0];
};
const user = new User({ firstName: 'John', lastName: 'Doe' });
console.log(user.getInitials()); // "JD"
// Static methods - available on the model
userSchema.statics.findByEmail = function(email) {
return this.findOne({ email: email });
};
const user = await User.findByEmail('john.doe@example.com');
Analogy: The Calculated Field
Virtuals are like calculated fields in a spreadsheet. They don't store actual data, but they compute values based on other fields. For example, in a spreadsheet, you might have columns for "Price" and "Quantity" and a calculated column for "Total" (Price × Quantity). If you change either Price or Quantity, Total updates automatically.
Similarly, a virtual like 'fullName' is calculated from 'firstName' and 'lastName', and updates automatically when either component changes.
Indexing
Indexes improve query performance but add overhead to write operations. Mongoose makes it easy to define indexes:
const userSchema = new Schema({
email: {
type: String,
required: true,
unique: true // This creates a unique index
},
lastName: String,
firstName: String,
createdAt: {
type: Date,
default: Date.now,
index: true // This creates a simple index
}
});
// Compound index
userSchema.index({ lastName: 1, firstName: 1 });
// Text index for searching
userSchema.index({
firstName: 'text',
lastName: 'text',
'address.city': 'text'
});
// Index with options
userSchema.index(
{ email: 1 },
{
unique: true,
partialFilterExpression: { isActive: true },
background: true,
name: 'active_email_idx'
}
);
When to Use Indexes
Consider adding indexes for:
- Fields you frequently query with (using find, findOne)
- Fields you sort by frequently
- Fields you use in aggregation pipelines
- Fields that need to be unique across all documents
But remember, each index has costs:
- Slower writes (inserts, updates, deletes)
- Increased storage space
- More memory usage
Transactions
MongoDB supports multi-document transactions since version 4.0. Mongoose provides a simple API to work with transactions:
// Example: Transfer money between accounts
async function transferMoney(fromAccountId, toAccountId, amount) {
// Start a session
const session = await mongoose.startSession();
try {
// Start a transaction
session.startTransaction();
// Perform operations within the transaction
const fromAccount = await Account.findById(fromAccountId, null, { session });
if (!fromAccount || fromAccount.balance < amount) {
throw new Error('Insufficient funds');
}
const toAccount = await Account.findById(toAccountId, null, { session });
if (!toAccount) {
throw new Error('Destination account not found');
}
// Update both accounts
await Account.updateOne(
{ _id: fromAccountId },
{ $inc: { balance: -amount } },
{ session }
);
await Account.updateOne(
{ _id: toAccountId },
{ $inc: { balance: amount } },
{ session }
);
// Add a transfer record
await TransferRecord.create([{
fromAccount: fromAccountId,
toAccount: toAccountId,
amount: amount,
date: new Date()
}], { session });
// Commit the transaction
await session.commitTransaction();
console.log('Transaction committed successfully');
} catch (error) {
// If an error occurred, abort the transaction
await session.abortTransaction();
console.error('Transaction aborted:', error);
throw error;
} finally {
// End the session
session.endSession();
}
}
Analogy: The Bank Transfer
A MongoDB transaction is like a bank transfer. When you transfer money between accounts, either the entire operation succeeds (money is deducted from one account and added to another) or the entire operation fails (no money moves at all). You never want to be in a situation where money is deducted but not credited, or vice versa.
Similarly, a MongoDB transaction ensures that a group of operations either all succeed or all fail, maintaining the consistency of your data.
Advanced Querying
Mongoose provides powerful querying capabilities, especially when combined with MongoDB's aggregation framework:
// Aggregation pipeline
const result = await User.aggregate([
// Match stage - filter documents
{ $match: { age: { $gte: 21 } } },
// Group stage - group by state and count
{ $group: {
_id: '$address.state',
count: { $sum: 1 },
averageAge: { $avg: '$age' },
users: { $push: { name: { $concat: ['$firstName', ' ', '$lastName'] }, email: '$email' } }
}},
// Sort stage - sort by count descending
{ $sort: { count: -1 } },
// Limit stage - get only top 5
{ $limit: 5 }
]);
// Using Mongoose's query builder for complex queries
const activeExperts = await User.find({
isActive: true,
skillLevel: { $gte: 8 },
$or: [
{ yearsExperience: { $gte: 5 } },
{ certifications: { $elemMatch: { level: 'advanced' } } }
]
})
.select('firstName lastName email skills')
.sort('-yearsExperience')
.limit(10);
Query Optimization Tips
- Use projection (select) to return only needed fields
- Use proper indexes for frequently used queries
- For pagination, use skip/limit with caution; consider using range queries instead
- Use lean() for read-only operations to get plain JavaScript objects
- Consider using countDocuments() instead of count() for better performance
- Use explain() to analyze query performance
Practical Application Example
Let's put it all together with a practical e-commerce example:
// Product schema
const productSchema = new Schema({
name: {
type: String,
required: true,
trim: true
},
price: {
type: Number,
required: true,
min: 0
},
description: String,
category: {
type: Schema.Types.ObjectId,
ref: 'Category',
required: true
},
inStock: {
type: Number,
default: 0,
min: 0
},
imageUrl: String,
createdAt: {
type: Date,
default: Date.now
}
});
// Add a method to check availability
productSchema.methods.isAvailable = function(quantity = 1) {
return this.inStock >= quantity;
};
// Virtual for checking if product is new (less than 7 days old)
productSchema.virtual('isNew').get(function() {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return this.createdAt > oneWeekAgo;
});
// Create the model
const Product = mongoose.model('Product', productSchema);
// Order schema
const orderSchema = new Schema({
user: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true
},
items: [{
product: {
type: Schema.Types.ObjectId,
ref: 'Product',
required: true
},
quantity: {
type: Number,
required: true,
min: 1
},
price: {
type: Number,
required: true
}
}],
total: {
type: Number,
required: true
},
shippingAddress: {
street: String,
city: String,
state: String,
zipCode: String,
country: String
},
status: {
type: String,
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
default: 'pending'
},
paymentMethod: {
type: String,
required: true,
enum: ['credit_card', 'paypal', 'bank_transfer']
},
createdAt: {
type: Date,
default: Date.now
}
});
// Pre-save hook to update inventory
orderSchema.pre('save', async function(next) {
// Only update inventory for new orders
if (!this.isNew) return next();
try {
// Start a session for transaction
const session = await mongoose.startSession();
session.startTransaction();
// Update inventory for each item
for (const item of this.items) {
const product = await Product.findById(item.product, null, { session });
if (!product) {
throw new Error(`Product ${item.product} not found`);
}
if (product.inStock < item.quantity) {
throw new Error(`Not enough inventory for ${product.name}`);
}
// Reduce inventory
await Product.updateOne(
{ _id: item.product },
{ $inc: { inStock: -item.quantity } },
{ session }
);
}
// All updates successful, commit transaction
await session.commitTransaction();
session.endSession();
next();
} catch (error) {
console.error('Error processing order:', error);
next(error);
}
});
// Create the model
const Order = mongoose.model('Order', orderSchema);
// Example usage - Create an order
async function createOrder(userId, items, shippingAddress, paymentMethod) {
try {
// Calculate total and format items
let total = 0;
const orderItems = [];
for (const item of items) {
const product = await Product.findById(item.productId);
if (!product) {
throw new Error(`Product ${item.productId} not found`);
}
if (product.inStock < item.quantity) {
throw new Error(`Not enough inventory for ${product.name}`);
}
total += product.price * item.quantity;
orderItems.push({
product: product._id,
quantity: item.quantity,
price: product.price
});
}
// Create the order
const order = await Order.create({
user: userId,
items: orderItems,
total,
shippingAddress,
paymentMethod
});
return order;
} catch (error) {
console.error('Error creating order:', error);
throw error;
}
}
// Example usage - Get detailed orders for a user
async function getUserOrders(userId) {
try {
const orders = await Order.find({ user: userId })
.populate('user', 'firstName lastName email')
.populate('items.product', 'name price imageUrl')
.sort('-createdAt');
return orders;
} catch (error) {
console.error('Error getting user orders:', error);
throw error;
}
}
// Example usage - Get sales analytics
async function getSalesAnalytics(startDate, endDate) {
try {
const results = await Order.aggregate([
{
$match: {
createdAt: { $gte: startDate, $lte: endDate },
status: { $ne: 'cancelled' }
}
},
{
$unwind: '$items'
},
{
$group: {
_id: '$items.product',
totalSold: { $sum: '$items.quantity' },
revenue: { $sum: { $multiply: ['$items.price', '$items.quantity'] } }
}
},
{
$lookup: {
from: 'products',
localField: '_id',
foreignField: '_id',
as: 'product'
}
},
{
$unwind: '$product'
},
{
$project: {
_id: 0,
productId: '$_id',
productName: '$product.name',
totalSold: 1,
revenue: 1,
category: '$product.category'
}
},
{
$sort: { revenue: -1 }
}
]);
return results;
} catch (error) {
console.error('Error getting sales analytics:', error);
throw error;
}
}
Best Practices
- Schema Design: Model your schemas based on access patterns, not just entity relationships
- Validation: Always validate data at the schema level
- Error Handling: Implement proper error handling for all database operations
- Indexes: Add appropriate indexes for performance, but don't over-index
- Connection Management: Establish a single connection and reuse it throughout your application
- Lean Queries: Use .lean() for read-only operations to improve performance
- Middleware: Use pre/post hooks for validation, business logic, and data transformation
- Security: Never trust client input, always validate and sanitize
- Transactions: Use transactions for operations that modify multiple documents and need consistency
- Use .exec(): Append .exec() to queries for better stack traces and async/await compatibility
Practical Exercises
-
Basic Schema & CRUD
Create a simple blog schema with User, Post, and Comment models. Implement full CRUD operations for each model.
-
Relationships & Population
Extend your blog application to handle relationships between models. Implement routes that populate related data.
-
Validation & Middleware
Add comprehensive validation to your models. Implement pre-save hooks for data processing (e.g., slug generation for posts).
-
Advanced Querying
Implement a search feature for your blog that searches across multiple fields using text indexes and aggregation.
-
Real-world Application
Choose a domain (e-commerce, task management, etc.) and create a complete data model with MongoDB and Mongoose. Include validation, middleware, and advanced queries.