Edge Cases From Examples
Once one example exists, the edge cases come from varying it along a short, repeatable list: zero, one, many, duplicate, invalid, concurrent. The list is not a template — it is where the requirements the happy path hid tend to live, and each one either has an obvious answer or is a decision.
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 the happy path and one worked example. How do you systematically find the cases that will break it, without guessing at random?
Checkout works. You added a product, paid in test mode, saw the confirmation. Someone on the team asks "what about edge cases?" and you say "like what?" — and realise that you have no method for finding them beyond waiting for a bug report. You could brainstorm, but brainstorming produces whatever you happened to think of.
Brainstorm. Open a list, write "empty cart", "invalid card", "product deleted", and stop when the ideas stop. It feels thorough, because the list has things on it, and it is honest work — every item is a real case.
The list is whatever came to mind, which is whatever has bitten you before. A developer who has never seen a duplicate-submit bug will not write "customer clicks Pay twice", and that is the case that costs money.
- The list is whatever came to mind, which is whatever has bitten you before. A developer who has never seen a duplicate-submit bug will not write "customer clicks Pay twice", and that is the case that costs money.
- Nothing on the list is decided. "Empty cart" is written down but not answered: is checkout refused, or is the button hidden, or both? A case without an expected behaviour is a worry, not an edge case.
- The list has no shape, so nobody can say whether it is finished. Ten items feel thorough; the eleventh — two customers, last unit — is the one that matters, and there was no reason it would come up.
- The cases are found after the code, so each one is a patch. The happy-path design assumed a non-empty cart at every step, and "empty cart" now needs a guard in three places.
The move
Precisely enough to apply it to a problem you have never seen — not a slogan.
- Take the working example and vary it along six axes that are the same for nearly every input a system accepts: zero of the thing, exactly one, many, the same thing twice (duplicate), a thing that is invalid or missing, and two of the thing at the same time (concurrent). Each axis applied to each input of the workflow produces a case; ask "what should happen?" of each.
- Sort the answers into three piles. Obvious: write the expected behaviour down as a test description. Undecided: this is a requirement nobody stated; take it to whoever owns the product. Impossible-here: the case cannot occur because of a rule elsewhere — write down which rule, because it is now an invariant you depend on.
- Use the piles to change the design, not just to add guards. If "product deleted while in cart" is possible, the cart holds a snapshot or a reference, and that is a data-model decision (Snapshots vs References) rather than a null check.
- Stop when each axis has been applied to each input once. The list has a shape now, and finishing it is a fact rather than a feeling. More cases can still appear later; they get added to the same grid, not to a new brainstorm.
The grid, applied to checkout
The grid is the method made visible. Inputs down the side, six axes across, and in each cell either the expected behaviour, "decision", or "impossible because". Most cells take seconds; the ones that take longer are the lesson.
| Input | Zero | One | Many | Duplicate | Invalid | Concurrent |
|---|---|---|---|---|---|---|
| Cart items | Refuse: "your cart is empty" | Happy path | Total correct; long order page deferred | Same product twice → merge lines at add time | Deleted product → decision (remove with message) | Two tabs editing one cart → last write wins, accepted for V1 |
| Quantity per line | Refuse the line | Happy path | More than stock → refuse with count | — | Negative or non-integer → refuse at the boundary | Two customers, last unit → atomic decrement, exactly one succeeds |
| Pay request | Impossible: the button submits one | Happy path | — | Second click returns the first order (idempotency key) | Card declined → order stays unpaid, message shown | Same cart from two tabs → same key, same order |
| Customer | Guest checkout → decision (V1: account required) | Happy path | — | — | Session expired mid-checkout → cart preserved, re-login | — |
Cases that became design, not guards
A good grid changes the shape of the system in a few places. The decomposition below is the checkout after the grid, with the edge cases that earned a component of their own rather than an if-statement. Every leaf is stated as the observation that would show it works — the same sentence that started as a cell.
- ├Validate the cart at checkout time— the deleted-product and quantity cells
- └Refuse empty and invalid linestestable An empty cart, a zero quantity and a negative quantity are each refused with a distinct reason before any order exists.
- └Re-check products against the catalogtestable A cart holding a product deleted after it was added is shown a message naming the product, and the line is removed.
- ├Make the Pay request idempotent— the duplicate and concurrent cells for the request
- └Key the request on the cart versiontestable Two Pay requests with the same key produce one order and one payment; the second response equals the first.
- ├Reserve stock atomically— the concurrent cell for quantity
- └Conditional decrementtestable Three units, two concurrent orders of two: exactly one order exists afterwards and stock reads one.
Three components came from three cells. The other cells became guards or "later", which is fine — the grid is for finding out which is which.
How the grid goes wrong
The method fails in predictable ways, and each failure has a recognisable symptom. Watching for them is cheaper than filling in every cell of every workflow.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| The grid is filled for every field of every form | A week of cells, none of which changed anything | The method applied where the happy path had no hidden assumptions | Core workflow first; one input at a time as features arrive |
| Every cell becomes an early return | Checkout has eleven guards and still double-charges | Cases handled as exceptions instead of examined for mechanism | Ask of each case: guard, decision, or design change? |
| The developer decides the undecided pile | A default branch sells the discontinued product | A business rule hidden in code nobody asked for | Undecided cases go to the owner as examples with two options |
| Only the six axes are ever used | Price changed between add and pay; nobody noticed | Time and state are their own axes | Add "what changed since?" as a seventh column for stateful workflows |
How to do it
Most important first.
- List the inputs of the workflow first: for checkout, the cart, its items, the quantities, the customer, the payment, the request itself. Edge cases are variations of inputs, so you need the inputs to vary.
- Apply zero / one / many / duplicate / invalid / concurrent to each input. Most cells are boring; the method is cheap precisely because most cells take five seconds.
- Write the expected behaviour for each non-boring cell as a sentence a test could be written from, and mark the ones you had to guess (A Slice Is Testable).
- Pay particular attention to duplicate and concurrent, because the happy path is single-threaded and single-click by construction and those two axes are where it silently assumed the world would cooperate (Duplicate Requests).
- Take the undecided pile to the product owner as examples, not as abstractions: "a customer presses Pay twice within a second — one order or two?" gets an answer; "how should we handle idempotency?" gets a shrug.
- Add the decided cases to the invariants list where they belong (What Must Never Break): "stock never below zero" and "one payment per order" were edge cases before they were invariants.
Worked on a concrete problem
The move has to produce something. This is what it produced.
- Checkout, input "cart items". Zero: an empty cart — refuse checkout with a reason, and hide the button; both, because the request can arrive without the button. One: the happy path. Many: a cart of forty lines — fine, but the total must not overflow anything and the order page must paginate or scroll; noted, not V1.
- Input "the Pay request". Duplicate: the customer clicks twice — the second click must find the first order and return it, not create another one; this is the case that makes an idempotency key a requirement rather than a nicety. Concurrent: two tabs, both paying the same cart — same answer, same mechanism, and now the mechanism has two tests.
- Input "product in cart". Invalid: the product was deleted by the admin after it was added — undecided, taken to the founder as "Alice has a discontinued lamp in her cart; does checkout refuse, remove it with a message, or sell it anyway at the old price?" She chooses remove-with-message, and the cart now needs to notice at checkout, not only at add time.
- Input "quantity". Zero: a line with quantity zero — invalid, refuse. Many: quantity larger than stock — refuse with the count. Concurrent: two customers, last unit — the race from Example-Driven Thinking, now sitting in a grid next to its siblings rather than found by luck.
How you know it worked
What now exists that did not before, and what question you can now ask.
- The edge-case list has a shape — inputs down one side, six axes across — and you can say it is complete for this workflow rather than that it feels complete.
- Every case has an expected behaviour or a name next to it, and the decided ones read as test descriptions.
- At least one case changed the design rather than adding a guard: a snapshot instead of a reference, an idempotency key, an atomic decrement.
- The concurrent and duplicate rows have entries, which means the happy path's single-threaded assumptions have been named.
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.
- ?What are the inputs of this workflow, and what happens with zero, one and many of each?
- ?What happens if this request arrives twice, and what happens if it arrives twice at the same moment?
- ?Which of these cases has an obvious answer, which is a decision nobody has made, and which is impossible because of a rule I am now depending on?
- ?Which case changes the data model or the mechanism, rather than needing a guard?
What can go wrong
- The grid is filled in for every input of every workflow before anything is built. Six axes times every field is hundreds of cells, most of them boring; the method is for the core workflow first, and for one input at a time when a new feature arrives.
- The axes are treated as the only edge cases. Zero / one / many / duplicate / invalid / concurrent finds most of them; it does not find "the price changed between add-to-cart and pay" or "the customer's currency differs from the product's". Those come from the state and time axes (Overwrite or Append?), and the grid should say so.
- Every case gets a guard and none gets a decision. The result is a checkout with eleven early returns and a design that still assumes a cooperative world underneath them.
- The undecided pile is decided by the developer. "Sell the discontinued lamp anyway" is a business call, and a developer who makes it silently has hidden a requirement inside a default branch.
- A grid is more work than a brainstorm and most of its cells are empty by inspection. The cost is paid in exchange for being able to say "done".
- Cases found before the code delay the code. The happy path could have shipped an afternoon earlier; the afternoon bought the duplicate-click case, which would have cost far more found in production.
- Taking undecided cases to a product owner produces decisions you may not like, and some of them — "sell it anyway" — are harder to build than the guard you would have written.
- "Six axes is the complete list." It is the list that finds the cases the happy path structurally hides — because the happy path always has one of everything, once, valid, alone. Time, state, money and permissions have their own axes, and each domain adds its own.
- "Edge cases are a testing concern." They are a requirements concern that testing later confirms. The case where a deleted product sits in a cart changed the cart's data model; a test could not have done that after the fact.
- "Handle every case in V1." The grid tells you the cases exist; the MVP decision says which ones V1 handles and which are written down as known and deferred. "Forty-line carts render badly" is deferred; "double-click charges twice" is not (What Cannot Be Simplified).
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.
- GENERALZero / one / many / duplicate / invalid / concurrent applies to any input of any system — a function argument, a request body, a message on a queue, a file in a bucket; the axes name what a happy path structurally assumes.
- ILLUSTRATIVEThe empty cart, the forty-line cart, the discontinued lamp and the double click are invented; the numbers are for the shape of the grid, not a real store's limits.
- SIMPLIFIEDSix axes is a teaching list. Real edge-case discovery adds time (stale state), money (rounding, currency), permissions (who may do this) and locale, and for a parser or protocol the input-shape axes dominate; the six are the ones a happy path hides in every domain.
Where the depth lives
This domain asks the question and hands the answer off by name.