๐ฆค Mongoose Schemas & Models
MongoDB happily stores whatever shape of document you throw at it โ which is freedom right up until it becomes chaos. Mongoose puts a friendly, structured layer on top: you describe the shape your data should have once, and every read and write is type-cast, validated, and easy to reason about. This lesson is where that structure begins.
Week 8 · Day 4 (Thursday: Mongoose ODM) · Lecture 1
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain what an ODM is and why Mongoose sits between your app and MongoDB
- Connect to MongoDB with modern
mongoose.connect()and async/await - Define a
Schemausing the built-in SchemaTypes and field options - Compile a schema into a
modeland use it for full CRUD operations - Trace the schema โ model โ document relationship in your own code
- Add instance methods, statics, and virtuals to enrich a model
Estimated Time: 70 minutes
Practice: Model a Book collection and run create/read/update/delete against it.
In This Lesson
Why Mongoose?
MongoDB is schema-less by design: one document in a collection can have ten fields and the next can have three, and the database won't complain. That flexibility is great for prototyping and terrible for a team that expects every user to have an email. Mongoose is an ODM โ an Object Data Modeling library โ that lets you declare the structure you want and then enforces it for you.
Think of Mongoose as a translator and a bouncer standing between your Node.js code and the database. As a translator, it converts your plain JavaScript objects into MongoDB documents and back, casting types along the way. As a bouncer, it checks every document against the rules you set before it lets anything into the collection.
plain JS objects] --> B[Mongoose ODM] B --> C[(MongoDB
documents)] B --> D[Type casting] B --> E[Validation] B --> F[Query building] B --> G[Middleware hooks]
Mongoose vs. the native driver
You can talk to MongoDB with the official driver directly. Mongoose builds on top of it and gives you structure, validation, and convenience for free.
| Concern | Native driver | Mongoose |
|---|---|---|
| Structure | You enforce it by hand | Declared once in a schema |
| Validation | Roll your own | Built-in + custom validators |
| Type casting | Manual | Automatic (e.g. "42" โ 42) |
| Business logic | Scattered in app code | Attached to the model (methods, hooks) |
| Learning curve | Lower | Slightly higher, pays off fast |
๐ A note on versions
This lesson uses Mongoose 7/8 with async/await. Legacy connect flags like useNewUrlParser and useUnifiedTopology are no longer needed โ they were removed as no-ops. If you see them in an old tutorial, just delete them.
Installing & Connecting
Install Mongoose from npm:
npm install mongoose
Then connect. In modern Mongoose the connection options object is optional โ a clean connection string is usually all you need. Keep the URI in an environment variable so credentials never live in your source code.
// db.js โ a small, reusable connection module
import mongoose from 'mongoose';
import 'dotenv/config';
export async function connectDB() {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log('โ
MongoDB connected');
} catch (err) {
console.error('โ MongoDB connection error:', err.message);
process.exit(1); // fail fast โ the app is useless without its database
}
}
// Surface connection lifecycle events (helpful in production):
mongoose.connection.on('disconnected', () => console.warn('MongoDB disconnected'));
mongoose.connection.on('reconnected', () => console.log('MongoDB reconnected'));
// Close the connection cleanly on shutdown:
process.on('SIGINT', async () => {
await mongoose.connection.close();
console.log('MongoDB connection closed โ exiting');
process.exit(0);
});
๐ก One connection for the whole app
You call mongoose.connect() once at startup. Mongoose maintains a shared connection pool behind the scenes, so every model you define automatically uses it โ there's no need to pass a connection around.
Defining a Schema
A schema is a blueprint. It describes what fields a document has, what type each field is, and what rules those fields must obey. A schema by itself doesn't touch the database โ it's just a description.
import mongoose from 'mongoose';
const { Schema } = mongoose;
const userSchema = new Schema({
firstName: { type: String, required: true, trim: true },
lastName: { type: String, required: true, trim: true },
email: {
type: String,
required: true,
unique: true, // builds a unique INDEX (see the caution below)
lowercase: true, // normalises "ADA@X.com" โ "ada@x.com" on save
trim: true
},
age: { type: Number, min: 18, max: 120 },
isActive: { type: Boolean, default: true },
birthDate: Date, // shorthand: a plain type with no options
// A nested object:
address: {
street: String,
city: String,
country: { type: String, default: 'USA' }
},
// An array of strings, and an array of sub-objects:
hobbies: [String],
education: [{ school: String, degree: String, year: Number }]
}, {
timestamps: true // auto-adds createdAt & updatedAt โ use this, don't hand-roll it
});
โ ๏ธ unique is NOT a validator
Despite living among required and min, unique: true does not validate anything. It tells MongoDB to build a unique index. If you insert a duplicate email you won't get a Mongoose ValidationError โ you'll get a MongoDB duplicate-key error (E11000), which you must catch separately. Also, the index only exists after MongoDB finishes building it; on a fresh collection, call await Model.syncIndexes() to be sure.
SchemaTypes & Options
Every field has a SchemaType that tells Mongoose how to cast and store the value. These are the ones you'll reach for constantly:
| SchemaType | Stores | Example field |
|---|---|---|
String | Text | name: String |
Number | Ints & floats | price: Number |
Boolean | true / false | inStock: Boolean |
Date | Timestamps | publishedAt: Date |
ObjectId | A reference to another doc | author: { type: Schema.Types.ObjectId, ref: 'User' } |
Array | Lists | tags: [String] |
Buffer | Binary data | thumbnail: Buffer |
Mixed | Anything (no casting) | meta: Schema.Types.Mixed |
Common field options
The object after type configures the field. These appear everywhere:
const productSchema = new Schema({
name: { type: String, required: true, trim: true },
slug: { type: String, lowercase: true },
price: { type: Number, required: true, min: 0, default: 0 },
sku: { type: String, immutable: true }, // can't change after creation
role: { type: String, enum: ['draft', 'live'], default: 'draft' },
notes: { type: String, select: false } // excluded from queries by default
});
Here's how those pieces fit together end to end: a schema definition is a collection of fields, each field is a SchemaType plus options, and the whole thing is what a model will enforce.
From Schema to Model
A schema is inert. To actually create, find, and update documents you compile the schema into a model with mongoose.model(). The model is a constructor bound to a MongoDB collection, and every document you make is an instance of that model.
// The model name 'User' โ Mongoose pluralises & lowercases it to
// the collection 'users' automatically.
const User = mongoose.model('User', userSchema);
// One model, three concepts:
// Schema โ the blueprint (userSchema)
// Model โ the factory + query interface (User)
// Document โ a single instance (const u = new User({...}))
This three-step relationship is the mental model to lock in for the entire week:
(blueprint / rules)"] -->|"mongoose.model()"| B["Model
(collection interface)"] B -->|"new User(...) or User.create()"| C["Document
(one record)"] C -->|".save()"| D[(users collection)] B -->|"User.find()"| D
โ ๏ธ Compile each model only once
Call mongoose.model('User', schema) a single time per model, usually in its own file that you import everywhere else. Compiling the same model name twice throws OverwriteModelError. Export the compiled model, not the schema, from your model files.
CRUD with a Model
Once you have a model, the four basic operations โ Create, Read, Update, Delete โ are all one-liners. Every method returns a promise, so await it.
Create
// Option A: build then save (gives you the instance first)
const user = new User({ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@x.com', age: 36 });
await user.save();
// Option B: create in one call (validates & saves)
const grace = await User.create({
firstName: 'Grace', lastName: 'Hopper', email: 'grace@x.com', age: 40
});
Read
await User.find(); // every user (an array)
await User.find({ isActive: true }); // filtered
await User.findById(id); // by _id, or null
await User.findOne({ email: 'ada@x.com' }); // first match, or null
// Chainable query building:
const adults = await User.find({ age: { $gte: 18 } })
.select('firstName email') // only these fields
.sort({ firstName: 1 }) // AโZ
.limit(10)
.lean(); // plain objects, faster (see best practices)
Update
// Find, mutate, save โ runs full document validation & hooks:
const u = await User.findById(id);
u.age = 41;
await u.save();
// Or update in place. Pass { new: true } to return the UPDATED doc,
// and { runValidators: true } to apply schema validation to the update.
const updated = await User.findByIdAndUpdate(
id,
{ age: 41 },
{ new: true, runValidators: true }
);
Delete
await User.findByIdAndDelete(id);
await User.deleteOne({ email: 'ada@x.com' });
await User.deleteMany({ isActive: false });
What User.create() gives back
{
_id: ObjectId("6650f1..."),
firstName: "Grace",
lastName: "Hopper",
email: "grace@x.com",
age: 40,
isActive: true, // filled from the schema default
hobbies: [],
createdAt: 2026-07-31T..., // added by timestamps:true
updatedAt: 2026-07-31T...,
__v: 0
}
Methods, Statics & Virtuals
Because a model bundles behaviour with data, you can attach reusable logic right to the schema. There are three flavours, and beginners mix them up constantly โ so anchor them by who calls them.
Instance methods โ called on one document
userSchema.methods.getFullName = function () {
return `${this.firstName} ${this.lastName}`; // 'this' = the document
};
const u = await User.findById(id);
u.getFullName(); // "Ada Lovelace"
Statics โ called on the model itself
userSchema.statics.findByEmail = function (email) {
return this.findOne({ email }); // 'this' = the model
};
await User.findByEmail('ada@x.com');
Virtuals โ computed properties that aren't stored
A virtual is a field that is calculated on the fly and never written to MongoDB. Perfect for derived values like a full name.
userSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`;
});
// Virtuals are hidden from JSON by default โ opt them in on the schema:
const userSchema = new Schema({ /* ... */ }, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
โ Method vs. static vs. virtual, in one line each
- Instance method โ behaviour for a single record (
doc.getFullName()). - Static โ a query helper on the whole collection (
User.findByEmail()). - Virtual โ a read-only derived value that is never persisted (
doc.fullName).
Always use a regular function (not an arrow) so this binds correctly.
Practice & Quiz
๐๏ธ Exercise 1: Model a Book
Goal: Define a bookSchema and compile it into a Book model. A book has a required title (trimmed), a required author string, a pages number that must be at least 1, a genre restricted to a small list, an inStock boolean defaulting to true, and automatic timestamps.
๐ก Hint
Use required: true and trim: true on the title, min: 1 on pages, and an enum array on genre. Pass { timestamps: true } as the schema's second argument. Compile with mongoose.model('Book', bookSchema).
โ Solution
import mongoose from 'mongoose';
const { Schema } = mongoose;
const bookSchema = new Schema({
title: { type: String, required: true, trim: true },
author: { type: String, required: true },
pages: { type: Number, min: 1 },
genre: { type: String, enum: ['fiction', 'nonfiction', 'poetry', 'reference'] },
inStock: { type: Boolean, default: true }
}, { timestamps: true });
const Book = mongoose.model('Book', bookSchema);
export default Book;
๐๏ธ Exercise 2: Full CRUD run
Goal: Using the Book model, create a book, read it back, raise its page count, then delete it โ all with async/await.
โ Solution
// Create
const book = await Book.create({
title: 'Structure and Interpretation of Computer Programs',
author: 'Abelson & Sussman', pages: 657, genre: 'reference'
});
// Read
const found = await Book.findById(book._id);
console.log(found.title);
// Update (return the updated doc, run validators)
const updated = await Book.findByIdAndUpdate(
book._id, { pages: 660 }, { new: true, runValidators: true }
);
console.log(updated.pages); // 660
// Delete
await Book.findByIdAndDelete(book._id);
๐ฏ Quick Quiz
Question 1: What does mongoose.model('User', userSchema) return?
Question 2: Which statement about unique: true is correct?
Question 3: You want a fullName derived from firstName and lastName but never stored in the database. You should use a:
Best Practices & Pitfalls
โ Do
- Keep one model per file and
importit wherever needed - Use
{ timestamps: true }instead of hand-managingcreatedAt/updatedAt - Pass
{ new: true, runValidators: true }tofindByIdAndUpdate - Use
.lean()for read-only queries that don't need document methods - Store your connection string in an environment variable
โ Don't
- Rely on
unique: trueto reject duplicates gracefully โ catchE11000yourself - Compile the same model name twice (causes
OverwriteModelError) - Use arrow functions for methods/virtuals โ they break
this - Forget that
findByIdAndUpdateskips validators unless you ask for them
โ ๏ธ Mixed won't track changes
doc.meta = doc.meta || {};
doc.meta.views = 10;
doc.markModified('meta'); // required โ Mongoose can't detect changes on Mixed
await doc.save();
Because Mixed fields have no schema, Mongoose can't tell when they change. Call markModified() or the save silently does nothing.
Summary
๐ Key Takeaways
- Mongoose is an ODM that adds structure, casting, and validation on top of MongoDB
- A schema is the blueprint;
mongoose.model()compiles it into a model; instances are documents - SchemaTypes (String, Number, Date, ObjectId, โฆ) plus field options define each field
- CRUD is one-liners:
create,find/findById,findByIdAndUpdate,findByIdAndDelete - Methods, statics, and virtuals attach behaviour to your model
uniqueis an index, not a validator
๐ Additional Resources
- Mongoose โ Schemas guide
- Mongoose โ SchemaTypes reference
- Mongoose โ Models
- Mongoose โ Queries
๐ What's Next?
Your schema can describe shape, but real apps need to reject bad data and run logic at key moments. Next up: Validation & Middleware โ built-in and custom validators, and the pre/post hooks that fire around save, find, and update.
๐ Schema mastered!
You can now model a collection, compile it into a model, and run full CRUD. Every Mongoose feature from here builds on exactly this.