Project: Online Shop Backend

MongoDB · Lesson 28 of 28 10 min read Updated

Time to put the whole course together. In this project you will build the backend of the practice shop as a small Node.js application. It places orders safely, cancels them, shows a customer's order history and prints a sales report, all from the command line. A web or mobile front end would call exactly these functions; only the way they are triggered would change.

You will use most of what you have learned: indexes, schema validation, an order-number counter, transactions, projections, sorting and an aggregation report with $facet. The finished app has five small files and fewer than 200 lines of code.

What you need

  • Node.js 20.19 or newer, and the mongodb driver, as set up in The Node.js Driver.
  • A MongoDB server that supports transactions: an Atlas cluster, or a local server running as a replica set (see Transactions).
  • The practice data, freshly loaded with load('shop-data.js') from The Practice Shop Database.

Create the project folder:

Terminal
mkdir shop-backend
cd shop-backend
npm init -y
npm pkg set type=module
npm install mongodb

Then create a .env file containing your connection string, for example MONGODB_URI=mongodb://127.0.0.1:27017/?replicaSet=rs0 for a local replica set, or your mongodb+srv:// string for Atlas. Every command in this project is run as node --env-file=.env shop.js <command>.

The app will support these commands:

CommandWhat it does
setupCreates indexes, adds validation to orders and starts the order counter
order <customer> <product:qty>...Places an order in a transaction, reducing stock
cancel <orderNo>Cancels a pending order and puts the stock back
history <customer>Lists a customer's orders, newest first
reportShows revenue per month in IST and the best-selling products

Step 1: the shared connection

Every part of the app uses one MongoClient, created in db.js. The module also exports the collections, so other files do not repeat collection names:

JavaScript
import { MongoClient } from 'mongodb';

export const client = new MongoClient(process.env.MONGODB_URI);
const db = client.db('shop');

export const customers = db.collection('customers');
export const products = db.collection('products');
export const orders = db.collection('orders');
export const counters = db.collection('counters');
export { db };

Creating the client does not connect yet; the first operation does. One client means one connection pool for the whole program, which is exactly what a web server would use too.

Step 2: indexes, validation and the counter

Before the app takes orders, the database needs the right indexes, rules that protect order data, and a counter for order numbers. Put this in setup.js:

JavaScript
import { db, customers, products, orders, counters } from './db.js';

export async function setup() {
  // 1. Indexes for the queries the app runs
  await customers.createIndex({ email: 1 }, { unique: true });
  await products.createIndex({ category: 1, price: 1 });
  await orders.createIndex({ customerId: 1, date: -1 });
  await orders.createIndex({ status: 1, date: -1 });

  // 2. Validation rules for new and changed orders
  await db.command({
    collMod: 'orders',
    validator: {
      $jsonSchema: {
        bsonType: 'object',
        required: ['customerId', 'date', 'status', 'items', 'total'],
        properties: {
          customerId: { bsonType: 'int' },
          date: { bsonType: 'date' },
          status: { enum: ['pending', 'shipped', 'delivered', 'cancelled'] },
          items: { bsonType: 'array', minItems: 1 },
          total: { bsonType: 'number', minimum: 0 }
        }
      }
    },
    validationLevel: 'strict'
  });

  // 3. Order number counter, starting after the existing orders
  await counters.updateOne({ _id: 'orderNo' }, { $setOnInsert: { seq: 1012 } }, { upsert: true });
}

Each index serves a real query. { customerId: 1, date: -1 } answers "this customer's orders, newest first" straight from the index, following the ESR rule from Indexes. { status: 1, date: -1 } serves the support team's "pending orders" screen. The unique email index stops duplicate accounts at the database level.

The validator, from Schema Validation with $jsonSchema, guarantees that no code path can store an order without items, with an unknown status or with a negative total. The Node.js driver stores whole JavaScript numbers that fit in 32 bits as int, so customerId: { bsonType: 'int' } matches what the app writes.

