Placing an Order Together
Placing an order must record the order, reduce stock, and mark the cart as ordered. In our document design, these changes touch an order document, one or more product documents, and a cart document. Let’s look at which guarantees a single update gives us, and which work we must group into a transaction.
Updating one document
A write to one document is atomic. When we changed an item in a cart, the database applied the whole change at once, even when another request changed the same cart at the same time. We can also put a condition on the update, so that it runs only if there is enough stock:
db.products.updateOne(
{
_id: "42",
shopId: "mugshop",
deletedAt: null,
quantity: { $gte: 2 },
},
{ $inc: { quantity: -2 } },
);
$gte means greater than or equal to. $inc changes the field by the given amount. A value of -2 subtracts two. The update succeeds only if the product is still available and has at least two units in stock. The condition and the reduction are one atomic operation. If we checked stock with a separate read first, another shopper could buy the product between our read and our update. With the condition in the update, once one shopper’s update reduces the stock to zero, another shopper’s update cannot match the condition.
The application must check whether a document was matched. No match means the stock reduction did not happen. The application must not continue as though it had succeeded.
Placing the whole order
For Shopend, we put the following work in one transaction:
- Read the cart within the shop and check that it belongs to the shopper and is still open.
- Read the available products and their purchase prices, and conditionally reduce stock for every item.
- Create the order with its purchase snapshots and total.
- Mark the cart as ordered and associate it with the new order.
We abort if any check or stock update fails. Two transactions running at the same time can also conflict. If we retry a transaction, we must repeat its reads and checks. A transaction across documents takes more coordination from the database than a write to one document. Placing an order changes several documents, so we need the transaction anyway.
MongoDB supports the transaction our design requires. Not every document database supports it. If we used a database that does not, we would have to reconsider which database we use or substantially redesign the purchase workflow.
Storing the idempotency key
The placeOrder mutation already takes an idempotency key. We store it as idempotencyKey on the order, alongside the shop, shopper, and cart identifiers. We write it in the same transaction. We enforce its uniqueness for each shopper within a shop:
db.orders.createIndex({ shopId: 1, shopperId: 1, idempotencyKey: 1 }, { unique: true });
When the backend handles a retry, it can find the order stored with that key. The unique index also stops two concurrent requests with the same key from committing two separate orders for that shopper in that shop.