Zero-Downtime MongoDB Schema Upgrade

Evolve a MongoDB product API with compatibility reads and lazy migration.

Introduction

30 Second Summary

A production catalog keeps serving products while its code changes. Existing MongoDB documents still need to work when the next release stores prices differently.

In this project, you will apply the Schema Versioning Pattern to the existing Product Catalog API so old and new MongoDB documents can coexist without downtime. A compatibility adapter keeps the API contract stable while versioned writes and lazy migration move edited products to schemaVersion 2.

What You'll Build

You'll run the repository's existing API, read legacy products through `/api/v1/products`, create version 2 products through its authenticated flow, and watch an edited legacy document migrate in place.

By the end of this project, you'll have:

  • A repository-specific migration built inside src/models/product.model.js and src/services/product.service.js.
  • A backward-compatible product response that keeps price available while adding pricing.amount and pricing.currency.
  • A lazy migration write path that preserves authentication, ownership, images, filtering, sorting, and pagination.
  • Secret Mission: Add a minimum-price filter to getAllProducts() that searches both price and pricing.amount.

Are there any prerequisites?

You'll need the completed product catalog repository plus access to its existing Atlas connection string. The lab adds no separate paid service, though existing paid Atlas deployment charges still apply.

Before We Start

Before any hands-on work, lock in the change you are making to your working product catalog. You are moving the legacy top-level price field into nested pricing while keeping version 1 documents readable.

Set Up an Isolated Evolution Lab

Prepare the Existing Catalog Repository

Your product catalog already contains authentication, uploads, validation, and MongoDB data. This continuation changes that working application instead of building a separate lab.

A dedicated Git branch protects the completed version while you verify the repository's current API. You will also repair two missing runtime dependencies before changing the product schema.

In this step, get ready to:
  • Create a feature branch inside the existing repository.
  • Verify the supported Node.js runtime and install the repository dependencies.
  • Run the existing tests and health endpoint before changing product data.
Open the repository and create a branch
  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Visual Studio Code and press Enter.
  • Click File in the top menu.
  • Click Open Folder.
  • Select your existing nextwork_product_catalog folder.
  • Select Terminal from the top menu.
  • Select New Terminal.
  • Create and switch to the schema-upgrade branch by running:
git switch -c feature/schema-versioning

You should see Switched to a new branch 'feature/schema-versioning'. Your completed main branch remains unchanged.

Branch already exists?

Run git switch feature/schema-versioning as a CodeBlock in your terminal if you created the branch earlier. Use help me check my schema-versioning branch if Git reports uncommitted changes.

Repair and install the dependencies

The repository uses Mongoose 9, which requires Node.js 20.19.0 or later. Its src/app.js file also imports two middleware packages missing from the current manifest.

  • Check the installed Node.js version by running:
node --version

✔️ I see version 20.19.0 or higher

Your Node.js runtime supports the repository's Mongoose release.

ⓧ I see an older version

Install the current LTS release from the official Node.js download page. Restart Visual Studio Code after the installer finishes.

ⓧ Command not found

Install Node.js from the official download page. Reopen the repository after the installer finishes.

  • Install the declared packages plus the two imported middleware packages by running:
npm install
npm install compression@1.8.1 cookie-parser@1.4.7 --save-exact

Why add two packages?

The application already calls compression() and cookieParser(). Declaring their packages makes a clean checkout match the code that starts.

The install can take a minute while npm downloads the dependency tree. You should see compression and cookie-parser under dependencies in package.json.

Dependency installation failed?

Confirm the terminal path ends in nextwork_product_catalog and your Node.js version meets the minimum. Select help me diagnose this npm install failure for help with the exact output.

Prove the existing API still works
  • Run the repository test suite with:
npm test

You should see the Node.js test runner finish without failed tests. This establishes the behavior your schema changes must preserve.

  • Start the existing development server by running:
npm run dev

