EngineerGENERALSTAGE-SPECIFICILLUSTRATIVE

What Can Go Wrong With a Cart

In memory nothing could go wrong that the rules did not catch. Persisted and shared, four new failures arrive: two tabs race on the same product, a product is removed from the catalog while in a cart, the price changes between add and checkout, and storage holds a cart from a previous world. Each one is a decision, not a bug.

The moveWorked exampleNext questions

The situation, the reflex, and why it stalls

Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.

The question

The cart works and is persisted. Which failures did persistence and sharing introduce that the in-memory cart could not have — and what does each one need you to decide?

The situation

The cart is on the server with a table behind it. A shopper reports a laptop appearing twice in their cart. Another reports checkout failing with an error about a product that does not exist. A third paid a different price than the cart showed. None of these happened while the cart was an array in a test.

The reflex

Fix each report where it surfaces. Dedupe the rows in the cart query; catch the missing-product error and skip the item; show the newer price at checkout. Three patches, three closed tickets, and the causes untouched.

Why it stalls

The dedupe hides a race. Two rows for one product exist because two tabs took the append branch at the same time; deduping the read leaves both rows, and the next changeQuantity updates one of them. The rule "one entry per product" lived in code, and code cannot see the other tab.

What the reflex produces — and fails to produce
  • The dedupe hides a race. Two rows for one product exist because two tabs took the append branch at the same time; deduping the read leaves both rows, and the next changeQuantity updates one of them. The rule "one entry per product" lived in code, and code cannot see the other tab.
  • Skipping the missing product silently changes what the shopper is buying. The cart said three things; checkout charges for two; nobody was told. The decision — drop, show as unavailable, or block — was made by a catch block.
  • Showing the newer price answers the wrong question. The cart is supposed to show the current price, because it derives it; the order is supposed to keep the price paid. The bug was that the order snapshotted nothing, and the patch made the cart behave like an order.
  • The stale storage case was never on the list, because nobody restarted the world between add and checkout in a test. Failures that need two actors or two moments in time do not appear in a single-process test suite (Failure Injection).
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

Precisely enough to apply it to a problem you have never seen — not a slogan.

  • List what became possible when the cart left memory. Two writers instead of one; two moments in time instead of one; a catalog that changes underneath; a copy that outlives the world it was saved in. The concept record lists four failure modes and every one is an instance of these; ask which of the four generators applies to your concept.
  • For each failure, find the rule it violates and the place that rule can no longer see. "One entry per product" cannot see the other tab from inside addItem; only the database sees both writes, so the rule moves down to a constraint and the add becomes an upsert. The failure tells you where the rule has to live (Rules That Live Elsewhere).
  • Where no rule is violated, a decision is missing. A removed product in a cart breaks nothing structurally; what breaks is that nobody said what should happen. Write the decision as a rule with an example — "a cart item whose product is gone is shown as unavailable and blocks checkout" — and it becomes code the same way every rule did.
  • Separate the cart's truth from the order's. The cart derives the price so the shopper sees today's; the order snapshots it so the customer pays what they saw. The same field, two lifetimes, two rules — Snapshots vs References is the discovery-stage lesson this engineering moment lands on.

Four failures, four generators

The rows are the concept record's four failure modes, quoted; the cause column names the generator that produced each. Reading the causes together is the point: the record's list is not exhaustive, but its generators are close to it, and running them against your own concept finds the fifth failure before a shopper does.

The cart record's failure modes, with their causes
TriggerSymptomCauseResponse
Two tabs add the same product.With server persistence and no locking, one add is lost or the entry is duplicated.Two writers — the rule that lived in code cannot see the other tab.The database constraint on (cart_id, product_id), and an add written as an upsert so the conflict becomes an increase.
A product is removed from the catalog while it sits in a cart.View works, total throws.A dependency that changes — the cart references the catalog and the catalog moved.Decide whether the cart drops the item, shows it as unavailable, or blocks checkout; write it as a rule with an example.
The price changes between adding and checking out.The cart shows the new price because it derives it.Two moments — add and checkout are not the same instant, and the field's lifetime differs between cart and order.The cart keeps deriving; the order must snapshot the price at checkout.
The browser stores a cart from a previous session with a product that no longer exists.The same failure as the removed product, arriving from storage instead of from the catalog.A copy that outlives its world.Apply the unavailable-item rule on load as well as on view — one rule, two entry points.

A rule that has to move

The second rule in the concept record is enforced in addItem by a find before an append. That enforcement is complete for one writer and blind to two. The device below is the same rule with its enforcement point moved to the only layer that sees both writes; the validation sentence changes, the rule does not.

