What Goes in a Document

We chose a document database for Shopend’s products. Choosing the database is one design decision. Once we have chosen it, we still have to decide how to store the data in it.

Earlier, we designed relational databases. We split the data into tables, chose columns with types and constraints, linked the tables with primary keys and foreign keys, and normalized the design. After that, we made more decisions to speed up the reads the application performs. For example, we denormalized some of the data and added indexes.

A document database needs similar decisions. In the previous section, we chose a products collection, decided what a product document holds, and chose which fields the database checks. Now we need to decide how to store carts and orders.

Shopend’s requirements describe products, carts, orders, and the items in those carts and orders. These tell us what data we need to store, but they do not yet tell us which collections to create. For example, should each cart item be a separate document, or should the cart document contain its items?

We still want to avoid unnecessary duplication and prevent data errors. But a document can contain nested objects and lists, so we have another decision to make. We have to decide which parts of the data to store together. To decide that, we look at how the application reads and changes the data.

In a relational design, a cart_items table can record each item’s cart identifier, product identifier, and quantity. To read a cart with its items, we read carts and cart_items. If we also need the products’ names and current prices, we read products as well. For example, we can join the three tables. When a shopper adds a product or changes a quantity, the application inserts or updates a row in cart_items.

Each cart item belongs to one cart, and the storefront reads the items together when it displays that cart. We also expect a cart to contain only a small number of items. These are reasons to keep the items inside the cart document. The document stores them as a list of objects. Storing objects inside another document this way is called embedding. Each item records a product identifier and the quantity the shopper wants.

Here is cart 73 with its items embedded:

{
  "_id": "73",
  "shopId": "mugshop",
  "shopperId": "7",
  "status": "open",
  "items": [
    { "productId": "42", "quantity": 2 },
    { "productId": "43", "quantity": 1 },
    { "productId": "44", "quantity": 1 }
  ]
}

The shopperId records who owns the cart. When that shopper adds a product or changes an item’s quantity, the application updates the list inside this document.

The document holds the products the shopper chose and how many of each. To display the cart page, the storefront also needs each product’s name and current price, and the cart document does not contain them.

Changing an item

Suppose the shopper changes the quantity of blue mugs in cart 73 from 2 to 3. We do not have to write the whole cart document again. MongoDB can change one item in the list:

db.carts.updateOne(
  {
    _id: "73",
    shopId: "mugshop",
    shopperId: "7",
    status: "open",
    "items.productId": "42",
  },
  { $set: { "items.$.quantity": 3 } },
);

The filter matches the cart only if it belongs to the shopper, is still open, and has an item for the blue mug. "items.productId" looks inside the list. It matches when any item in the list has a productId of "42". $set is an update operator. It gives a field a new value. In "items.$.quantity", the $ stands for the item the filter matched. Only that item’s quantity changes. MongoDB has other update operators that change a list. $push adds an item to the end of the list, and $pull removes the items that match a condition.

MongoDB makes a write to one document atomic, including a write that changes several fields. So the database applies this change to the cart all at once, even when another request changes the same cart at the same time.

Now suppose the application updates the cart a different way. It reads the cart, changes the list in its own memory, and then writes the whole list back. The shopper has the storefront open in two browser tabs. In one tab, they change the blue mugs to 3. In the other, they add a white mug. Both requests read the cart before either one writes, so both start from the same list. Each request then writes back its own version of the whole list, and the second write replaces the first. Either the new quantity or the white mug is gone. This is the lost update we saw with transactions: both requests read the same data, and the second write replaces the work of the first.

With $set and $push, each request sends only its own change. The database applies each change to the cart as it is when the change arrives. So neither change is lost.