You should see the MongoDB connection message followed by Server is running on port 5000. Keep this terminal running.

  • Visit http://localhost:5000/api/v1/health in your browser.

You should see {"message":"API is healthy"}. That confirms the real repository is your starting point.

Health endpoint not loading?

Check that the development terminal is still running and that your local .env contains MONGODB_URI. Use help me debug the repository health endpoint if the server exits.

Your real catalog now starts cleanly on an isolated branch. Next, you will create a controlled legacy product and teach its existing model about both schema versions.

Store Two Versions and Expose the Break

Expand the Real Product Model

Your existing catalog now runs from the feature branch with its original tests passing. The next change starts with a real product created through the repository's protected route.

Current documents store a top-level price and have no schema discriminator. You will preserve that version 1 shape while expanding the existing Mongoose model to accept nested version 2 pricing.

In this step, get ready to:
  • Create a controlled legacy product through the existing authenticated API.
  • Expand the real product model to accept both pricing layouts.
  • Confirm the legacy API response still works after the model change.
Create a controlled version 1 product

The repository's current create flow supplies the required createdBy value from the authenticated user. Creating the legacy example through that route keeps the test document valid for this application.

  • Open the imported postman_collection.json collection from the prerequisite project.
  • Run its login request with your existing test account.
  • Apply the returned access token to the collection's Bearer Token authorization.
  • Open the collection's create-product request.
  • Select Body.
  • Select raw.
  • Select JSON as the body format.
  • Replace the request body with:
{
  "name": "Legacy Reading Lamp",
  "price": 18.5,
  "category": "lighting",
  "description": "Controlled version 1 product for the schema upgrade"
}
  • Send the request to POST /api/v1/products.

You should receive status 201 with success: true. The returned product contains price: 18.5 and the fields required by the repository.

  • Record the returned product ID: your legacy product ID.

Create request rejected?

Confirm the access token belongs to the same account used in the prerequisite project. Check that the API terminal is still running. Select help me debug this protected create request for help interpreting the status code.

Expand the existing product model

A zero-downtime expansion accepts the old field and the new field at the same time. Existing documents remain valid because price becomes optional only after nested pricing is available in the schema.

  • Open src/models/product.model.js in the Visual Studio Code Explorer sidebar.
  • Find the current required price block:
price: {
  type: Number,
  required: [true, "Price is required"],
  min: [0.01, "Price must be greater than zero"],
},
  • Replace that block with the expanded pricing fields:
// Version 1 stores price at the top level.
price: {
  type: Number,
  min: [0.01, "Price must be greater than zero"],
},
// Version 2 stores the same value with its currency.
pricing: {
  amount: {
    type: Number,
    min: [0.01, "Price must be greater than zero"],
  },
  currency: {
    type: String,
    trim: true,
    uppercase: true,
  },
},
// Missing means a document predates schema versioning.
schemaVersion: {
  type: Number,
  enum: [1, 2],
},

Why is schemaVersion optional?

  • The optional top-level price keeps existing documents readable.
  • The nested pricing object introduces amount and currency without changing unrelated fields.
  • The optional schemaVersion lets pre-upgrade documents remain valid.
  • Save src/models/product.model.js.
  • Stop the development server by pressing Ctrl+C in its terminal.
  • Run the repository tests with:
npm test

You should see the test suite finish without failures. That proves the model expansion has not removed the original product behavior.

Tests cannot load the model?

Check the commas after the price, pricing, and schemaVersion blocks. Select help me check the expanded product model if a test cannot load the model.

Verify the legacy contract survives
  • Restart the repository API by running:
npm run dev

Before you read the controlled product, do you expect MongoDB to add nested pricing to the stored version 1 document automatically?

  • Open the collection's get-product request in Postman.
  • Set its product ID to your legacy product ID.
  • Send the request to GET /api/v1/products/:id.

You should still see Legacy Reading Lamp with price: 18.5. The response has no nested pricing yet because expanding a model does not rewrite stored documents.

