CSE 341: Web Services

W03 Learning Activity: Mongoose

Overview

In Weeks 01 and 02, you worked directly with the MongoDB Node.js driver. This week, you will learn how about the Mongoose library, which adds models, schemas, validation, and helper methods on top of MongoDB.

Mongoose is an Object Data Modeling library, often called an ODM. An ODM gives JavaScript code a structured way to work with database documents. Instead of writing every database query directly against a collection, you define models that represent important resources in your application.

In an ecommerce application, Mongoose can help represent resources such as products, categories, orders, and customers. It can also help enforce rules about required fields and relationships between those resources.

Preparation Material

Why use Mongoose?

MongoDB is flexible. That flexibility is useful, but it also means your application code must be careful about document shape, required fields, and validation. Mongoose helps by letting you define a schema for each type of document your app stores.

Without Mongoose, your code might insert any object into a collection as long as MongoDB accepts it. That means one product document might have productName, another might have name, and a third might accidentally leave out the price. Mongoose lets your application define a more predictable shape before documents are saved.

MongoDB driver versus Mongoose

The MongoDB driver gives direct access to collections and methods such as find, insertOne, updateOne, and deleteOne. Mongoose still uses MongoDB underneath, but it gives your code a higher-level model API.

Task MongoDB driver style Mongoose style
Get all products db.collection('products').find({}).toArray() Product.find()
Create a product insertOne(productData) Product.create(productData)
Update a product updateOne(filter, update) Product.findByIdAndUpdate(id, data)
Validate required fields Usually manual validation in app code Schema validation before save/update

Example schema

import mongoose from 'mongoose';

const productSchema = new mongoose.Schema({
  sku: { type: String, required: true, unique: true },
  name: { type: String, required: true },
  price: { type: Number, required: true, min: 0 },
  category: { type: String, required: true },
  isActive: { type: Boolean, default: true }
});

const Product = mongoose.model('Product', productSchema);

export default Product;

This schema says that each product should have a unique SKU, a name, a price, a category, and an active flag. If SKU, name, price, or category is missing, Mongoose can reject the document before it is saved.

Schema field options

Mongoose schemas can include more than just field names and types. They can also include rules and defaults.

const categorySchema = new mongoose.Schema({
  slug: { type: String, required: true, unique: true, trim: true },
  displayName: { type: String, required: true, trim: true },
  department: {
    type: String,
    enum: ['electronics', 'home', 'clothing', 'outdoor'],
    required: true
  },
  sortOrder: { type: Number, min: 1, required: true },
  isVisible: { type: Boolean, default: true }
});

Unique is not the same as required

unique helps MongoDB prevent duplicate values, but it does not automatically mean the field is required. Use required when a value must be present.

A duplicate value for a unique field usually produces a MongoDB duplicate key error, often with code 11000. Your API should map that kind of conflict intentionally, often to 409 Conflict, instead of treating it as an unexpected server error.

Common Mongoose operations

Example data-access functions

In a project using MVC-style organization, you might export functions that wrap Mongoose calls. This keeps controller code focused on request and response behavior. In fact, this is a great example of why separating model logic from controller logic is important. You can change the model implementation without affecting the controller code.

The code below is an example of the model code you might find in a file like src/models/product.js. In this example, Product is the Mongoose model created from the schema above. The exported functions are the data-access functions your controllers would call.

import Product from './product-schema.js';

const getAllProducts = async () => {
  return Product.find().sort({ name: 1 });
};

const getProductById = async (productId) => {
  return Product.findById(productId);
};

const createProduct = async (productData) => {
  return Product.create(productData);
};

const updateProduct = async (productId, productData) => {
  const allowedUpdates = {};

  if (productData.name !== undefined) {
    allowedUpdates.name = productData.name;
  }

  if (productData.price !== undefined) {
    allowedUpdates.price = productData.price;
  }

  if (productData.category !== undefined) {
    allowedUpdates.category = productData.category;
  }

  if (productData.isActive !== undefined) {
    allowedUpdates.isActive = productData.isActive;
  }

  return Product.findByIdAndUpdate(
    productId,
    allowedUpdates,
    { new: true, runValidators: true }
  );
};

const deleteProduct = async (productId) => {
  return Product.findByIdAndDelete(productId);
};

export {
  getAllProducts,
  getProductById,
  createProduct,
  updateProduct,
  deleteProduct
};

The new: true option returns the updated document instead of the original document. The runValidators: true option tells Mongoose to apply schema validation during updates. The example also builds an update object from allowed fields instead of passing the full request body into the database update.

Validation errors in controllers

Mongoose validation is useful, but your controller still needs to return safe, useful API responses. A validation failure should usually be treated as a client-side input problem, often a 400 Bad Request.

const createProductHandler = async (req, res) => {
  try {
    const product = await createProduct(req.body);
    return res.status(201).json(product);
  } catch (error) {
    if (error.name === 'ValidationError') {
      return res.status(400).json({ message: 'Invalid product data' });
    }

    return res.status(500).json({ message: 'Internal server error' });
  }
};

This example does not send raw validation details to the client. In a real project, you might choose to return specific field errors, but you should do so intentionally and consistently.

When a controller uses findById() or findByIdAndUpdate(), an invalid id format can produce a Mongoose CastError. Treat invalid id formats as client input errors, usually 400. Treat a valid id that does not match a document as 404.

Relationships with references

Mongoose can store relationships by saving one document's ObjectId in another document. For example, an ecommerce product could reference a category.

const productSchema = new mongoose.Schema({
  sku: { type: String, required: true, unique: true },
  name: { type: String, required: true },
  price: { type: Number, required: true, min: 0 },
  category: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Category',
    required: true
  },
  isActive: { type: Boolean, default: true }
});

Using populate

When a schema uses a reference, the document stores an id. Sometimes the API only needs the id. Other times, the API should return related data. Mongoose's populate method can replace a referenced id with selected fields from the related document.

const product = await Product.findById(productId)
  .populate('category', 'displayName slug department');

Use populate carefully. It can make responses more useful, but it also means Mongoose may need to perform additional database work. Your API should only populate related data when the client needs it.

Choosing references or embedded documents

There is no single perfect answer for every relationship. Think about how the data changes and how the API will read it.

For example, a product might reference the category it belongs to. But an order item might store a snapshot of the product name and price at the time of purchase, because the product's current price could change later.

How Mongoose fits the railway project

In the Week 03 railway project, Mongoose-related issues might ask you to define or improve schemas, add validation, create references between collections, or migrate a route from direct MongoDB calls to Mongoose model methods.

When you work on one of these issues, pay attention to the existing codebase patterns. If the project already has a models folder, controller files, and route files, follow those conventions instead of inventing a new structure.

Activity Instructions

Complete the following:
  1. Find one existing model in the team railway codebase.
  2. Identify the schema fields, required fields, defaults, enum values, and relationship references.
  3. Find one controller or route that uses the model.
  4. Choose a field and decide whether the field should be required, optional, have a default, or have allowed values.
  5. Find a field that stores a reference to another model, or identify where a reference should exist.

Deliverables Checklist

Other Links: