Models
Your data's blueprint - schemas, validation, and the bridge between your code and the database.
The Blueprint Analogy#
Before building a house, you need blueprints. They define what rooms exist, how big they are, where the doors go. Without blueprints, you'd have chaos.
Models are blueprints for your data. They define:
- What fields exist (name, email, createdAt)
- What types they are (string, number, date)
- What rules they must follow (required, unique, min/max)
- How they relate to other data (user has many posts)
Mongoose Models#
Mongoose is the standard for MongoDB in Node.js. It gives you schemas (blueprints) and models (factories that create documents).
// src/models/User.js
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [8, 'Password must be at least 8 characters'],
select: false, // Never include in queries by default
},
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
maxlength: [100, 'Name cannot exceed 100 characters'],
},
role: {
type: String,
enum: ['user', 'admin', 'moderator'],
default: 'user',
},
isActive: {
type: Boolean,
default: true,
},
}, {
timestamps: true, // Adds createdAt and updatedAt
});
export const User = mongoose.model('User', userSchema);
Field Types#
Mongoose supports these types:
const exampleSchema = new mongoose.Schema({
// Primitives
name: String,
age: Number,
isActive: Boolean,
birthDate: Date,
// With options
email: {
type: String,
required: true,
unique: true,
},
// Arrays
tags: [String],
scores: [Number],
// Nested objects
address: {
street: String,
city: String,
zipCode: String,
country: { type: String, default: 'USA' },
},
// Reference to another model
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
// Array of references
followers: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
}],
// Mixed (any JSON) - use sparingly
metadata: mongoose.Schema.Types.Mixed,
});
Validation#
Mongoose validates data before saving:
const productSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Product name is required'],
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [200, 'Name cannot exceed 200 characters'],
},
price: {
type: Number,
required: true,
min: [0, 'Price cannot be negative'],
},
sku: {
type: String,
required: true,
unique: true,
uppercase: true,
match: [/^[A-Z0-9-]+$/, 'Invalid SKU format'],
},
category: {
type: String,
required: true,
enum: {
values: ['electronics', 'clothing', 'food', 'other'],
message: '{VALUE} is not a valid category',
},
},
stock: {
type: Number,
default: 0,
min: 0,
validate: {
validator: Number.isInteger,
message: 'Stock must be a whole number',
},
},
});
Custom Validators#
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
validate: {
validator: function(v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
},
message: props => `${props.value} is not a valid email`,
},
},
age: {
type: Number,
validate: {
validator: function(v) {
return v >= 18 && v <= 120;
},
message: 'Age must be between 18 and 120',
},
},
// Async validator
username: {
type: String,
validate: {
validator: async function(v) {
const count = await this.constructor.countDocuments({ username: v });
return count === 0;
},
message: 'Username already taken',
},
},
});
Schema Options#
const postSchema = new mongoose.Schema({
title: String,
content: String,
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
}, {
timestamps: true, // Add createdAt, updatedAt
toJSON: { virtuals: true }, // Include virtuals in JSON
toObject: { virtuals: true },
collection: 'blog_posts', // Custom collection name
});
Virtuals#
Computed properties that don't get stored:
const userSchema = new mongoose.Schema({
firstName: String,
lastName: String,
});
// Virtual property
userSchema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
// Usage
const user = await User.findById(id);
console.log(user.fullName); // "John Doe"
Virtual Populate#
Reference related documents without storing IDs:
const userSchema = new mongoose.Schema({
name: String,
});
// Virtual for user's posts
userSchema.virtual('posts', {
ref: 'Post',
localField: '_id',
foreignField: 'author',
});
// Usage
const user = await User.findById(id).populate('posts');
console.log(user.posts); // Array of user's posts
Middleware (Hooks)#
Run code before or after operations:
import bcrypt from 'bcrypt';
const userSchema = new mongoose.Schema({
email: String,
password: String,
});
// Hash password before saving
userSchema.pre('save', async function(next) {
// Only hash if password changed
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
// Log after saving (useful for debugging)
userSchema.post('save', function(doc) {
console.log('User saved:', doc._id);
});
// Run before any find query
userSchema.pre(/^find/, function(next) {
// Exclude inactive users by default
this.where({ isActive: { $ne: false } });
next();
});
// Clean up related data on delete
userSchema.pre('findOneAndDelete', async function(next) {
const userId = this.getQuery()._id;
await Post.deleteMany({ author: userId });
next();
});
Instance Methods#
Add methods to individual documents:
const userSchema = new mongoose.Schema({
email: String,
password: { type: String, select: false },
});
// Check password
userSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
// Generate auth token
userSchema.methods.generateAuthToken = function() {
return jwt.sign(
{ id: this._id, email: this.email },
config.jwt.secret,
{ expiresIn: config.jwt.expiresIn }
);
};
// Usage
const user = await User.findById(id).select('+password');
const isMatch = await user.comparePassword('password123');
const token = user.generateAuthToken();
Static Methods#
Add methods to the model itself:
const userSchema = new mongoose.Schema({
email: String,
role: String,
});
// Find by email
userSchema.statics.findByEmail = function(email) {
return this.findOne({ email: email.toLowerCase() });
};
// Find admins
userSchema.statics.findAdmins = function() {
return this.find({ role: 'admin' });
};
// Usage
const user = await User.findByEmail('test@test.com');
const admins = await User.findAdmins();
Indexes#
Speed up queries and enforce uniqueness:
const userSchema = new mongoose.Schema({
email: { type: String, unique: true }, // Creates unique index
username: { type: String, index: true }, // Creates regular index
name: String,
createdAt: Date,
});
// Compound index
userSchema.index({ name: 'text', email: 'text' }); // Text search
userSchema.index({ createdAt: -1 }); // Sort by date desc
userSchema.index({ role: 1, isActive: 1 }); // Compound for common queries
Index Performance
Indexes speed up reads but slow down writes. Only index fields you frequently query. MongoDB has a limit of 64 indexes per collection.
Practical Model Examples#
User Model#
// src/models/User.js
import mongoose from 'mongoose';
import bcrypt from 'bcrypt';
const userSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email format'],
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: 8,
select: false,
},
name: {
type: String,
required: true,
trim: true,
maxlength: 100,
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user',
},
avatar: String,
isActive: {
type: Boolean,
default: true,
},
lastLogin: Date,
}, {
timestamps: true,
});
// Hash password before save
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
// Compare password
userSchema.methods.comparePassword = async function(candidate) {
return bcrypt.compare(candidate, this.password);
};
// Remove password from JSON
userSchema.methods.toJSON = function() {
const obj = this.toObject();
delete obj.password;
return obj;
};
userSchema.index({ email: 1 });
userSchema.index({ createdAt: -1 });
export const User = mongoose.model('User', userSchema);
Post Model with References#
// src/models/Post.js
import mongoose from 'mongoose';
const postSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
maxlength: 200,
},
slug: {
type: String,
unique: true,
lowercase: true,
},
content: {
type: String,
required: true,
},
excerpt: {
type: String,
maxlength: 500,
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
tags: [{
type: String,
lowercase: true,
}],
status: {
type: String,
enum: ['draft', 'published', 'archived'],
default: 'draft',
},
publishedAt: Date,
viewCount: {
type: Number,
default: 0,
},
}, {
timestamps: true,
});
// Generate slug before save
postSchema.pre('save', function(next) {
if (this.isModified('title')) {
this.slug = this.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
next();
});
// Auto-populate author
postSchema.pre(/^find/, function(next) {
this.populate('author', 'name avatar');
next();
});
postSchema.index({ slug: 1 });
postSchema.index({ author: 1, status: 1 });
postSchema.index({ tags: 1 });
postSchema.index({ title: 'text', content: 'text' });
export const Post = mongoose.model('Post', postSchema);
Query Helpers#
Create reusable query modifiers:
const postSchema = new mongoose.Schema({ ... });
postSchema.query.published = function() {
return this.where({ status: 'published' });
};
postSchema.query.byAuthor = function(authorId) {
return this.where({ author: authorId });
};
postSchema.query.recent = function() {
return this.sort({ createdAt: -1 });
};
// Usage - chain helpers
const posts = await Post.find()
.published()
.byAuthor(userId)
.recent()
.limit(10);
Key Takeaways#
- Schemas define structure - Fields, types, validation rules
- Validation happens automatically - Use built-in validators, add custom ones
- Middleware for side effects - Hash passwords, clean up relations
- Instance methods for documents - comparePassword, generateToken
- Static methods for models - findByEmail, findAdmins
- Index frequently queried fields - But don't over-index
The Pattern
Put data logic in models (validation, hooks, methods). Put business logic in services. Models answer "what is valid data?" Services answer "what do we do with it?"
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.