Your real model now accepts both product generations without touching the controlled legacy record. Next, you will normalize both shapes while preserving the repository's response envelope, population, sorting, and pagination.

Normalize Both Versions at the Read Boundary

Normalize the Repository Read Paths

Your repository now recognizes both physical pricing layouts, while the controlled legacy product still returns its original shape. Clients need one dependable contract before version 2 writes begin.

A compatibility adapter can add nested pricing to every response without rewriting MongoDB data. The list service also needs an effective price for sorting so its existing pagination and sort options continue to work across both versions.

In this step, get ready to:
  • Create a repository-specific product normalizer.
  • Preserve list sorting, population, and pagination across both price locations.
  • Normalize both list and single-product responses.
Create the product normalizer

The service layer receives both hydrated Mongoose documents and plain objects returned by aggregation. The adapter handles either input before it builds the public product shape.

  • Select the src/services folder in the Visual Studio Code Explorer sidebar.
  • Click the New File button.
  • Name the file product.normalizer.js.
  • Add the compatibility adapter by pasting:
function normalizeProduct(product) {
  // Aggregations return plain objects, while other reads may return documents.
  const raw =
    typeof product.toObject === "function"
      ? product.toObject()
      : { ...product };

  // Documents without a discriminator are existing version 1 products.
  const schemaVersion = raw.schemaVersion ?? 1;
  const amount = raw.pricing?.amount ?? raw.price;
  const currency = raw.pricing?.currency ?? "USD";

  // Keep price for old clients while exposing the new nested contract.
  return {
    ...raw,
    price: amount,
    pricing: { amount, currency },
    schemaVersion,
  };
}

module.exports = { normalizeProduct };

What does this adapter preserve?

  • The input check converts hydrated documents only when necessary.
  • The nullish fallback treats a missing discriminator as version 1.
  • The response keeps top-level price for existing clients and adds nested pricing.
  • Save src/services/product.normalizer.js.
  • Confirm the file appears beside product.service.js.

Normalizer file not recognized?

Check that the filename contains product.normalizer.js with a dot between both words. Select help me check the product normalizer if the file reports a syntax error.

Preserve list behavior across both versions

The existing list service sorts directly on price. Version 2 moves that stored value, so an aggregation computes effectivePrice before sorting and removes the temporary field before the response.

  • Open src/services/product.service.js.
  • Add this import below the existing AppError import:
const { normalizeProduct } = require("./product.normalizer");
  • Find the list query beginning with const [products, total].
  • Replace that query and the return object with:
// Sort both physical price locations through one temporary value.
const sortField = sortOption.startsWith("-")
  ? sortOption.slice(1)
  : sortOption;
const sortDirection = sortOption.startsWith("-") ? -1 : 1;
const sortStage =
  sortField === "price"
    ? { effectivePrice: sortDirection }
    : { [sortField]: sortDirection };

const [products, total] = await Promise.all([
  Product.aggregate([
    { $match: filter },
    { $addFields: { effectivePrice: { $ifNull: ["$pricing.amount", "$price"] } } },
    { $sort: sortStage },
    { $skip: skip },
    { $limit: limitNum },
    { $project: { effectivePrice: 0 } },
  ]),
  Product.countDocuments(filter),
]);

// Restore the existing creator summary after aggregation.
await Product.populate(products, { path: "createdBy", select: "name email" });

return {
  products: products.map(normalizeProduct),
  total,
  page: pageNum,
  limit: limitNum,
  pages: Math.ceil(total / limitNum),
};

How does the list stay compatible?

  • The aggregation applies the existing category filter before pagination.
  • The $ifNull expression chooses nested pricing first and legacy price second.
  • The sort, skip, and limit stages preserve the repository's current list options.
  • The populate call restores the creator's name and email on the plain aggregation results.
  • Save src/services/product.service.js.
  • Run the tests with:
