The decision tables are done. Twelve produce items. Three hundred rows. Maya’s forty years of farming knowledge, flattened into a spreadsheet. Now someone has to turn them into code.
Maya and Anika built decision tables for Greenbox’s substitution logic. The LLM generated about a thousand lines of Go with four hundred test cases. This is what that code looks like, and why table-driven tests are the natural implementation pattern.
The decision table as data
Charlotte’s first suggestion surprises Tom. “Don’t write a giant if-else tree. Model the table as data.”
The decision table for zucchini has six condition columns and one action column. In Go, that’s a struct:
// file: supplymatching/rules.go
package supplymatching
type SubstitutionRule struct {
Produce string
Season Season
Allergens AllergenFlag
Preferences PreferenceFlag
BoxSize BoxSize
PriceBand PriceBand
Substitute string
}
type Season string
const (
SeasonSummer Season = "summer"
SeasonWinter Season = "winter"
SeasonAny Season = "any"
)
type AllergenFlag string
const (
AllergenNone AllergenFlag = "none"
AllergenNightshade AllergenFlag = "nightshade"
AllergenNuts AllergenFlag = "nuts"
AllergenAny AllergenFlag = "any"
)
type PreferenceFlag string
const (
PreferenceNone PreferenceFlag = "none"
PreferenceNoLegumes PreferenceFlag = "no_legumes"
PreferenceNoBrassicas PreferenceFlag = "no_brassicas"
PreferenceAny PreferenceFlag = "any"
)
type PriceBand string
const (
PriceBandStandard PriceBand = "standard"
PriceBandPremium PriceBand = "premium"
PriceBandAny PriceBand = "any"
)
type BoxSize string
const (
BoxSizeSmall BoxSize = "small"
BoxSizeMedium BoxSize = "medium"
BoxSizeLarge BoxSize = "large"
BoxSizeAny BoxSize = "any"
)
Supply Matching defines its own BoxSize rather than borrowing the Subscription context’s, the same different-types-for-different-contexts discipline the team adopted when drawing the context boundaries.
The value types use Go’s type system to prevent invalid combinations. A Season is not a string you can misspell, it’s one of three constants.
The table as a Go slice
Each produce item’s decision table becomes a slice of rules. Here’s zucchini, the table Maya built with Anika and Dave corrected:
// file: supplymatching/rules_zucchini.go
var ZucchiniRules = []SubstitutionRule{
{"zucchini", SeasonSummer, AllergenNone, PreferenceNone, BoxSizeSmall, PriceBandStandard, "green beans"},
{"zucchini", SeasonSummer, AllergenNone, PreferenceNone, BoxSizeLarge, PriceBandStandard, "green beans"},
{"zucchini", SeasonSummer, AllergenNone, PreferenceNoLegumes, BoxSizeSmall, PriceBandStandard, "yellow squash"},
{"zucchini", SeasonSummer, AllergenNone, PreferenceNoLegumes, BoxSizeLarge, PriceBandStandard, "yellow squash"},
{"zucchini", SeasonSummer, AllergenNightshade, PreferenceNone, BoxSizeSmall, PriceBandStandard, "green beans"},
{"zucchini", SeasonWinter, AllergenNone, PreferenceNone, BoxSizeSmall, PriceBandStandard, "broccoli"},
{"zucchini", SeasonWinter, AllergenNone, PreferenceNone, BoxSizeLarge, PriceBandStandard, "broccoli"},
{"zucchini", SeasonWinter, AllergenNone, PreferenceNoBrassicas, BoxSizeSmall, PriceBandStandard, "sweet potato"},
{"zucchini", SeasonWinter, AllergenNone, PreferenceNoBrassicas, BoxSizeLarge, PriceBandStandard, "kent pumpkin"},
{"zucchini", SeasonWinter, AllergenNightshade, PreferenceNone, BoxSizeSmall, PriceBandStandard, "broccoli"},
{"zucchini", SeasonWinter, AllergenNightshade, PreferenceNoBrassicas, BoxSizeSmall, PriceBandStandard, "sweet potato"},
{"zucchini", SeasonAny, AllergenAny, PreferenceAny, BoxSizeAny, PriceBandPremium, "asparagus"},
}
Dave’s correction is right there in the data, row 9 says “kent pumpkin” not “butternut pumpkin” for large winter boxes without brassicas. These twelve rows are an excerpt; the shipped zucchini table has thirty-eight. The code is the table. The table is the truth.
The matching engine
The engine evaluates rules top-to-bottom and returns the first match. Wildcards (Any) match everything:
// file: supplymatching/engine.go
type SubstitutionEngine struct {
rules []SubstitutionRule
}
func NewSubstitutionEngine(rules ...[]SubstitutionRule) *SubstitutionEngine {
var all []SubstitutionRule
for _, r := range rules {
all = append(all, r...)
}
return &SubstitutionEngine{rules: all}
}
type SubstitutionRequest struct {
Produce string
Season Season
Allergens AllergenFlag
Preferences PreferenceFlag
BoxSize BoxSize
PriceBand PriceBand
}
func (e *SubstitutionEngine) FindSubstitute(req SubstitutionRequest) (string, error) {
for _, rule := range e.rules {
if matches(rule, req) {
return rule.Substitute, nil
}
}
return "", fmt.Errorf(
"no substitution rule for %s (season=%s, allergens=%s, prefs=%s, size=%s, price=%s)",
req.Produce, req.Season, req.Allergens, req.Preferences, req.BoxSize, req.PriceBand,
)
}
func matches(rule SubstitutionRule, req SubstitutionRequest) bool {
return rule.Produce == req.Produce &&
matchSeason(rule.Season, req.Season) &&
matchAllergen(rule.Allergens, req.Allergens) &&
matchPreference(rule.Preferences, req.Preferences) &&
matchBoxSize(rule.BoxSize, req.BoxSize) &&
matchPriceBand(rule.PriceBand, req.PriceBand)
}
func matchSeason(rule, req Season) bool {
return rule == SeasonAny || rule == req
}
func matchAllergen(rule, req AllergenFlag) bool {
return rule == AllergenAny || rule == req
}
func matchPreference(rule, req PreferenceFlag) bool {
return rule == PreferenceAny || rule == req
}
func matchBoxSize(rule, req BoxSize) bool {
return rule == BoxSizeAny || rule == req
}
func matchPriceBand(rule, req PriceBand) bool {
return rule == PriceBandAny || rule == req
}
The engine is twenty lines of logic. The complexity lives in the data, not the code. When Greenbox onboards dragon fruit, they add a DragonFruitRules slice. The engine doesn’t change.
Table-driven tests: every row is a test case
This is where Go shines. Each row in the decision table becomes a row in a table-driven test. The structure is identical, conditions in, substitute out:
// file: supplymatching/engine_test.go
func TestZucchiniSubstitutions(t *testing.T) {
engine := NewSubstitutionEngine(ZucchiniRules)
tests := []struct {
name string
season Season
allergens AllergenFlag
preferences PreferenceFlag
boxSize BoxSize
priceBand PriceBand
want string
}{
{
name: "summer, no constraints, small box",
season: SeasonSummer,
allergens: AllergenNone, preferences: PreferenceNone,
boxSize: BoxSizeSmall, priceBand: PriceBandStandard,
want: "green beans",
},
{
name: "summer, no legumes, small box",
season: SeasonSummer,
allergens: AllergenNone, preferences: PreferenceNoLegumes,
boxSize: BoxSizeSmall, priceBand: PriceBandStandard,
want: "yellow squash",
},
{
name: "winter, no constraints, small box",
season: SeasonWinter,
allergens: AllergenNone, preferences: PreferenceNone,
boxSize: BoxSizeSmall, priceBand: PriceBandStandard,
want: "broccoli",
},
{
name: "winter, no brassicas, large box gets kent pumpkin not butternut",
season: SeasonWinter,
allergens: AllergenNone, preferences: PreferenceNoBrassicas,
boxSize: BoxSizeLarge, priceBand: PriceBandStandard,
want: "kent pumpkin",
},
{
name: "winter, nightshade allergy AND no brassicas",
season: SeasonWinter,
allergens: AllergenNightshade, preferences: PreferenceNoBrassicas,
boxSize: BoxSizeSmall, priceBand: PriceBandStandard,
want: "sweet potato",
},
{
name: "premium price band always gets asparagus",
season: SeasonSummer,
allergens: AllergenNone, preferences: PreferenceNone,
boxSize: BoxSizeSmall, priceBand: PriceBandPremium,
want: "asparagus",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := engine.FindSubstitute(SubstitutionRequest{
Produce: "zucchini",
Season: tt.season,
Allergens: tt.allergens,
Preferences: tt.preferences,
BoxSize: tt.boxSize,
PriceBand: tt.priceBand,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
The test table mirrors the decision table. Each test case is named with the conditions, so when one fails, the name tells you exactly which combination broke: winter, nightshade allergy AND no brassicas. No debugging. No reading the assertion. The name is the diagnosis.
Catching gaps
Anika’s edge case from the workshop, vegan AND nut allergy AND small box AND winter, becomes a test:
// file: supplymatching/engine_test.go
func TestMissingCombinationReturnsError(t *testing.T) {
engine := NewSubstitutionEngine(ZucchiniRules)
_, err := engine.FindSubstitute(SubstitutionRequest{
Produce: "zucchini",
Season: SeasonWinter,
Allergens: AllergenNuts,
Preferences: PreferenceNoLegumes,
BoxSize: BoxSizeSmall,
PriceBand: PriceBandStandard,
})
if err == nil {
t.Error("expected error for uncovered combination")
}
}
This test would fail if the team hadn’t added a rule. It codifies the gap. Every gap Anika found at the whiteboard becomes a test that proves the gap is closed.
Charlotte makes the team write gap tests before adding the rule. “Write the test for the combination you don’t handle yet. Watch it fail. Then add the row to the table. Watch it pass. The test proved you needed the rule. The rule proved you closed the gap.”
Completeness checking
For critical produce items, the team writes a completeness test that checks every valid combination has a rule:
// file: supplymatching/engine_test.go
func TestZucchiniCoverage(t *testing.T) {
engine := NewSubstitutionEngine(ZucchiniRules)
seasons := []Season{SeasonSummer, SeasonWinter}
allergens := []AllergenFlag{AllergenNone, AllergenNightshade, AllergenNuts}
preferences := []PreferenceFlag{PreferenceNone, PreferenceNoLegumes, PreferenceNoBrassicas}
sizes := []BoxSize{BoxSizeSmall, BoxSizeMedium, BoxSizeLarge}
priceBands := []PriceBand{PriceBandStandard, PriceBandPremium}
var missing []string
for _, season := range seasons {
for _, allergen := range allergens {
for _, pref := range preferences {
for _, size := range sizes {
for _, price := range priceBands {
_, err := engine.FindSubstitute(SubstitutionRequest{
Produce: "zucchini",
Season: season,
Allergens: allergen,
Preferences: pref,
BoxSize: size,
PriceBand: price,
})
if err != nil {
missing = append(missing, fmt.Sprintf(
"season=%s allergen=%s pref=%s size=%s price=%s",
season, allergen, pref, size, price,
))
}
}
}
}
}
}
if len(missing) > 0 {
t.Errorf("uncovered combinations (%d):\n%s", len(missing), strings.Join(missing, "\n"))
}
}
This test generates every valid combination of conditions, 2 seasons x 3 allergens x 3 preferences x 3 sizes x 2 price bands = 108 combinations, and checks each one has a matching rule. When it fails, it lists every gap. The twelve-row excerpt earlier in this post would fail it loudly; the shipped zucchini table has thirty-eight rows precisely because this test kept listing combinations until the team had answered all of them.
“That’s the test that makes decision tables worth it,” Charlotte says. “You can’t write an equivalent for if-else trees. The tree might handle the combination silently wrong. The table either has a row or it doesn’t.”
Mrs Patterson’s beetroot
Mrs Patterson’s individual preference, “no beetroot”, doesn’t fit the category-based preference system. The team adds a CustomerFlag to the request, the existing struct grows a field:
// file: supplymatching/engine.go
type SubstitutionRequest struct {
Produce string
Season Season
Allergens AllergenFlag
Preferences PreferenceFlag
BoxSize BoxSize
PriceBand PriceBand
CustomerFlag bool // any individually recorded preference
}
When CustomerFlag is true, the engine doesn’t consult the table at all. It short-circuits to a sentinel value that triggers manual review:
// file: supplymatching/engine.go
const ManualReview = "MANUAL_REVIEW"
func (e *SubstitutionEngine) FindSubstitute(req SubstitutionRequest) (string, error) {
if req.CustomerFlag {
return ManualReview, nil
}
for _, rule := range e.rules {
if matches(rule, req) {
return rule.Substitute, nil
}
}
return "", fmt.Errorf(
"no substitution rule for %s (season=%s, allergens=%s, prefs=%s, size=%s, price=%s)",
req.Produce, req.Season, req.Allergens, req.Preferences, req.BoxSize, req.PriceBand,
)
}
There’s deliberately no Mrs Patterson row in any table. An individual preference isn’t a combination of categories, it’s a signal that a human should look. The guard clause says so once, before the rules are evaluated, instead of as a wildcard row repeated across twelve tables, and there’s no way to forget it when the team writes table thirteen.
Mrs Patterson gets flagged for Sam to check. The engine doesn’t try to be clever about individual preferences, it defers to a human. “The system is honest about what it doesn’t know,” as Charlotte put it.
Loading tables from CSV
The team initially defines rules as Go slices. But Maya and Anika maintain the tables in spreadsheets, that’s where domain experts work. So Tom writes a CSV loader:
// file: supplymatching/csv.go
func LoadRulesFromCSV(r io.Reader) ([]SubstitutionRule, error) {
reader := csv.NewReader(r)
header, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("reading header: %w", err)
}
if len(header) < 7 {
return nil, fmt.Errorf("expected at least 7 columns, got %d", len(header))
}
var rules []SubstitutionRule
lineNum := 1
for {
lineNum++
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNum, err)
}
rule, err := parseRule(record, lineNum)
if err != nil {
return nil, err
}
rules = append(rules, rule)
}
return rules, nil
}
Now Maya edits a spreadsheet. Exports to CSV. The build picks up the new rules. Tests run. If coverage is incomplete, CI fails. Maya doesn’t need to write Go. She doesn’t need to talk to an LLM. She edits a spreadsheet, the tool she’s used for twenty years.
Keeping the CSV and the code honest
Two sources of truth is how things drift. Maya edits the spreadsheet, the running engine keeps using last week’s rules, and nobody notices until a subscriber gets the wrong box. The team closes that gap by refusing to have two sources at all. The CSV is embedded into the binary at build time:
// file: supplymatching/rules.go
import _ "embed"
//go:embed tables/zucchini.csv
var zucchiniCSV string
There’s no generated Go to fall out of date, because there’s no generated Go. The spreadsheet Maya exports is the thing the binary reads. Build the engine and you’ve built her latest table.
That leaves one question: did her edit leave a gap? The completeness test answers it. CI runs go test ./... on every change, and TestZucchiniCoverage walks all 108 combinations against the embedded CSV. If Maya’s export is missing a row, the build goes red with the exact list of uncovered combinations, before anything ships.
The named tests, TestZucchiniSubstitutions and its kin, play the opposite role. They pin specific answers: winter, no brassicas, large box gets kent pumpkin. Change that row in the spreadsheet and the test goes red, not because anything’s broken, but because you’ve changed a decision someone wrote down on purpose. A red there is a question: did you mean to?
“The build is the reconciliation,” Charlotte says. “You don’t check that the code matches the table. You make the table the only thing there is, and let the tests prove it’s complete.” Coverage proves the table is whole; the named tests prove the changes are deliberate. Between them, a spreadsheet edit can’t reach production unnoticed.
What the team learned
Three months later, the substitution engine has twelve produce tables, four hundred and twelve rules, and zero production incidents. Anika runs Melbourne matching every Tuesday in forty minutes. Maya reviews the output over breakfast.
When Greenbox adds corporate catering boxes, the team adds a new price band (PriceBandCorporate) and new rules. The completeness tests immediately flag sixty-seven gaps. They fill them in an afternoon.
“The decision table isn’t the clever bit,” Charlotte says. “The clever bit is that the test structure matches the table structure. When you add a dimension, the tests tell you every combination you forgot.”
Tom, who initially wanted to write the substitution logic as a series of if-else statements (“it’s just conditions, how hard can it be”), admits the table approach caught combinations he’d never have tested. “I would’ve written the happy path and five edge cases. The table has four hundred rows. Most of them are edge cases.”
The decision tables captured what Greenbox does. They don’t capture why. As the team grows and decisions pile up, knowing the reasoning behind those decisions becomes critical. It starts when Ravi, a new developer, asks a simple question nobody can answer: “Why does the payment system charge on delivery day instead of signup day?”
The next chapter, Architecture Decision Records: Why We Did It That Way, publishes around 25 June.