ExecutionGENERALSTAGE-SPECIFICILLUSTRATIVE

Input → Lookup → Branch → Mutation → Output

Every call to addItem passes through the same five stops. Tracing one call by hand — what came in, what was looked up, which way it went, what was changed, what went back — is how you find out whether the function you wrote is the operation you meant.

The moveWorked exampleNext questions▶ Cart Lab

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

addItem runs and the tests pass. Can you say, for one specific call, what it read, which branch it took and which field it changed — and if not, what do you actually know about it?

The situation

You have addItem on the screen — yours, or the reference — and the example "Laptop × 1, add Laptop → Laptop × 2" is in the tests and green. Someone asks "and what happens on the second add, exactly?" and you start reading the code from the top, out loud, and lose the thread at the find.

The reflex

Run it and look at the result. Add a console.log after the function, call it twice, see quantity: 2, and conclude that it works. The output is right, so the function is understood — that is what "it works" means, and it took ten seconds.

Why it stalls

The output says the destination; it says nothing about the route. quantity: 2 is consistent with the find-and-increase branch and with a bug that appends a duplicate and then a view that merges duplicates on render. You checked the answer, not the function.

What the reflex produces — and fails to produce
  • The output says the destination; it says nothing about the route. quantity: 2 is consistent with the find-and-increase branch and with a bug that appends a duplicate and then a view that merges duplicates on render. You checked the answer, not the function.
  • When the output is wrong, the same reflex has nothing to offer. A log at the end shows the wrong state and every line above it is a suspect; there is no notion of "where along the way it went wrong" because the way was never made visible.
  • Reading the code from the top is the second reflex and it fails at the branch. Code is written once and read in every order the inputs can produce; reading it as text tells you what it could do, not what it did for this call.
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

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

  • Pick one concrete call — one input, one starting state — and follow it through five stops in order. Input: what exactly arrived, including defaults. Lookup: what the function read before deciding anything — the catalog, the existing entries. Branch: which condition was true, and therefore which path executed and which did not. Mutation: which field of which entry changed, from what to what — or nothing. Output: what was returned, and whether it is the same object that was mutated.
  • Write each stop as a line with real values, not variable names. "existing = the Laptop entry with quantity 1", not "existing = find(...)". The trace is the code evaluated for this call; where the code has a symbol, the trace has a value.
  • Do it for the three cases the concept already gave the operation: normal, edge, invalid (Normal, Edge, Invalid). Three traces of the same function show the three routes through it, and a function whose three traces you can write is a function you understand — not before.
  • Compare the trace to the plain-English steps in the concept (Plain-English Logic Is the Algorithm). Each stop should correspond to a step; a stop with no step is code the concept did not ask for, and a step with no stop is a rule the code forgot.

The normal case, stop by stop

The trace below is the concept's example add-again with every symbol replaced by its value for this call. Read the branch line twice: it names the path that ran and, by implication, the one that did not. A bug in the append path is not exercised by this call, and the trace says so.

The mutation line is a single field. That is what "adding the same product twice gives one entry with quantity 2" means at the level of memory, and it is the line the rule "one logical entry per product" is protecting.

addItem([Laptop × 1], "laptop") — the normal case
  1. inputcart.items = [ { productId: "laptop", quantity: 1 } ]; productId = "laptop"; quantity = 1 — the default, because the caller passed nothing.
  2. lookupcatalog.has("laptop") → true. find over cart.items for productId == "laptop" → the first (only) entry, quantity 1. One comparison; O(n) in the size of the cart, n = 1 here.
  3. branchexisting is present → take the increase path. The append path does not execute for this call.
  4. mutationexisting.quantity: 1 → 2. cart.items still has exactly one entry; nothing was added or removed.
  5. outputthe same cart object, now [ Laptop × 2 ]. The caller holds the object that was mutated, not a copy.

Remove and change, through the same five stops

Two more traces, chosen because they look different from addItem and turn out to have the same shape. removeItem has almost no branch — the filter keeps everything that is not the id, absent or not — which is why the concept could choose "no-op" for the missing product. changeQuantity has the most branches of the five operations, and its trace is where "set to 0" turns into a removal.

changeQuantity([Laptop × 2], "laptop", 0) — the edge case
  1. inputcart.items = [ { productId: "laptop", quantity: 2 } ]; productId = "laptop"; quantity = 0.
  2. lookupquantity < 0? → false. quantity == 0? → true. The find for the entry never runs; the function delegates before it looks.
  3. branchquantity is zero → return removeItem(cart, "laptop"). Inside removeItem: keep every entry whose productId != "laptop" → none kept.
  4. mutationcart.items: [ Laptop × 2 ] → [ ]. The entry is gone rather than sitting at quantity 0.
  5. outputthe cart, now empty — a valid cart. The example to-zero's note, "a cart never holds quantity 0", is this mutation line.

The invalid case has a stop that says "none"

The third trace is the one people skip because nothing happens. Nothing happening is the rule: the state is untouched and the reason is returned. A trace that cannot write "mutation: none" with confidence does not know whether the reject came before or after a partial change — and in an operation that touches two things, that is the whole difference between a rejection and a corruption (From Invariant to Validation).