npm test

You should see the test suite finish without failures. Existing category, sort, page, and limit inputs still reach the same service.

List tests failing?

Check that sortStage is defined before the aggregation and that every pipeline stage is inside the array. Select help me debug the cross-version list query if a list test fails.

Normalize single-product reads
  • Find the final return statement inside getProductById().
  • Replace the current line:
return product;
  • Use the normalizer before returning the product:
return normalizeProduct(product);
  • Save src/services/product.service.js.
  • Restart the development server by running:
npm run dev

Before you repeat the read, which fields do you expect the adapter to add to the version 1 product?

  • Send the existing Postman request for GET /api/v1/products/:id with your legacy product ID.

You should see price: 18.5, pricing.amount: 18.5, pricing.currency: "USD", and schemaVersion: 1 inside the repository's existing { success, data } response.

  • Send GET /api/v1/products?sort=price&page=1&limit=5 from Postman.

You should see a normalized product array plus the existing meta object. The catalog now reads mixed schemas without changing its public envelope.

Your catalog can now read old and current price locations through one stable API contract. Next, you will keep the existing create request unchanged while storing every new product as version 2.

Make Every New Write Version 2

Store Every New Product as Version 2

Your read paths now return one stable product contract while preserving the repository's response envelopes and list controls. Existing version 1 products can stay online during the rollout.

The next boundary is creation. Clients will continue sending the familiar top-level price, while the service converts that value into nested pricing and sets schema version 2 before MongoDB receives the document.

In this step, get ready to:
  • Convert validated price input into a number.
  • Store every new product with nested pricing and schema version 2.
  • Verify authentication, creator ownership, and list sorting still work.
Normalize the validated price input

JSON sends numbers directly, while multipart image forms send price as text. The existing validator can convert both accepted inputs before the controller passes them to the service.

  • Open src/middleware/validators/product.validator.js.
  • Find the create validator's price chain ending with:
.isFloat({ gt: 0 }).withMessage('Price must be a positive number'),
  • Replace that line with:
.isFloat({ gt: 0 }).withMessage('Price must be a positive number')
.toFloat(),
  • Find the update validator's matching price chain.
  • Add .toFloat() after its successful float check.
  • Save src/middleware/validators/product.validator.js.

Why convert inside validation?

The create and update services now receive a number whether the request started as JSON or multipart form data. This keeps the storage conversion independent from the upload format.

  • Run the repository tests with:
npm test

You should see the validation tests finish without failures. Existing invalid, zero, and negative prices remain rejected.

Price validation tests failing?

Check that .toFloat() comes after .isFloat() in both validator chains. Select help me check the price validators if validation tests fail.

Convert new writes inside the service

The controller already adds createdBy and optional image fields before calling the service. Destructuring only price lets every other repository field continue through unchanged.

  • Open src/services/product.service.js.
  • Find the current create service:
const createProduct = async (data) => {
  const product = await Product.create(data);
  return product;
};
  • Replace it with the version 2 write path:
const createProduct = async (data) => {
  // Keep every existing catalog field except the legacy storage path.
  const { price, ...productData } = data;

  const product = await Product.create({
    ...productData,
    pricing: {
      amount: price,
      currency: "USD",
    },
    schemaVersion: 2,
  });

  // Preserve the current public response for older clients.
  return normalizeProduct(product);
};

How does this avoid breaking clients?

  • The rest object preserves name, category, description, ownership, and image fields.
  • The stored object uses pricing.amount, pricing.currency, and schemaVersion: 2.
  • The normalizer adds top-level price back to the response without storing it.
  • Save src/services/product.service.js.
  • Run the tests with:
npm test

You should see the suite finish without failures. The existing route, authentication middleware, controller, and upload flow remain in place.

Create service tests failing?

Confirm normalizeProduct is imported near the top of the service. Check that productData is spread before nested pricing is added. Select help me debug the version 2 create service if creation tests fail.

