RulesGENERALSTAGE-SPECIFICILLUSTRATIVE

Inject a Constraint and Follow It Through

The cart works. Now: "quantity may not exceed 10". Follow the new requirement through the same path the original rules took — invariant → example → implementation → test — and notice that it touches exactly one rule, two examples, two functions and one test, which is how you know the earlier work was right.

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

A new rule arrives after the solution works. How do you change the implementation without breaking what it already does — and how do you know you touched everything the rule reaches?

The situation

Your cart passes its tests. A message arrives: "customers are adding 500 of something by accident; cap it at 10 per product." You open addItem, add an if, and feel done. Then you wonder about changeQuantity, about the test suite, and about what "10" means when the cart already holds 8 and someone adds 3.

The reflex

Add if (quantity > 10) throw to addItem and ship it. It is one line, it is obviously what was asked, and the tests still pass.

Why it stalls

The tests still pass because none of them mention 10 — the rule has no test, so the line is unverified. And changeQuantity(…, 50) still works, because the rule was encoded in one function instead of attached to the state.

What the reflex produces — and fails to produce
  • The tests still pass because none of them mention 10 — the rule has no test, so the line is unverified. And changeQuantity(…, 50) still works, because the rule was encoded in one function instead of attached to the state.
  • The cap was applied to the input, not to the resulting quantity. A cart holding Laptop × 8 accepts add 3 and ends at 11, which is exactly the state the message asked you to prevent.
  • Nobody wrote the example, so nobody decided what happens at the boundary: is 10 allowed or is 11 the first rejection? The > in the line decided it, silently, and it may not be what the business meant.
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

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

  • Treat the new requirement as a rule and write it in the state-sentence form first: "no entry's quantity exceeds 10." Notice that it is about the state after the operation, not about the input — which is what catches Laptop × 8 + 3.
  • Ask which operations threaten it. Anything that raises a quantity: addItem (existing entry plus new quantity, or a new entry's quantity) and changeQuantity (the new value). removeItem, clear, total cannot break it. That is the list of functions to touch — and it was found before opening any of them.
  • Write the examples at the boundary: an edge case that reaches exactly 10 and succeeds, an invalid case that would reach 11 and is rejected with the state unchanged. Only now write the checks, one per threatened operation, reading the boundary off the examples.
  • Turn the examples into tests, run them against the old code to watch them fail, then against the new code to watch them pass. The list of what changed — one rule, two examples, two functions, one test file — is the evidence that the earlier structure was sound.

The rule, about the state

The message said "cap it at 10 per product". The rule that encodes it is about the state after the operation, and that one shift — from input to resulting quantity — is what catches a cart already holding 8. The validation sentence names both threatening operations; the check in addItem compares the sum, not the argument.

The injected constraint

rule No entry's quantity exceeds 10.

becomes validation In addItem, reject when the existing quantity plus the added quantity would exceed 10 (or when a new entry's quantity would); in changeQuantity, reject a new value above 10. Nothing else can raise a quantity.

becomes code
-- addItem
existing = find(cart.items, productId)
resulting = (existing.quantity if existing else 0) + quantity
if resulting > 10: reject "at most 10 per product"

-- changeQuantity
if quantity > 10: reject "at most 10 per product"

The examples at the boundary

Two examples decide everything the if will say. The edge example reaches exactly 10 and must succeed — it is what stops a later >=. The invalid example would reach 11, is rejected, and leaves the state untouched; its changed list is deliberately empty of state and full of what the caller sees.

Edge: exactly at the boundary
before
Cart = [ Laptop × 8 ]
add Laptop × 2 →
after
Cart = [ Laptop × 10 ]
what changed Laptop's quantity: 8 → 10 · Number of entries: unchanged · The check compared 8 + 2 against 10 and allowed it — 10 is inclusive
The invalid example, as the test that fails against the old code
1const cart = addItem(createCart(), 'laptop', 8)
2assert.throws(() => addItem(cart, 'laptop', 3), /at most 10/)
3assert.deepEqual(cart.items, [{ productId: 'laptop', quantity: 8 }])

Run this before the change: it fails because the cart reaches 11. A test that passes before the change proves nothing about it.

One order for the change, and the other

The sequence below is the one the module recommends and the one Cart Lab walks through when you inject the constraint. The alternative is legitimate and is what a strict test-first practitioner would do; both reach the same code, and both produce the change list that lets you check the diff.

Following the constraint through
  1. 1
    Rule, as a state sentence

    because Decides that the check is on the resulting quantity, not the input, before any code is opened.

  2. 2
    Threatening operations

    because Produces the change list — addItem and changeQuantity — and rules out the others.

  3. 3
    Edge and invalid examples at the boundary

    because Settles whether 10 is inclusive; the > is read off the example rather than chosen.

  4. 4
    Checks in the threatened operations

    because Each check is the validation sentence encoded, placed where the threat is.

  5. 5
    Tests from the examples, failing first

    because A test that failed against the old code is evidence the rule is enforced; one that never failed is decoration.

  6. 6
    Diff against the change list

    because Anything unexpected in the diff is a mistake or a discovered rule, and either needs a sentence.

a different valid order Test-first: write the failing test from the message before writing the rule, and let the rule sentence emerge from the assertion. Choose this when the boundary is uncontroversial and the code is already well-tested — the test is the example, and the rule is read off it afterwards. It is riskier when the boundary is unclear, because the assertion decides inclusivity silently.

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.

  • Write the rule about the resulting state, never about the input; "quantity ≤ 10" means the entry's quantity after the operation (Rules Determine Implementation).
  • Enumerate the threatening operations from the rule before opening the code; the enumeration is the change list.
  • Write the exact-boundary edge example and the one-past-boundary invalid example before any check (Normal, Edge, Invalid).
  • Run the new tests against the unchanged code first; a test that passes before the change proves nothing about it.
  • Diff the change against the list you made. Anything in the diff that is not on the list is either a mistake or a rule you found on the way — say which (Understanding Is Demonstrated by Modification).

Worked on a concrete problem

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

  • Rule: no entry's quantity exceeds 10. Threats: addItem (existing + new, or new entry), changeQuantity (new value). Examples: [Laptop × 8], add 2 → [Laptop × 10] (edge, allowed); [Laptop × 8], add 3 → rejected, cart still [Laptop × 8] (invalid). The check in addItem is on existing.quantity + quantity, not on quantity.
  • Then the test, from the invalid example: add 8, then attempt to add 3, assert it throws and the items are still [Laptop × 8]. Against the old code the test fails (the cart reaches 11); against the new code it passes. The edge test — reaching exactly 10 — passes both before and after, which is fine: it guards the boundary against a future >=.
  • Next message, a week later: "make it 5 for accessories". The path is the same, and the rule stops being a constant — it becomes a per-product maximum the catalog owns, which is the rule moving to where it belongs (Rules That Live Elsewhere).

How you know it worked

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

  • Before touching code, there is a list: the rule sentence, the threatened operations, the boundary examples, the tests — and the diff matches it.
  • The new tests failed against the old code and pass against the new one.
  • The boundary — is 10 allowed? — was decided in an example someone can read, not in the choice between > and >=.

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
  • ?Is this new rule about the input or about the resulting state?
  • ?Which operations can raise the value it constrains — and which cannot?
  • ?What is the example at exactly the boundary, and what is the example one past it?
  • ?Did the new test fail against the old code?
  • ?Which of the earlier rules, examples or functions did this change touch that I did not predict?

What can go wrong

How the move itself fails
  • The rule is written about the input and the sum is missed; Laptop × 8 + 3 slips through. Rules are about state.
  • The rule is enforced in every function, including the ones that cannot break it, and the check in total guards nothing while looking like defence.
  • The path is followed for the first constraint and skipped for the second because "it is the same shape" — and the second constraint, per-product maxima, is not the same shape at all; it changes who owns the number.
What the move costs
  • The full path — rule, threats, examples, checks, tests — is slower than the one-line fix, and for a genuinely trivial constant it is slower by a lot.
  • Writing tests at the boundary commits the boundary; when the business later says "10 was inclusive, actually", the tests are the thing that has to change first.
  • Following the path reveals that the constant belongs somewhere else, which turns a one-line ticket into a catalog change nobody asked for yet.
Misreads
  • "Don't over-engineer — it is one if." The slogan is falsifiable here: the one if leaves changeQuantity open and misses the sum, so it is not the smaller change, it is the incomplete one. Over-engineering would be a rules engine; following the rule through two functions is the size the rule actually is.
  • "The tests passed, so the change is safe." Passing tests say the old behaviour survived; they say nothing about the new rule until a test mentions it.
  • "Injecting constraints is a testing exercise." It is a design check: a constraint that touches one rule, two functions and one test says the structure was right; one that touches fifteen files says the rules were never separated from the code.

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.

  • GENERALA new rule arriving after the solution works is the normal case for any concept; rule → threats → boundary examples → checks → tests is the same path for a rate limiter's new window size or a todo list's new maximum.
  • STAGE-SPECIFICOn an in-memory V0 the whole change is two functions and a test; once the cart is persisted, the same rule usually also becomes a database CHECK and an API validation, and the change list grows to include a migration — the path is unchanged, the list is longer.
  • ILLUSTRATIVEThe cap of 10, the accidental 500, the quantities 8 and 3 and the follow-up cap of 5 are invented to show the boundary reasoning; no real store's limits are described.

Where the depth lives

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