addItem([], "laptop", 0) — the invalid case
  1. inputcart.items = [ ]; productId = "laptop"; quantity = 0 — passed explicitly this time.
  2. lookupcatalog.has("laptop") → true. quantity <= 0 → true. The find does not run; the check order is catalog, quantity, then find.
  3. branchquantity ≤ 0 → reject "quantity must be positive". Neither the increase path nor the append path executes.
  4. mutationnone. cart.items is still [ ]. The rule "every quantity is greater than zero" held because the check came before any write.
  5. outputan error carrying the rule's wording; the caller's cart is unchanged and can be retried.

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.

  • Take the concept's example add-again and trace it on paper before running it: five lines, one per stop, values filled in (Predict the State Before Running the Code).
  • Trace the invalid case second. The trace should stop at the branch — "quantity ≤ 0 is true → reject" — with no mutation line at all; write "mutation: none" explicitly, because that absence is the rule.
  • Trace one other operation with the same five stops. removeItem has no branch worth the name in V1 and changeQuantity has three; noticing that is the point (What Can Happen to It?).
  • When you have the Cart Lab open, predict the trace, then run the script and compare stop by stop; the lab prints exactly these five lines.
  • Keep one trace per operation in the worksheet next to its pseudocode. It is the fastest thing to reread when the operation changes (The Engineering Notebook).

Worked on a concrete problem

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

  • add-again, traced. Input: cart = [Laptop × 1], productId = "laptop", quantity = 1 (the default; nobody passed it). Lookup: catalog.has("laptop") → true; find in items → the Laptop entry, quantity 1. Branch: existing is present → the increase path; the append path does not run. Mutation: Laptop.quantity 1 → 2; items still has one entry. Output: the same cart object, now [Laptop × 2].
  • add-first, traced. Input: cart = [], "laptop", 1. Lookup: catalog → true; find over an empty list → nothing. Branch: existing absent → the append path. Mutation: items [] → [Laptop × 1]; no existing entry was touched because there was none. Output: the cart, [Laptop × 1]. Same function, different stop three, different stop four.
  • invalid-zero, traced. Input: [], "laptop", 0. Lookup: catalog → true; the find never runs, because the quantity check comes first. Branch: quantity ≤ 0 is true → reject "quantity must be positive". Mutation: none — and the trace writes the word. Output: an error, and a cart still equal to []. The example's note, "the invalid case leaves the state untouched", is stop four of this trace.

How you know it worked

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

  • You can answer "what happens on the second add, exactly?" in five sentences with values in them, without looking at the code.
  • The word "none" appears in a mutation line and you know why it is there.
  • The branch line names the path that did not run, so a bug in that path is visibly not exercised by this call.
  • Each stop maps to a plain-English step from the concept, and the mapping had no leftovers on either side.

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
  • ?For this call, what exactly came in, including every default the caller did not write?
  • ?What did the function read before it decided anything, and in what order?
  • ?Which condition was true, and which path therefore did not run?
  • ?Which field changed, from what to what — or did nothing change, and is that the rule?

What can go wrong

How the move itself fails
  • Tracing with symbols instead of values. "existing = find(items, productId)" is the code again; the trace only says something when it reads "existing = the Laptop entry, quantity 1".
  • Tracing only the happy path. One trace shows one route; the function has as many routes as branches, and the invalid route is the one most often wrong.
  • Skipping the lookup stop. Adding "what did it read?" is what exposes the order of checks — quantity before find, catalog before both — which is a rule and a cost, not a detail.
  • Confusing the output with the mutation. addItem returns the cart it mutated; a version that returned a copy would have the same output line and a different mutation line, and the difference is the whole persistence story later.
What the move costs
  • A written trace for three cases costs a few minutes per operation; for an operation with one branch that is a minute you could have spent running it.
  • Tracing by hand is slow and exact; a debugger is fast and shows every variable — including the ones that do not matter. The hand trace is for understanding, the debugger for finding.
  • Five fixed stops fit a state-changing operation; a pure read like getItems has trivial lookup, branch and mutation lines, and forcing the shape on it teaches little.
Misreads
  • "So I should add logging at all five stops." In a learning setting, on paper, yes; in the code, no — the trace is a way of reading, not a feature. Structured logging is a production concern with its own rules.
  • "The trace is what a debugger shows." A debugger shows every variable at every line; the trace is the five values that explain this call. Learning to pick them is the skill the debugger does not teach.
  • "If the output is right, the trace must be right." The predict-the-bug variant produces the right total from two duplicate entries. The output is the last stop, and it is the one most able to hide a wrong fourth stop.

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.

  • GENERALEvery state-changing operation, in any language or framework, passes through input, lookup, branch, mutation and output; the five stops are the shape of a call, not a cart detail.
  • STAGE-SPECIFICWhile learning an operation, trace every case by hand; once the operation is understood, trace only when it misbehaves — the habit stays, the paper does not.
  • ILLUSTRATIVELaptop × 1 → Laptop × 2 and the ten seconds are the concept's own example and an invented duration; nothing was measured.

Where the depth lives

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