Create and inspect a version 2 product
  • Restart the development server by running:
npm run dev

Before you send the unchanged request shape, which pricing fields do you expect the response to contain?

  • Open the authenticated create-product request in Postman.
  • Use this JSON body:
{
  "name": "Versioned Desk Light",
  "price": 29.99,
  "category": "lighting",
  "description": "Version 2 product created through the existing API"
}
  • Send the request to POST /api/v1/products.

You should receive status 201 with top-level price: 29.99, nested pricing, schemaVersion: 2, and the authenticated creator.

  • Record the returned product ID: your version 2 product ID.
  • Send GET /api/v1/products?sort=price&page=1&limit=5.

You should see the version 1 and version 2 products sorted through their effective prices. The response still includes the repository's pagination metadata.

Every new product now moves forward without changing the route or request body used by current clients. Next, you will migrate the controlled legacy product during its normal authenticated update.

Migrate Old Products When They Change

Migrate Legacy Products During Updates

Your catalog now writes version 2 products while reading existing version 1 products through the same contract. The remaining legacy population can shrink gradually as users edit their products.

The repository's current update service loads a product for its ownership check, but then performs a separate direct update. Reusing that hydrated document lets Mongoose migrate the schema through the normal save and validation cycle.

In this step, get ready to:
  • Replace the direct update with a hydrated-document save.
  • Preserve ownership checks and Cloudinary image cleanup.
  • Update the controlled legacy product and verify its stable response.
Migrate the hydrated product before saving

The service has already confirmed that the product exists and belongs to the authenticated user. The migration can now apply requested fields, derive the effective amount, and save version 2 in one document operation.

  • Open src/services/product.service.js.
  • Find this direct update block inside updateProduct():
const updatedProduct = await Product.findByIdAndUpdate(id, data, {
  new: true,
  runValidators: true,
});
  • Replace that block with the lazy migration:
// Keep the previous image ID for cleanup after a successful save.
const oldImagePublicId = existingProduct.imagePublicId;

// Apply the same fields accepted by the existing controller.
Object.assign(existingProduct, data);

// Prefer a requested price, then either stored pricing location.
const effectiveAmount =
  data.price !== undefined
    ? data.price
    : existingProduct.pricing?.amount ?? existingProduct.price;

existingProduct.pricing = {
  amount: effectiveAmount,
  currency: existingProduct.pricing?.currency ?? "USD",
};
existingProduct.price = undefined;
existingProduct.schemaVersion = 2;

// Mongoose validates changed paths before persisting the migration.
const updatedProduct = await existingProduct.save();

How does lazy migration work here?

  • Object assignment preserves updates to name, category, description, and image fields.
  • The amount fallback handles both legacy and current storage.
  • Assigning undefined removes the legacy price during the save.
  • The save keeps the model's validation active while changing the schema version.
  • Save src/services/product.service.js.
  • Run the tests with:
npm test

You should see the suite finish without failures. The ownership check still runs before any product fields change.

Migration tests failing?

Check that effectiveAmount is calculated before price becomes undefined. Select help me debug the hydrated product migration if an update test fails.

Preserve image cleanup and normalize the result

The current image cleanup compares the updated document with itself. The saved pre-update image ID gives the service a reliable value when an upload replaces an existing Cloudinary asset.

  • Find the image cleanup condition after the save.
  • Replace the current condition with:
if (
  data.imagePublicId &&
  oldImagePublicId &&
  oldImagePublicId !== data.imagePublicId
) {
  try {
    await cloudinary.uploader.destroy(oldImagePublicId);
  } catch (cloudinaryError) {
    console.warn(
      `Failed to remove old Cloudinary image ${oldImagePublicId}:`,
      cloudinaryError.message,
    );
  }
}
  • Find the final return statement in updateProduct().
  • Replace return updatedProduct; with:
return normalizeProduct(updatedProduct);

