Connecting Carts to Products

The cart document records which products the shopper wants and how many of each. But the cart page also shows each product’s name and current price. Where should those come from?

Putting product details in the cart

We could copy the name and price into each item. Part of cart 73 would then look like this:

{
  "productId": "42",
  "quantity": 2,
  "productName": "Blue mug",
  "priceCents": 1800
}

Now reading the cart document gives us the names and prices too. Suppose the blue mug costs 1800 cents during a weekend promotion. A shopper adds it to their cart on Sunday and returns on Monday, after the promotion has ended. The product document now holds the regular price of 2000 cents, but the cart still holds the promotional price of 1800.

Our requirements say that a cart shows current prices. If we use the price copied into the cart, we must keep it up to date. The blue mug may be in many shoppers’ carts, so one price change could require updating many documents. This is the cost of denormalization we saw earlier: when the value changes, every copy has to change too. Or we could look up the current price every time we read the cart. But then we do the lookup anyway, so the copied price does not save us anything.

The cart’s item and the product have different roles. The item records one shopper’s selection and belongs to that cart. The product belongs to the shop’s catalog, can appear in many carts, and can change independently of any of them. We do read the two together, and that is one reason to store them together. But it is not the only thing we have to consider.

Keeping a reference

For Shopend, we keep each product’s name and current price in its product document. The cart item keeps only the product’s identifier and the quantity the shopper wants, as in the previous section:

{
  "productId": "42",
  "quantity": 2
}

The productId is a reference: a value that identifies another document. Here it identifies a document in the products collection. Embedding and referencing can appear in the same design. We embed the items in the cart, and each item references a product.

To display cart 73, the backend first reads the cart document:

db.carts.findOne({ _id: "73", shopId: "mugshop" });

After checking that the cart belongs to the shopper, it collects the product identifiers from the cart’s items and requests those products:

db.products.find({
  shopId: "mugshop",
  _id: { $in: ["42", "43", "44"] },
});

$in matches any of the identifiers in the list. This lets us request the products together instead of making a separate database request for each item. The shop filter keeps the lookup within mugshop.

The backend matches each cart item’s productId to the corresponding product’s _id. It takes the name and current price from that product and computes the total using the quantities in the cart.

This is what a relational join does: it combines related data. In a SQL query with a join, the database matches the records and returns the combined result. Here, we read the cart and the products separately and have the backend match them. MongoDB also provides an operation called $lookup that can combine documents from different collections in the database.

This costs a second database read compared with having all the details in the cart document. In return, a merchant can change a product’s name or price in one place without updating every cart that mentions it.

In a relational design, cart_items would have a foreign key to products. The database would then enforce referential integrity: it would reject a cart item whose product does not exist. MongoDB does not check references. To the database, productId is a string like any other field. So when a shopper adds a product to the cart, our code must first look up the product within the shop and check that it exists and has not been deleted. Only then does it add the item.

A reference can also point to a product that is no longer available. We already decided how the API handles this: the cart item remains, but its product field in the response is null and the response includes an error. Keeping the identifier lets the shopper remove that item from the cart.

Indexes for these reads

MongoDB automatically creates a unique index on _id. It supports both the individual product lookup and the lookup using $in. Including shopId in those filters restricts the results to the requested shop. It does not mean that these reads need another index.

Listing a shop’s catalog needs a different index. We can make the same indexing decisions we made in relational design. The index supports filtering by shop and deletion status, and then ordering by identifier:

db.products.createIndex({ shopId: 1, deletedAt: 1, _id: 1 });

Here, deletedAt records soft deletion, as deleted_at did in our relational design; active products have a null value. MongoDB calls this a compound index. The 1 specifies ascending order.

The response and the stored document

Recall that the GraphQL cart response nests a product inside each item. For example, consider a storefront sends the following GraphQL request:

{
  cart(id: 73) {
    totalCents
    items {
      quantity
      product {
        name
        priceCents
      }
    }
  }
}

The backend assembles the response from the cart and the products it references.

async function findCartWithProducts(shopId, cartId) {
  const cart = await db.carts.findOne({ _id: cartId, shopId: shopId });

  const productIds = cart.items.map((item) => item.productId);
  const products = await db.products.find({
    shopId: shopId,
    _id: { $in: productIds },
  });

  return { cart, products };
}

One request from the storefront can involve several reads from the database.