Normal, Edge, Invalid
For every operation, three examples: the normal case, the edge case, and the invalid one. addItem: a new product; a product already present; quantity 0. The normal case writes the happy path, the edge case finds the branch, the invalid case fixes where the checks go and proves the state survives rejection.
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.
You have one example per operation and the code works for it. Which examples are missing, and how do you find them systematically instead of waiting for bugs?
Your cart passes the one test you wrote: add a laptop, see a laptop. You suspect there are more cases. You try to brainstorm "edge cases" and produce a list that includes "what if the network fails" for a function that has no network, and nothing about quantity 0.
Brainstorm everything that could go wrong, write it all down, and feel thorough. The list is long and mostly about infrastructure.
The list mixes concerns the operation cannot have with cases it must have. "Database down" is not an example of addItem on an in-memory cart; "the product is already there" is, and it is missing because it does not sound like something going wrong.
- The list mixes concerns the operation cannot have with cases it must have. "Database down" is not an example of
addItemon an in-memory cart; "the product is already there" is, and it is missing because it does not sound like something going wrong. - The happy path is the only case with an example, so the code has one branch and no checks, and every other case is decided by whatever the code does by default — usually append, usually accept.
- "Edge case" and "invalid input" are used interchangeably, so the second add — which must succeed, differently — is treated like a zero quantity — which must fail. One of them gets a rejection it should not have.
The move
Precisely enough to apply it to a problem you have never seen — not a slogan.
- For each operation, write exactly three examples with three different jobs. Normal: the input the operation was named for, on a state that does not complicate it. Edge: a valid input on a state that forces the operation to do something different — already present, last item, empty cart. Invalid: an input the rules refuse, where the after must equal the before and the message must name the rule.
- Let each kind produce a different piece of the implementation. The normal case writes the main path. The edge case reveals the branch and the lookup that decides it. The invalid case says where the checks go — before anything touches the state — and what the caller sees.
- Find edges from the state, not from imagination: what is special about empty, about one, about already-there, about last? Find invalids from the rules: each rule refuses some input, and that input is an invalid example. Infrastructure failures are not examples of this operation; they belong to the version that introduces the infrastructure.
- Check the invalid case's after with the same care as the normal case's. "Rejected" is half an answer; "rejected, and the cart is still [Laptop × 1]" is the whole one, and it is the half that catches a check placed after the append.
The grid
The concept record carries normal, edge and invalid for every operation; the grid is what it looks like when they are laid side by side. Read it down a column and the jobs are visible: the normal column writes the happy paths, the edge column reveals the branches and the "empty is valid" facts, and the invalid column is a list of the rules — quantity, existence — plus the decisions about absent removals and missing prices.
| Operation | Normal | Edge | Invalid |
|---|---|---|---|
| addItem | [] + Laptop → [Laptop × 1] | [Laptop × 1] + Laptop → [Laptop × 2], one entry | Laptop × 0 → rejected, cart unchanged |
| removeItem | [Laptop × 2, Mouse × 1] − Mouse → [Laptop × 2] | remove the last item → [], still a valid cart | remove Keyboard (absent) → unchanged; V1 no-op, an API might 404 |
| changeQuantity | Laptop × 2 → 3 | → 0: the entry disappears | → −1: rejected |
| getItems | two entries → two entries | empty cart → empty list, not null | none — a read cannot be wrong, only stale |
| total | Laptop × 2 at 1000 + Mouse at 20 → 2020 | empty cart → 0 | a product with no price → error, not silently 0 |
| clear | two entries → none | clearing an empty cart succeeds | none |
The invalid case, traced
An invalid example is only half-written until its after is stated, and the after is "unchanged". The trace shows why that matters: the check runs before the lookup, so the branch is taken at the first stop and no mutation happens. A version with the check after the append would produce the same error message and a cart containing Laptop × 0 — a test that only asserts "throws" cannot tell them apart.
- inputproductId = laptop, quantity = 0; cart = []
- lookupcatalog.has(laptop) → true. The find for an existing entry is never reached.
- branchquantity <= 0 → true → reject "quantity must be positive"
- mutationNone. The check precedes every line that touches cart.items.
- outputAn error naming the rule; cart is still [] — the after of the example, verified by the trace rather than assumed.
What a missing kind looks like
Each kind of example, when missing, produces a recognisable bug. The table is how to read someone else's cart — or your own, a month later — and infer which column of the grid was never filled in.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| No edge example for addItem | The same product appears as two entries | The already-present state was never written down, so there is no find | Write [Laptop × 1] + Laptop; add the find and the branch. |
| No invalid example for changeQuantity | A −1 in the cart and a smaller total | No rule-derived input was refused, so the setter accepts anything | Walk the rules: quantity > 0 refuses −1; add the check before the set. |
| Invalid example without an after | The test passes and the cart holds Laptop × 0 | The check runs after the append; "throws" was asserted, "unchanged" was not | State the after; assert on the items after the throw; move the check before the mutation. |
| No edge example for total | An empty cart's total is null and the checkout page crashes | The sum was initialised from the first item instead of from zero | Write [] → 0; start the sum at zero. |
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.
- Make a grid: operations down, normal / edge / invalid across. Fill every cell before writing code; an empty cell is a case the code will decide for you (Edge Cases From Examples).
- For edges, walk the state: empty, one entry, the product already present, the last entry. Each special state is a candidate edge for every operation.
- For invalids, walk the rules: each rule refuses something, and that something is the example (From Invariant to Validation).
- Write the after of every invalid case as the unchanged before plus the message; then trace the operation to confirm no line runs that could change the state before the rejection.
- Move anything about persistence, network or concurrency to a later version's grid; it is not a case of this operation yet.
Worked on a concrete problem
The move has to produce something. This is what it produced.
- addItem. Normal: [] + add Laptop → [Laptop × 1]. Edge: [Laptop × 1] + add Laptop → [Laptop × 2], not two entries. Invalid: [] + add Laptop × 0 → rejected "quantity must be positive", cart still []. The edge produced the find; the invalid put the check before it.
- removeItem. Normal: [Laptop × 2, Mouse × 1] − Mouse → [Laptop × 2]. Edge: remove the last item → [], which is still a valid cart. Invalid: remove Keyboard, which is not there → cart unchanged, V1 choosing a no-op where an API might choose 404 — the invalid case for a delete is a decision, and it is written down as one.
- changeQuantity. Normal: Laptop × 2 → 3. Edge: → 0, and the entry disappears rather than staying at zero. Invalid: → −1, rejected. total. Normal: Laptop × 2 at 1000 and Mouse at 20 → 2020. Edge: empty cart → 0. Invalid: a product with no price → an error surfaced, not silently 0.
How you know it worked
What now exists that did not before, and what question you can now ask.
- Every operation has three examples with three jobs, and you can say which line of the implementation each one produced.
- Every invalid example states the after explicitly, and it equals the before.
- The "edge cases" list no longer contains anything the operation cannot experience in this version.
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.
- ?For this operation, what is the plain case, what state makes it behave differently, and what input do the rules refuse?
- ?Which special states — empty, one, already there, last — does this operation care about?
- ?For each invalid example, what exactly is the state afterwards?
- ?Which of my "edge cases" belong to a later version's infrastructure rather than to this operation?
What can go wrong
- Three becomes thirty: every combination of state and input gets an example, and the grid stops being read. Three per operation is what the implementation needs; more belong in tests, and only if they exercise a different branch.
- The edge is chosen from imagination — "what if the product name has an emoji?" — instead of from the state, and the already-present case is missed because it does not feel edgy.
- The invalid case is written as "throws" with no after, and the check that throws after appending passes the test.
- Three examples per operation is six operations times three for the cart, and most of the eighteen are unsurprising; the discipline pays on the two that are not.
- Sorting cases into three kinds forces decisions — is removing an absent product invalid or an edge? — that a longer, vaguer list would have let you postpone.
- Excluding infrastructure failures from the grid means they must be tracked somewhere else, and that somewhere is easy to lose.
- "Edge cases are the cases where things fail." An edge is a valid input on an awkward state, and it must succeed — differently. Treating the second add as a failure is the misread that produces "already in cart" errors nobody asked for.
- "Three is a rule." It is a minimum with a job for each; an operation with two branches may need two edges. The rule is that each example must produce something in the implementation, not that there are three.
- "Test everything" means every combination. Falsifiable: a case that exercises no new branch and no new rule changes nothing in the code and proves nothing new; it costs a test and buys a green line.
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.
- GENERALNormal / edge / invalid is a partition of any operation's inputs — a pagination request past the last page, a login with an empty password, a job enqueued twice — and each kind produces the same kind of code in every concept.
- STAGE-SPECIFICOn an in-memory V0 the invalid cases are inputs; once there is persistence, an API and concurrency, each version adds a fourth column — "the environment failed" — with its own examples, and the lesson's grid is deliberately the V0 one.
- ILLUSTRATIVEThe products, prices (1000, 20) and quantities are the concept record's invented example; the grid's shape is the point, not the numbers.
Where the depth lives
This domain asks the question and hands the answer off by name.