Why normalize after the save?

Cleanup runs only after the database save succeeds. The returned product then passes through the same adapter used by read and create operations.

  • Save src/services/product.service.js.
  • Run the tests again with:
npm test

You should see the update and image-cleanup tests finish without failures. The schema migration now fits the repository's existing service responsibilities.

Image cleanup tests failing?

Confirm the cleanup condition uses oldImagePublicId in every comparison and destroy call. Select help me check the image cleanup flow if an upload-related test fails.

Run the repository's lazy migration
  • Restart the development server by running:
npm run dev

Before you update the controlled legacy product, do you expect its public price field to disappear?

  • Open the authenticated update-product request in Postman.
  • Set its product ID to your legacy product ID.
  • Use this JSON body:
{
  "price": 19.99,
  "description": "Legacy product migrated during a normal update"
}
  • Send the request to PUT /api/v1/products/:id.

You should receive status 200 with price: 19.99, nested pricing, and schemaVersion: 2. The public price remains available even though storage moved.

  • Open your product collection in MongoDB Atlas.
  • Search for Legacy Reading Lamp.
  • Confirm the document contains pricing and schemaVersion: 2.
  • Confirm the document no longer contains the top-level price field.

That is the migration working in place. The product changed physical shape during an ordinary authorized update while clients kept the same price field.

  • Send GET /api/v1/products?sort=price&page=1&limit=5.

You should see both controlled products in effective-price order with the existing meta object. Pagination and sorting still span both storage generations.

Legacy product did not migrate?

Confirm you are updating the product created by your current account. Check the response status before inspecting Atlas. Select help me debug the lazy migration request if the response is not successful.

Secret mission

Filter Both Product Schema Versions

Extend the repository's real list service with a validated minimum-price filter that searches both product schema versions while preserving category filters, sorting, and pagination.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your schema-upgrade branch, pause the local API, or remove the controlled test products. This project uses your existing MongoDB Atlas deployment, so its normal pricing continues unchanged.

Resources you used:

  • The controlled Legacy Reading Lamp product.
  • The controlled Versioned Desk Light product.
  • The local feature/schema-versioning branch.

Keep everything running

No action is needed. Choose this if you want to keep testing the schema upgrade and review the branch later.

  • Keep both controlled products so you can repeat the cross-version API checks.
  • Keep feature/schema-versioning as the isolated copy of your implementation.

Pause - I'll come back to this later

Shut down the local API to free the port while keeping your code and MongoDB products available for later.

  • Return to the terminal running npm run dev.
  • Press Ctrl+C to stop the local server.
  • Keep the feature branch and both recorded product IDs.

Delete - I don't want to use this again

This option permanently removes the two controlled products and discards the local feature branch. Confirm that you do not need the unmerged code before continuing.

  • Open the authenticated delete-product request in Postman.
  • Delete the product identified by your legacy product ID through DELETE /api/v1/products/:id.
  • Delete the product identified by your version 2 product ID through the same endpoint.

You should receive status 200 for each deletion. Any linked Cloudinary images use the repository's existing cleanup path.

  • Switch away from the feature branch and delete it by running:
git switch main
git branch -D feature/schema-versioning

You should see confirmation that Git deleted feature/schema-versioning. Your original main branch and existing Atlas deployment remain in place.

Nice Work!

Nice Work!

You did it! Your product API now evolves its MongoDB schema without downtime while keeping one stable response contract.

You've learned how to:

  • Apply the Schema Versioning Pattern to store two product shapes in one MongoDB collection.
  • Protect API contract stability with a compatibility adapter that normalizes legacy pricing into the current response shape.
  • Combine versioned writes with lazy migration so edited legacy products become schemaVersion 2 documents.
  • Secret Mission: Build a cross-version minimum-price filter that searches price plus pricing.amount through a $or query. Keep the normalized response contract unchanged.

Ready to quiz yourself?