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 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 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 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.
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.
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.
- 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).
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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| 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.
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.
-- 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.quantityThe 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.
cart_item for this cart: no rows. Tab A and tab B both GET /cart and both hold { items: [] }.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.
The implementation ladder
Concept, examples, pseudocode, code, tests, production — for the concept this lesson is about. Code is the fourth tab, not the first.
Shopping Cart = A temporary collection of products the user intends to purchase, held between browsing and checkout.
- 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.
- 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.
- 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
- • 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.
- ?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
- 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).
- 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.
- "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.