--- name: data-driven-design description: Use when adding a mode, switch case, config entry, special-cased name, or per-column/per-dataset handler to a tool that processes authored data — or when a tool "knows" a project's layout, dataset names, key prefixes, or format quirks and could not be open sourced as-is. --- # Data-Driven Design The tool implements **general mechanisms**; the data declares **all specific knowledge**. Every time code names a thing that lives in data — a dataset, a column, a format quirk — the tool stops being a tool and becomes a private extension of one project's layout. **The test:** could this repo be open sourced and build a *different* project's data without code changes? Every hardcoded name, mode, and special case is a "no". ## The two smells **1. The mode zoo.** A new data shape arrives and you add a named mode plus a switch case: ```yaml - {column: AvailableHeadsMale, mode: external_hex_list, hex_width: 3} - {column: PreferredAlignments, mode: alignment_set_mask, hex_width: 3} - {column: ProficiencyFeats, mode: id_hex_list, hex_width: 4} ``` Three "modes" are one concept — *a set of ids with an engine-side encoding* — wearing three names. Each mode is code the data could have been. The fix is a smaller vocabulary of orthogonal primitives declared where the column is defined: ```json "EquipSlots": {"shape": "id_set", "encode": "bitmask", "max": 31} ``` One parser for `id_set`, one encoder per encoding. Adding the next column is a data edit, zero code. The mode zoo *shrinks*: N special cases collapse into shape × encoding. **2. Names in code.** `datasetRows("skills")`, `case "skills":`, `TrimPrefix(key, "skills:")` — the tool assumes the project's layout. When data moves (`skills` → `skills/core`), the tool breaks even though the data is self-consistent. Names must flow *from* the data: the spec that references a dataset names it; a key's namespace is whatever precedes its own colon. If code needs a name, some piece of data already knows it — read it from there. ## Deciding Ask of every constant, case, and config knob: **whose knowledge is this?** - *The mechanism's* (how to parse JSON, allocate rows, write a table) → code. - *The data's* (which columns exist, how the engine encodes them, what things are called) → data, even if code is faster to write today. Generalize only when it deletes: a primitive that replaces N modes is lazy; a framework "for future shapes" is not. One new shape may take its case *if* the case is spelled as a reusable primitive the next shape can declare. ## Rationalizations | Excuse | Reality | |---|---| | "Just follow the existing pattern, it's proven" | The pattern *is* the defect. Each repetition raises the cost of the real fix. | | "N cases isn't a crisis yet" | The crisis is per-case: every one couples the tool to one project forever. | | "Release is tonight, generalize later" | Later never comes; tonight's mode is tomorrow's baseline. Declaring shape in data is usually the *same* hour of work. | | "This name never changes" | Today's rename broke exactly such a name. Data moves; mechanisms shouldn't care. | ## Red flags — stop and re-shape - A config entry that names both a dataset and a column to select behavior - A new value in a `mode`/`kind`/`type` string enum with its own switch arm - A literal in code that also appears in the data tree (`"skills"`, `"skills:"`) - Per-project constants (row ranges, hex widths, id ceilings) living in Go instead of the dataset that owns them