1. The Native Node.js Driver
The official mongodb package talks to MongoDB directly, with no
schema layer on top — every method you learned in mongosh exists
here too, as an async JavaScript function.
npm install mongodb
const { MongoClient } = require('mongodb');
const client = new MongoClient('mongodb://localhost:27017');
async function main() {
await client.connect();
const db = client.db('school');
const students = db.collection('students');
// Every mongosh command you know, as an async method call
await students.insertOne({ name: 'Ben Diaz', grade: 9, gpa: 3.4 });
const ninthGraders = await students.find({ grade: 9 }).toArray();
console.log(ninthGraders);
await client.close();
}
main().catch(console.error);
find() returns a cursor, not an array directly —
.toArray() pulls every matching document into memory at once,
which is fine for small result sets but worth avoiding on a query that could
return millions of documents (iterate the cursor directly instead, in that
case). The native driver gives you full control and no extra abstraction, at
the cost of writing your own validation and structure by hand for every
collection.
2. Mongoose: Schemas & Models
Mongoose sits on top of the native driver and adds exactly what raw MongoDB doesn't enforce: a schema, defined once in code, that every document passing through it is checked against. A schema describes the shape; a model is the compiled class you actually use to query and create documents matching that shape.
npm install mongoose
const mongoose = require('mongoose');
const studentSchema = new mongoose.Schema({
name: { type: String, required: true },
grade: { type: Number, required: true },
gpa: { type: Number, default: 0 },
enrolledAt: { type: Date, default: Date.now }
});
// Mongoose pluralizes "Student" into the "students" collection automatically
const Student = mongoose.model('Student', studentSchema);
module.exports = Student;
const mongoose = require('mongoose');
const Student = require('./models/Student');
async function main() {
await mongoose.connect('mongodb://localhost:27017/school');
const ben = await Student.create({ name: 'Ben Diaz', grade: 9, gpa: 3.4 });
const ninthGraders = await Student.find({ grade: 9 });
const oneStudent = await Student.findById(ben._id);
console.log(ninthGraders);
}
main().catch(console.error);
Notice Student.find() reads almost exactly like the native driver's
students.find() — Mongoose's query API deliberately mirrors
MongoDB's own, so everything from Weeks 1–2 transfers directly. What's new is
the schema sitting in front of every write.
3. Validation in Mongoose
Where Week 3's $jsonSchema validates at the database level,
Mongoose validates in application code, before a document is ever sent to
MongoDB — which means a validation failure is a normal JavaScript error you can
catch and handle immediately, without a round trip to the server.
const productSchema = new mongoose.Schema({
name: { type: String, required: [true, 'Product name is required'], trim: true },
price: { type: Number, required: true, min: [0, 'Price cannot be negative'] },
category: {
type: String,
required: true,
enum: ['Electronics', 'Office', 'Home']
},
sku: { type: String, required: true, unique: true }
});
const Product = mongoose.model('Product', productSchema);
try {
await Product.create({ name: 'Broken Widget', price: -5, category: 'Toys' });
} catch (err) {
console.log(err.errors.price.message); // "Price cannot be negative"
console.log(err.errors.category.message); // enum validation message
}
unique: true is a common point of confusion worth flagging now: it
tells Mongoose to create a unique index on that field — the actual
uniqueness is enforced by MongoDB itself when the index builds, not by Mongoose's
own validation logic. A duplicate sku fails as a MongoDB duplicate-key
error, not a Mongoose validation error, which changes how you'd catch it.
4. Middleware: Pre & Post Hooks
Mongoose middleware (also called hooks) lets you run code automatically before
or after a specific operation on every document — a pre('save')
hook to hash a password, a post('remove') hook to clean up related
data, without repeating that logic at every call site.
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true }
});
// Runs automatically before every .save() -- including the first insert
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next(); // don't re-hash an unchanged password
this.password = await bcrypt.hash(this.password, 10);
next();
});
userSchema.post('save', function (doc) {
console.log(`New user saved: ${doc.email}`);
});
const User = mongoose.model('User', userSchema);
this.isModified('password') is the detail that matters most here:
without it, updating any other field on an existing user would re-hash an
already-hashed password, corrupting it. Checking which fields actually changed
before acting is a common, easy-to-miss requirement for pre('save')
hooks that touch sensitive fields.
5. Wiring a CRUD API Route to a Collection
Putting it together: a real Express route handler that reads and writes through a Mongoose model, with the error handling a production endpoint actually needs.
const express = require('express');
const router = express.Router();
const Product = require('../models/Product');
// GET /api/products?category=Electronics
router.get('/', async (req, res) => {
const filter = req.query.category ? { category: req.query.category } : {};
const products = await Product.find(filter);
res.json(products);
});
// POST /api/products
router.post('/', async (req, res) => {
try {
const product = await Product.create(req.body);
res.status(201).json(product);
} catch (err) {
res.status(400).json({ error: err.message }); // validation failure -> 400, not 500
}
});
// PATCH /api/products/:id
router.patch('/:id', async (req, res) => {
const updated = await Product.findByIdAndUpdate(req.params.id, req.body, {
new: true, // return the UPDATED document, not the pre-update one
runValidators: true // Mongoose does NOT validate on update by default -- opt in explicitly
});
if (!updated) return res.status(404).json({ error: 'Product not found' });
res.json(updated);
});
module.exports = router;
runValidators: true is easy to forget
By default, Mongoose only runs schema validation on create() and .save() — not on findByIdAndUpdate() or similar update methods, for historical compatibility reasons. Skip runValidators: true and an update can silently write a document that violates your schema, exactly the gap validation was supposed to close.
6. Hands-on Exercise
Build a validated, hook-enabled CRUD API for a task list
A small Express + Mongoose API, covering schema validation, a middleware hook, and every CRUD route.
Requirements:
- Define a
Taskschema:title(required string),done(boolean, defaultfalse),priority(string,enumof'low'/'medium'/'high'),createdAt(date, defaultDate.now). - Add a
pre('save')hook that trims whitespace fromtitlebefore saving, and confirm it runs by inserting a task with leading/trailing spaces in its title. - Build Express routes:
GET /api/tasks(supporting an optional?done=truefilter),POST /api/tasks,PATCH /api/tasks/:id, andDELETE /api/tasks/:id. - Make sure your
PATCHroute setsrunValidators: true, and prove it matters: try patching a task'spriorityto an invalid value and confirm it's rejected. - Test every route (Postman,
curl, or Thunder Client all work) and confirm aPOSTwith a missingtitlereturns a400, not a500or a silently-created invalid document.
If your POST route returns a 500 instead of a 400 for bad input, you're likely missing the try/catch around Product.create() from Section 5 — an uncaught Mongoose validation error propagates as an unhandled rejection, which Express turns into a generic server error.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does .toArray() actually do when calling find() with the native Node.js driver?
What does .toArray() actually do when calling find() with the native Node.js driver?
find() returns a cursor, not the results directly — .toArray() pulls every matching document into memory at once as a real array. That's convenient for small result sets, but risky for a query that could match millions of documents, where iterating the cursor directly is the safer approach.
Q2
What's the difference between a Mongoose schema and a Mongoose model?
What's the difference between a Mongoose schema and a Mongoose model?
A schema describes the shape and validation rules for a document — field types, required fields, defaults. A model is the compiled class built from that schema, and it's what you actually import and call methods like .find() or .create() on. The schema is the blueprint; the model is the usable object.
Q3
Why does a pre('save') hook that hashes a password need to check this.isModified('password') first?
Why does a pre('save') hook that hashes a password need to check this.isModified('password') first?
Without that check, the hook re-hashes the password on every save — including saves that only changed an unrelated field, like a user's email. Hashing an already-hashed password corrupts it, so the hook must confirm the password field itself actually changed before hashing it again.
Q4
Why does findByIdAndUpdate() need { runValidators: true } explicitly, when create() doesn't?
Why does findByIdAndUpdate() need { runValidators: true } explicitly, when create() doesn't?
Mongoose only runs schema validation on create()/.save() by default — update methods like findByIdAndUpdate() skip validation unless you opt in with runValidators: true. Forgetting it means an update can write a document that violates the schema's own rules, silently defeating the validation you defined.