One logical entry per product — under two writers

rule One logical entry per product.

becomes validation In code: before adding, look for an existing entry and increase it instead of appending — sufficient when addItem is the only writer. Under concurrent writers: hold the rule where both writes are visible, as a unique constraint, and write the add so that a conflict increases the quantity instead of failing.

becomes code
-- in addItem, unchanged: the rule for one writer
existing = find(cart.items, productId)
if existing: existing.quantity += quantity
else: append(cart.items, { productId, quantity })

-- in the database, the rule for any number of writers
UNIQUE (cart_id, product_id)
INSERT (cart_id, product_id, quantity)
    ON CONFLICT (cart_id, product_id) DO UPDATE SET quantity = quantity + excluded.quantity

The race, made visible

Two writers is the failure the in-memory tests cannot show, so it is drawn as a state change with both writers in it. Before is what both tabs loaded; the operation is both adds; the after is what a database without the constraint holds — and the changed list names the row that should not exist.

Two tabs, one laptop, no constraint
before
cart_item for this cart: no rows. Tab A and tab B both GET /cart and both hold { items: [] }.
Tab A: POST /cart/items { laptop, 1 }. Tab B: POST /cart/items { laptop, 1 }. Both handlers load an empty cart, both take the append branch, both INSERT. →
after
cart_item: (cart, laptop, 1) and (cart, laptop, 1) — two rows for one product. GET /cart returns two laptop entries; changeQuantity(laptop, 3) updates one of them.
what changed Two rows were inserted where the rule allows one — the find in each handler saw an empty cart because the other insert had not happened yet. · The invariant "one entry per product" is now false in storage, and no code path noticed; the rule was enforced in a place that could not see the violation. · With the constraint and the upsert, the after is one row (cart, laptop, 2): the second insert becomes an increase, and the invariant holds regardless of timing.

The implementation ladder

Concept, examples, pseudocode, code, tests, production — for the concept this lesson is about. Code is the fourth tab, not the first.

concept Shopping Cart beginner
Build it step by step →

Shopping Cart = A temporary collection of products the user intends to purchase, held between browsing and checkout.

Identity, ownership, lifetime
  • Does a cart have identity? Yes, weakly. Two carts with the same items are still two carts, because each belongs to someone and will become a different order. It needs an id once it leaves memory; in memory the variable is the identity.
  • Who owns it? A shopper — a logged-in user or an anonymous session. The owner is part of the state because "my cart" has to be findable again.
  • How long does it exist? From the first add until checkout or abandonment. Whether it survives a reload, a closed browser or a login is not a property of the concept; it is a persistence decision made later, and each answer changes where the cart lives.
  • Should it survive reload? Usually yes for a store, usually no for a demo. V1 in memory says no; V2 browser storage says yes on one device; V3 server storage says yes everywhere the user is logged in.
  • Should it survive login? Only if an anonymous cart and a logged-in cart are merged — a rule that does not exist in V1 and appears as a modification later.
State it must remember
  • itemscollection of CartItemkeepThe cart is its items; without them nothing else means anything.
  • items[].productIdidkeepThe reference to what is being bought. The catalog owns the product; the cart only points at it.
  • items[].quantityinteger > 0keepTwo laptops is one entry with quantity 2, not two entries — the rule "one entry per product" needs a quantity to hold.
  • owneruser id or session iddependsSo the cart can be found again by the person it belongs to.
  • items[].productNamestringderiveIt would be convenient to render the cart without a catalog lookup.
  • items[].pricemoneydependsThe total needs a price per item.
  • totalmoneydropEvery screen shows the total.
  • currencycodedependsPrices need a currency to be added.
  • createdAttimestampdropAbandoned carts might be expired or emailed about.
Operations
  • update Add item the updated cart
  • delete Remove item the updated cart
  • update Change quantity the updated cart
  • read View items the list of entries — product id and quantity — for rendering
  • domain Calculate total the sum of price × quantity over the entries
  • delete Clear cart the empty cart
Rules that must always hold
  • Every quantity is greater than zero.
  • One logical entry per product.
  • The total is never negative.
  • An unknown product cannot be added.
  • Quantity cannot exceed available stock — if inventory is enforced here.

How to do it

Most important first.

  • Run the four generators against the concept: two writers, two moments, a dependency that changes, a copy that outlives its world. Write one concrete failure per generator before any of them is reported.
  • For each failure, name the rule (or the missing decision) and the layer that can see the failure happening; put the enforcement there (Where Should This Code Live?).
  • Turn each failure into an example: before, operation, after — with the after being what should happen, not what does. The example becomes the test, exactly as the normal cases did (Examples Become Tests).
  • Inject the failure on purpose: two requests fired together, a product deleted between add and total, a stored cart edited by hand. If it cannot be injected it cannot be tested.
  • Decide the order-versus-cart lifetime for every derived field: which are recomputed on view and which are frozen at checkout.

