Decision Tables: The Substitution Engine in Go

June 24, 2026 · 16 min read

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.

The previous post showed how 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"
)

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. 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 team adds rows until coverage is complete.

“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:

// file: supplymatching/rules.go
type SubstitutionRequest struct {
	Produce      string
	Season       Season
	Allergens    AllergenFlag
	Preferences  PreferenceFlag
	BoxSize      BoxSize
	PriceBand    PriceBand
	CustomerFlag bool
}

When CustomerFlag is true, the engine returns a sentinel value that triggers manual review:

var ManualReview = "MANUAL_REVIEW"

A rule at the top of every table catches it:

{"zucchini", SeasonAny, AllergenAny, PreferenceAny, BoxSizeAny, PriceBandAny, true, ManualReview},

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.

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 30 June.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.