The counter uses an upsert with $setOnInsert from Upserts & Bulk Writes, so running setup twice never resets it. All of setup() is safe to repeat: creating an existing index does nothing, and collMod simply sets the same rules again.

Step 3: placing an order in a transaction

Placing an order touches three collections: it reduces stock in products, takes the next number from counters and inserts into orders. Either all of that happens or none of it, so it runs in a transaction. Create orders.js and start with placeOrder():

JavaScript
import { client, customers, products, orders, counters } from './db.js';

export async function placeOrder(customerId, lines, payment = 'UPI') {
  const session = client.startSession();
  let order;
  try {
    await session.withTransaction(async () => {
      const customer = await customers.findOne({ _id: customerId }, { session });
      if (!customer) throw new Error(`No customer with id ${customerId}`);

      const items = [];
      for (const { productId, qty } of lines) {
        const product = await products.findOneAndUpdate(
          { _id: productId, stock: { $gte: qty } },
          { $inc: { stock: -qty } },
          { session, returnDocument: 'after', projection: { name: 1, price: 1 } }
        );
        if (!product) throw new Error(`Product ${productId} is unknown or has fewer than ${qty} in stock`);
        items.push({ productId, name: product.name, price: product.price, qty });
      }

      const counter = await counters.findOneAndUpdate(
        { _id: 'orderNo' },
        { $inc: { seq: 1 } },
        { session, returnDocument: 'after' }
      );
      order = {
        _id: counter.seq,
        customerId,
        date: new Date(),
        status: 'pending',
        payment,
        items,
        total: items.reduce((sum, i) => sum + i.price * i.qty, 0)
      };
      await orders.insertOne(order, { session });
    });
    return order;
  } finally {
    await session.endSession();
  }
}

Several ideas from the course meet here:

  • Conditional atomic stock update. The filter stock: { $gte: qty } together with $inc means stock can never go below zero, even when two customers buy the last unit at the same moment.
  • Price from the database, not the client. The item's name and price come from the product document returned by findOneAndUpdate(), then are copied into the order: the extended reference pattern from Schema Design: Embed or Reference. A buyer cannot send a fake price.
  • Counter inside the transaction. If any step fails, the counter increase is rolled back too, so order numbers have no gaps. The trade-off is that simultaneous orders compete for the counter document; withTransaction() retries the losers automatically.
  • { session } on every operation. Each read and write inside the callback passes the session, so all of them belong to the transaction.

Throwing an error inside the callback aborts the transaction and passes the error on to the caller.

Step 4: cancellations and order history

Add two more functions to orders.js. Cancelling must change the order and return its items to stock, so it is another transaction:

JavaScript
export async function cancelOrder(orderNo) {
  const session = client.startSession();
  try {
    await session.withTransaction(async () => {
      const order = await orders.findOneAndUpdate(
        { _id: orderNo, status: 'pending' },
        { $set: { status: 'cancelled', cancelledAt: new Date() } },
        { session }
      );
      if (!order) throw new Error(`Order ${orderNo} does not exist or is no longer pending`);
      for (const item of order.items) {
        await products.updateOne({ _id: item.productId }, { $inc: { stock: item.qty } }, { session });
      }
    });
  } finally {
    await session.endSession();
  }
}

export function orderHistory(customerId) {
  return orders
    .find({ customerId }, { projection: { date: 1, status: 1, total: 1, 'items.name': 1 } })
    .sort({ date: -1 })
    .toArray();
}

The filter status: 'pending' makes cancelling safe to repeat: a second attempt finds nothing and changes no stock. Without returnDocument, findOneAndUpdate() returns the order as it was before the update, which is all we need to know which items to put back. orderHistory() uses a projection with dot notation ('items.name') so the history screen receives only what it shows, and its filter plus sort match the { customerId: 1, date: -1 } index.

Step 5: the sales report

The report needs two summaries of the same non-cancelled orders. $facet from $lookup, $unwind & $facet produces both in a single query. Create reports.js:

JavaScript
import { orders } from './db.js';