Worked on a concrete problem

The move has to produce something. This is what it produced.

  • Two tabs add the laptop. Both load [ ], both take the append branch, both write. Rule violated: one entry per product. Layer that can see it: the database. Response: UNIQUE (cart_id, product_id), and the add written as "insert, or on conflict increase quantity". Injection: two POSTs sent without awaiting; the test asserts one row with quantity 2 (Invariants Under Concurrency).
  • A product is removed from the catalog while in a cart. Rule violated: none — the record says "view works, total throws". Missing decision, made: the item is kept, shown as unavailable, excluded from the total, and blocks checkout until removed. Example: [ Laptop × 2, Ghost × 1 ] → view shows Ghost as unavailable → total = 2000 → checkout rejected with "remove unavailable items".
  • The price changes between add and checkout. Cart: derives, shows the new price, correct by the record's own field decision. Order: must snapshot. The failure was the order reading the catalog at payment time. Response: OrderItem gets a price column written at checkout from the price the shopper was shown; the cart is unchanged.
  • Storage holds a cart from last week with a product that no longer exists. Same failure as the removed product, "arriving from storage instead of from the catalog" — the record says so. Response: the same unavailable rule, applied on load rather than on view; one rule, two entry points, and the test loads a hand-edited cart.

How you know it worked

What now exists that did not before, and what question you can now ask.

  • Every failure in the concept record maps to a generator — two writers, two moments, a moving dependency, an outliving copy — and you have checked each generator for one more.
  • Each failure is either a rule now enforced where it can be seen, or a written decision with an example and a test.
  • You can inject every failure on demand, and the tests do.
  • The cart and the order disagree about price on purpose, and the code says which is which.

The questions you can now ask

The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.

Next questions
  • ?Which of the four generators — two writers, two moments, a changing dependency, an outliving copy — apply to this concept on this rung?
  • ?For this failure, which rule is violated, and which layer can actually see it happen?
  • ?Where no rule is violated, what decision is missing, and what is its example?
  • ?Which derived fields are recomputed on view and which must be frozen at a moment — and where is that moment?

What can go wrong

How the move itself fails
  • Every imaginable failure is handled before the first real one. The V2 browser cart gets a merge-conflict resolver for tabs that cannot conflict, and checkout is delayed by defences against a database that does not exist yet; the generators apply to the rung you are on (Failure Path Second).
  • Failures are handled where they surface instead of where they are caused, and the cause keeps generating. The dedupe on read is the model case.
  • A decision is made in a catch block and never written down. Six months later "why does checkout drop items?" has no answer in any rule, example or test.
  • The concurrency failure is solved with a lock in the application server, which works on one server and silently fails on two (A Mutex on Server A Does Nothing About Server B is the concurrency lesson for that).
What the move costs
  • Enforcing "one entry per product" as a constraint means a race surfaces as a database error the endpoint has to translate, instead of a rule the function names.
  • Blocking checkout on an unavailable item loses a sale that silently dropping the item would have kept — a business decision dressed as a failure rule, and it should be made by the business.
  • Snapshotting prices into the order duplicates data the catalog holds, on purpose; it is the one place in the store where the derived-versus-stored answer flips.
Misreads
  • "These are database problems." Two of the four have no database in them: a removed product and a stale copy happen on the browser-storage rung too. They are consequences of time and sharing, and the database is one place they show up.
  • "The unique constraint is the fix for the race." It is the detection; the fix is the upsert that turns a conflict into an increase. A constraint alone turns a lost add into a 500.
  • "Handling failures means try/catch." A catch block that decides what happens to a shopper's cart is a rule that nobody wrote. The move is to write the rule and let the code encode it, which is what every rule lesson in the track did.

Where this applies

Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.

  • GENERALTwo writers, two moments in time, a dependency that changes and a copy that outlives its world are the failure generators for any persisted, shared concept — a todo list, a booking, a comment thread.
  • STAGE-SPECIFICOn the in-memory and browser-storage rungs only the last two generators apply; the race arrives with the first shared writer, which is why this lesson follows The Persistence Ladder rather than the concept definition.
  • ILLUSTRATIVEThe duplicated laptop, the ghost product and the price of 2000 are invented to show each failure's shape; the numbers carry no meaning beyond the example.

Where the depth lives

This domain asks the question and hands the answer off by name.