Decision Tables: Business Logic You Can Test
A returns rule that reads well in prose, laid out as a sixteen-row decision table: which combinations the policy settles, and what to do with the rest.
Here is a business rule as it stands on a public returns page.
Items can be returned within 30 days of delivery. Damaged or faulty items are refunded in full, including the original delivery cost. If you have changed your mind, the item must be unused and in its original packaging. Partial refunds are available on orders over 40 EUR.
Four sentences, nothing obviously missing. I read it twice and could not say what was wrong. Then I put it in a grid and by the third column it had become sixteen cases, seven of which the text does not answer.
A decision table is a grid that lists every combination of the conditions a rule depends on and states the outcome for each combination, so that the cases nobody thought about show up as empty cells instead of as arguments during build.
No client and no engagement here: a published returns policy, a few help centre articles from the same retailer, ten public complaint threads I picked myself. Nobody there was asked anything and I hold no internal data, so every number below comes from those sources or from counting cells you can recount.
In short
- A rule that reads cleanly in prose turns into sixteen cases once you lay out its conditions, and seven of them come out empty.
- An empty cell is not a gap in your table, it is a decision nobody has made, so it needs an owner, a due event and a default.
- The 40 EUR threshold everyone quotes changes the outcome in one of the seven distinct rules, and there it only removes an option.
- Sixteen rows collapse back to seven once you mark the irrelevant conditions, and that shorter version is what a developer gets.
What are the conditions, actually?
Decomposing prose means reading it for anything that can take more than one value. Most of the work is here, because prose hides conditions in adjectives.
Reason for return. Damaged or faulty, against changed my mind. The parcel that never arrived is a third case the passage does not cover: a customer with no item has nothing to return. Out of scope here on purpose, with a line saying so.
Inside the 30 day window. The text names a start event, better than most, though the carrier's delivery date and the day the box was opened are not always the same. Note that this is the general return window, and it is not the clock BR-04 runs on in the worked case: damage in transit has to be reported within 14 days of delivery. Two windows in one policy, and the table above only lays out the wider one. Anchoring a duration to a named event is the subject of acceptance criteria that survive a sprint review.
Unused and in original packaging. Two conditions wearing one coat. Used but boxed, and unused but the box went out with the recycling, get the same answer in my table. I collapsed them because splitting doubles the table, and wrote that down so a reviewer can overrule me in one line.
Order value above 40 EUR. Published, repeated in help centre wording as a hard rule, and no public source I found gives the reason for it or names who owns it. The boundary is soft as written: above 40 EUR and 40 EUR or more are different rules, and whether order value includes delivery is not stated.
Four conditions, two values each, sixteen rows. Then the outcomes, where prose was hiding a second decision. The rule reads as though there is one answer per case. There are two: whether the return is accepted, and what amount comes back. They do not always move together, and that is invisible until the columns are separate.
Sixteen rows
Open means the passage above does not answer it.
| # | Reason | Within 30 days | Unused and boxed | Over 40 EUR | Return accepted? | Amount refunded |
|---|---|---|---|---|---|---|
| 1 | Damaged | Yes | Yes | Yes | Yes | Item + delivery |
| 2 | Damaged | Yes | Yes | No | Yes | Item + delivery |
| 3 | Damaged | Yes | No | Yes | open | open |
| 4 | Damaged | Yes | No | No | open | open |
| 5 | Damaged | No | Yes | Yes | open | open |
| 6 | Damaged | No | Yes | No | open | open |
| 7 | Damaged | No | No | Yes | open | open |
| 8 | Damaged | No | No | No | open | open |
| 9 | Changed mind | Yes | Yes | Yes | Yes | open |
| 10 | Changed mind | Yes | Yes | No | Yes | open |
| 11 | Changed mind | Yes | No | Yes | open | open |
| 12 | Changed mind | Yes | No | No | No | None |
| 13 | Changed mind | No | Yes | Yes | No | None |
| 14 | Changed mind | No | Yes | No | No | None |
| 15 | Changed mind | No | No | Yes | No | None |
| 16 | Changed mind | No | No | No | No | None |
Rows 3 and 4: the policy attaches the packaging condition to the change of mind sentence only, while a help centre article asks for original packaging on all returns where possible. Two sources, one soft qualifier, nothing decidable from outside.
Rows 5 to 8: the passage says nothing about a fault found after the window closes, and whether rights of some kind outlive a retailer's own 30 days is for someone with standing to answer.
Rows 9 and 10 I did not expect: the return is accepted and no amount is stated, because the refund including delivery is promised for damaged goods only.
Row 11 is the interesting one. Read strictly, the item fails the unused and boxed condition and the return is refused. Read against the partial refund sentence, which names a threshold and never says what triggers one, a used item over 40 EUR is the case that clause would exist for. Both readings fit.
AS-1: unused and in original packaging treated as one condition. Splitting gives thirty two rows and matters in rows 11 and 12. Recorded, not resolved.
How much of this does the policy actually settle?
Sixteen rows and two outcome columns give thirty two cells: sixteen filled from the published text, sixteen open. Read row 11 the other way and the count changes, which is the useful kind of disagreement: it is about the business, not the table.
Now collapse it: where a condition makes no difference, mark it irrelevant and merge. Sixteen rows become seven rules.
- Damaged, in window, boxed: accept, item plus delivery. Value irrelevant.
- Damaged, in window, not boxed: open.
- Damaged, out of window: open. Other conditions irrelevant.
- Changed mind, in window, boxed: accept, amount open. Value irrelevant.
- Changed mind, in window, not boxed, over 40 EUR: open.
- Changed mind, in window, not boxed, 40 EUR or under: refuse.
- Changed mind, out of window: refuse. Other conditions irrelevant.
Three of the seven have nothing in them and a fourth has an acceptance with no amount. Seven is also the version to hand a developer, because sixteen rows invite somebody to implement four conditions where two would do.
The collapse also shows what the threshold does. Order value survives in one rule out of seven, and only to remove partial refund from the options in rule 6. It is the most quoted number in the policy and decides less than the sentence structure around it. I am not calling it wrong, since it is published policy and I cannot price a split refund from outside. A number can be prominent and nearly inert at once, and the table is what tells the difference.
Three of my ten threads land in rows this table leaves open, on my own reading of threads I picked, which is how I choose the cell to ask about first.
Why is an empty cell worse than a wrong one?
A wrong outcome is visible. Somebody reads rule 6, disagrees, and the argument happens in review where it is cheap.
An empty cell gets filled in anyway, later and by somebody else. A developer meets the case in build and takes the branch that is easiest to write. An agent meets it on a Tuesday, makes a reasonable call, then makes a different reasonable call three weeks later. Nobody logs a decision, because from where each of them sits nothing was decided. The behaviour ends up with no author and no record, and the first time anyone reads it as a rule is when a customer holds two answers side by side.
Who fills them in, and by when?
Sixteen open cells is not sixteen questions. Grouped by the decision behind them, they are four.
| # | Open question | Cells | Who decides | Due by | Default if the date passes |
|---|---|---|---|---|---|
| OQ-07 | Is a fault handled after the 30 day window, and on what terms? | 8 | Refund policy owner, with legal input. Public sources name no person, so the row stays a role | Before build starts, it changes the intake form | Route to a human queue with a named owner, never auto refuse |
| OQ-08 | Is a damaged item accepted without its original packaging? | 4 | Refund policy owner, with the warehouse that receives the item | Before build starts | Accept, since refusing on a where possible is the harder position to defend |
| OQ-09 | On a change of mind, does the original delivery cost come back? | 2 | Refund policy owner, with finance | Before the refund amount is coded | Item only, said on the return screen before the parcel is posted |
| OQ-10 | Used item over 40 EUR: refuse outright, or offer a partial refund? | 2 | Refund policy owner | Before build starts, it decides whether partial refund exists at all | Refuse, and record the partial refund clause as unreachable until a trigger is defined |
The numbering carries on from the register in the worked case, which already holds OQ-01 to OQ-06. Ids are permanent and never reused, so a question raised by a decision table gets the next free number rather than a private sequence that collides with the one a reader already has.
The last three columns matter more than the question. The owner is a role, because I could not name a person from public sources and an invented one is worse than an open row. The due date is an event in the build, so it survives a change of schedule. The default is the column people leave out, and it stops the question becoming a silent decision taken by whoever sits closest to the keyboard.
The sixteen settled cells go the other way: each is now a statement a tester can run without asking me what I meant.
The blank template
Three conditions, eight rows. Add the fourth only when you have proved you need it.
| # | [Condition 1, phrased so the answer is yes or no] | [Condition 2] | [Condition 3] | [Outcome A: the decision] | [Outcome B: amount, status or owner] |
|---|---|---|---|---|---|
| 1 | Yes | Yes | Yes | [outcome, or "open"] | [outcome, or "open"] |
| 2 | Yes | Yes | No | ||
| 3 | Yes | No | Yes | ||
| 4 | Yes | No | No | ||
| 5 | No | Yes | Yes | ||
| 6 | No | Yes | No | ||
| 7 | No | No | Yes | ||
| 8 | No | No | No |
Underneath it, two blocks that do most of the work.
AS-n [a condition you collapsed, a boundary you read one way, a case you
put out of scope. One line each, so a reviewer can overrule you]
OQ-n [the open question, one sentence]
Cells: [which rows it covers]
Owner: [the role holding the decision right, not a name you guessed]
Due: [an event in the build, not a calendar date]
Default: [what happens if that event arrives with no answer]
Then one line naming the source of every filled cell, so a reader can tell which outcomes came from a published rule and which from you.
When is the table the wrong tool?
Above five or six conditions it stops being readable, so split it by reason for return. Continuous values fit only once you band them, and the arguments then move to the band edges. Sequence it does not model at all: who holds the case while it waits is a question for a process model. And a first pass with no empty cells usually means you picked the conditions to fit the answers you already had.
The full returns case, with the register the criteria sit in and the rule register the thresholds come from, is in the proof pack. The table above is not in it: the pack carries these rules as a register rather than as a grid, so this page is the same logic laid out the other way. The scorecard carries the checks I run before anybody else sees it.
Twenty minutes, today: take a rule from a product you use, find two conditions, write the four rows and see how many you can answer. Two is a normal score. The two you cannot answer are the ones your team will decide by accident.
Read next
Acceptance Criteria That Survive a Sprint Review
What an acceptance criterion actually is, six weak ones shown as they arrived and as they went out, and the three object
From Change Request to Impact Analysis in One Page
A one-line change request and the six fields of an impact analysis, filled in one at a time: what belongs in each field,
Get the Proof Pack
Six blank templates and one worked case. Free, one email. The scorecard is a separate file and needs none.
Send it to meAll field notes · The portfolio guide · More in The Case File