Week 4: Mongoose & the Node.js Driver

Everything so far ran in mongosh. This week moves the same operations into real application code — the raw Node.js driver first, so you see exactly what's underneath, then Mongoose's schema layer on top of it, which is what most production Node.js apps actually use day to day.

Module 4 of 10 Week 4 of 10 ~3 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Connect to MongoDB and run CRUD operations from Node.js, with and without Mongoose
  • Define a Mongoose schema with validation, and know what a model actually is
  • Wire a working CRUD API route to a MongoDB collection

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.

terminal
npm install mongodb
db.js — connecting with the native driver
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.

terminal
npm install mongoose
models/Student.js
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;
app.js — connecting & using the model
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.

models/Product.js — richer validation
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);
catching a validation error
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.

models/User.js
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.

routes/products.js
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

Hands-on

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:

  1. Define a Task schema: title (required string), done (boolean, default false), priority (string, enum of 'low'/'medium'/'high'), createdAt (date, default Date.now).
  2. Add a pre('save') hook that trims whitespace from title before saving, and confirm it runs by inserting a task with leading/trailing spaces in its title.
  3. Build Express routes: GET /api/tasks (supporting an optional ?done=true filter), POST /api/tasks, PATCH /api/tasks/:id, and DELETE /api/tasks/:id.
  4. Make sure your PATCH route sets runValidators: true, and prove it matters: try patching a task's priority to an invalid value and confirm it's rejected.
  5. Test every route (Postman, curl, or Thunder Client all work) and confirm a POST with a missing title returns a 400, not a 500 or a silently-created invalid document.
Hint

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?

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?

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?

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?

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.