export async function salesReport() {
  const [result] = await orders.aggregate([
    { $match: { status: { $ne: 'cancelled' } } },
    { $facet: {
        byMonth: [
          { $group: {
              _id: { $dateToString: { format: '%Y-%m', date: '$date', timezone: 'Asia/Kolkata' } },
              orders: { $sum: 1 },
              revenue: { $sum: '$total' }
          } },
          { $sort: { _id: 1 } }
        ],
        topProducts: [
          { $unwind: '$items' },
          { $group: { _id: '$items.name', units: { $sum: '$items.qty' } } },
          { $sort: { units: -1, _id: 1 } },
          { $limit: 3 }
        ]
    } }
  ]).toArray();
  return result;
}

The $match comes first, so it runs once for both facets and can use the status index. Months are grouped in Indian time, and _id breaks ties between products with the same number of units so the report never changes order between runs.

Step 6: the command-line interface

The last file, shop.js, reads the command from process.argv, calls the right function and prints the result. Amounts are formatted the Indian way with toLocaleString('en-IN'):

JavaScript
import { client } from './db.js';
import { setup } from './setup.js';
import { placeOrder, cancelOrder, orderHistory } from './orders.js';
import { salesReport } from './reports.js';

const [command, ...args] = process.argv.slice(2);
const rupees = (n) => '₹' + n.toLocaleString('en-IN');

try {
  switch (command) {
    case 'setup':
      await setup();
      console.log('Indexes, validation and order counter are ready');
      break;

    case 'order': {
      const customerId = Number(args[0]);
      const lines = args.slice(1).map((arg) => {
        const [productId, qty = '1'] = arg.split(':');
        return { productId: Number(productId), qty: Number(qty) };
      });
      const order = await placeOrder(customerId, lines);
      console.log(`Order ${order._id} placed: ${order.items.length} item(s), total ${rupees(order.total)}`);
      break;
    }

    case 'cancel':
      await cancelOrder(Number(args[0]));
      console.log(`Order ${args[0]} cancelled and stock restored`);
      break;

    case 'history':
      for (const o of await orderHistory(Number(args[0]))) {
        const day = o.date.toLocaleDateString('en-IN', { timeZone: 'Asia/Kolkata' });
        const names = o.items.map((i) => i.name).join(', ');
        console.log(`${o._id}  ${day.padEnd(10)}  ${o.status.padEnd(9)}  ${rupees(o.total).padStart(8)}  ${names}`);
      }
      break;

    case 'report': {
      const { byMonth, topProducts } = await salesReport();
      console.log('Revenue by month (IST)');
      for (const m of byMonth) console.log(`  ${m._id}  ${String(m.orders).padStart(2)} orders  ${rupees(m.revenue).padStart(8)}`);
      console.log('Top products by units sold');
      for (const p of topProducts) console.log(`  ${p.units}  ${p._id}`);
      break;
    }

    default:
      console.log('Usage: node --env-file=.env shop.js setup | order <customerId> <productId:qty>... | cancel <orderNo> | history <customerId> | report');
  }
} catch (err) {
  console.error('Error:', err.message);
  process.exitCode = 1;
} finally {
  await client.close();
}

Command-line arguments are always strings, so IDs and quantities are converted with Number() before they reach a query; the string '101' would never match the number 101. Errors are printed as a short message with a non-zero exit code, and the finally block always closes the connection pool.

Run the app

Start from freshly loaded shop data and try a whole session. Aarav orders a keyboard and two cables, Isha tries to buy the out-of-stock Laptop Stand, Aarav checks his history and cancels his older pending order, and the manager looks at the report:

Terminal
node --env-file=.env shop.js setup
node --env-file=.env shop.js order 1 102:1 104:2
node --env-file=.env shop.js order 8 106:1
node --env-file=.env shop.js history 1
node --env-file=.env shop.js cancel 1011
node --env-file=.env shop.js report
Output
Indexes, validation and order counter are ready
Order 1013 placed: 2 item(s), total ₹4,097
Error: Product 106 is unknown or has fewer than 1 in stock
1013  30/10/2026  pending      ₹4,097  Mechanical Keyboard, USB-C Cable
1011  20/10/2026  pending      ₹3,499  Mechanical Keyboard
1003  15/8/2026   delivered    ₹6,999  Portable SSD 1TB
1001  3/8/2026    delivered    ₹1,397  Wireless Mouse, USB-C Cable
Order 1011 cancelled and stock restored
Revenue by month (IST)
  2026-08   4 orders   ₹28,693
  2026-09   3 orders    ₹4,995
  2026-10   4 orders   ₹13,341
Top products by units sold
  5  USB-C Cable
  3  Learn Python Book
  3  Wireless Mouse

The date of order 1013 is the day you run it (here, 30 October 2026). Notice that order 1011 shows 20/10/2026: it was placed at 20:25 UTC on 19 October, which is 1:55 am on 20 October in India. The failed order left no trace: the counter did not move, so the next successful order will still be 1014. October's revenue is ₹13,341 because the new ₹4,097 order was added and the cancelled ₹3,499 order is excluded. In mongosh you can confirm the stock: the keyboard is back to 45 (one sold, one returned) and the cable is at 298.

Ideas to take it further

  • Add a deliver <orderNo> command that changes pending or shipped orders to delivered and increases a computed totalSpent field on the customer.
  • Wrap the same functions in an Express 5 API (POST /api/orders, GET /api/customers/:id/orders), reusing db.js unchanged.
  • Add a search <text> command using MongoDB Search with fuzzy matching on product names.
  • Create a database user with only readWrite on shop for the app, as in Security: Users, Roles & Network Access, and schedule a nightly mongodump.

Key points

  • One shared MongoClient in its own module serves the whole application.
  • A setup step creates the indexes the app's queries need, adds $jsonSchema validation and seeds a counter with an upsert, and is safe to run repeatedly.
  • Orders are placed in a transaction: conditional $inc on stock, a counter for order numbers and an insert, all with { session }.
  • Prices are read from the database and copied into the order, so old orders keep the price that was paid.
  • Cancelling is a second transaction whose status: 'pending' filter makes it safe to repeat.
  • A single $facet pipeline produces several report sections in IST from the same filtered orders.

Exercise

Add a deliver <orderNo> command. It should change an order whose status is pending or shipped to delivered, set a deliveredAt date, and add the order's total to a totalSpent field on the customer, in one transaction. Print a clear error if the order cannot be delivered.

Show solution
JavaScript
// orders.js
export async function deliverOrder(orderNo) {
  const session = client.startSession();
  try {
    await session.withTransaction(async () => {
      const order = await orders.findOneAndUpdate(
        { _id: orderNo, status: { $in: ['pending', 'shipped'] } },
        { $set: { status: 'delivered', deliveredAt: new Date() } },
        { session, returnDocument: 'after' }
      );
      if (!order) throw new Error(`Order ${orderNo} cannot be delivered`);
      await customers.updateOne({ _id: order.customerId }, { $inc: { totalSpent: order.total } }, { session });
    });
  } finally {
    await session.endSession();
  }
}

// shop.js, inside the switch (and import deliverOrder at the top)
case 'deliver':
  await deliverOrder(Number(args[0]));
  console.log(`Order ${args[0]} delivered`);
  break;

Running node --env-file=.env shop.js deliver 1013 marks the order delivered and adds ₹4,097 to Aarav's totalSpent, which $inc creates the first time. Running it again prints Error: Order 1013 cannot be delivered, because the status filter no longer matches, so the customer's total is never counted twice. This is the computed pattern, kept correct by the transaction.

Quick Quiz

1. Why does placeOrder() read the price from the product document instead of trusting the request?

2. What stops two buyers from both getting the last unit of a product?

3. Why can setup be run more than once without harm?

4. Why is the counter increased inside the transaction?

Learn MongoDB with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call