ADR-007: Capability-Based Code Generation Instead of Identity-Based Dispatch¶
| Field | Value |
|---|---|
| Date | 2026-07-19 |
| Status | Accepted |
| Deciders | Holger Zahnleiter |
| Supersedes | — |
| Superseded by | — |
Context and Problem Statement¶
The generator selected language (C++/Go/Java) and project type (CLI tool/library/service) through
internal.Language/internal.ProjectType enums, dispatched via switch statements through a
three-tier generator hierarchy: a top-level templatingGenerator, one per-language generator
(templatingCPPGenerator/templatingGoGenerator/templatingJavaGenerator), and one leaf
generator per (language, project type) pair — 17 internal.Generator implementations in total.
Which guideline files (specs/guidelines/*.md) a given profile received was not recorded as a
data structure anywhere. Each of the 9 leaf generators hardcoded its own near-identical
[]engine.GenerateSpec list, duplicated again in 3 separate update_generator.go files for
repo update. The "Guideline Applicability" table in this repository's own CLAUDE.md was a
hand-maintained record of a mapping that existed nine times over in code, with no mechanism to
keep the two in sync.
Decision Drivers¶
- Extensibility: adding a new language or project type must not require touching central dispatch code
- Duplication: the same guideline-list logic existed independently in 9+3 places
- Single source of truth: the CLAUDE.md table and the actual generator behavior must never drift apart
- Testability: profile membership must be checkable without running the generator end-to-end
- Backward-compatible refactor:
repo init/repo update's public behavior must not change during the migration (see the golden characterization test suite added specifically for this)
Considered Options¶
- Identity-based switch dispatch (status quo)
- Capability-based flat
map[ProfileKey][]Capabilitymodel ← chosen - Capability-based nested
map[Language]map[ProjectType][]Capability]
Decision Outcome¶
Chosen option: Option 2 — flat capability registry, because it removes dispatch code as a touchpoint for new languages/project types entirely, while staying simpler and less error-prone than the nested alternative.
Capability is a typed string (self-documenting in test failures, go generate output, and
the generated CLAUDE.md table — unlike an ordinal int, which would carry no meaning outside the
program). A Registry maps each ProfileKey{Language, ProjectType} to its ordered
[]Capability list, and maps each Capability to the Action (a plain function) that produces
its artifact. A single generic capability.generator — implementing the one-method
internal.Generator interface — replaces all 17 former types: it resolves a project's profile,
then runs each capability's registered action in order. Language/project-type variance is
expressed exclusively through which profile row references which capability; no Action ever
branches on Language or ProjectType to decide whether to run — only, where legitimate, to
assemble the content of a single well-defined artifact (e.g. a Makefile's per-project-type
partials).
Positive Consequences¶
- Adding a new project type: new profile rows + capability actions in a new package, zero changes
to
registry.go,generator.go, or any existing package's dispatch logic - Adding a new language: one additional
Contribute()call in the composition root (internal/templating/registry.go), the only central touchpoint, and it is additive, not aswitchcase that must be remembered - The universal-guideline duplication (14 guidelines × up to 9 profiles) collapsed to one registration per guideline, referenced by every profile row
Registry.Inconsistencies()and a registry test with a fictional profile (TestRegistry_NewAxisRequiresNoCoreCodeChange) make the "no dispatch code needed" claim a standing, automatically enforced guarantee rather than a one-time observation- The CLAUDE.md "Guideline Applicability" table is now generated from the same
Registrythe generator itself runs against (internal/templating/gen, wired viago generate), with a CI drift check (make claude-md-check) — the table can no longer silently diverge from the code - A companion CI job,
make selfcheck(scripts/selfcheck.sh), applies the same never-silently-diverge principle to this repository's ownspecs/guidelines/and.claude/rules/: it generates a throwaway reference project from the current registry and diffs it against this repo's checked-in copies, catching both content drift and files a newer template generates that this repo never added
Negative Consequences / Risks¶
- An extra layer of indirection: reading what one profile generates means resolving a
Capabilitystring through the registry rather than reading a single generator struct top to bottom — mitigated byRegistry.CapabilitiesForbeing a small, directly testable function - Capability metadata (regenerable by
repo update, scaffolding-only, ...) accumulates as additional per-capability flags over time; left unchecked this could turn the registry into an implicit rules engine — mitigated by keeping the profile→capability map itself flat (never nested) and by modeling mutually exclusive states as a single tri-state field rather than independent booleans, so invalid combinations stay unrepresentable - The migration itself (17 old types → the new model) was large and had to be done in ~25 small,
individually tested steps to keep
repo init/repo updatebehavior byte-identical throughout — a one-shot rewrite would have carried materially higher regression risk
Pros and Cons of the Options¶
Option 1 — Identity-based switch dispatch¶
- Pro: No indirection; a leaf generator's
generateFiles()shows everything a profile produces in one place - Con: A new language or project type requires a new generator struct plus a new
casein every dispatchingswitch - Con: Guideline-list duplication across 9 leaf generators, with no shared data structure
- Con: No way to derive the CLAUDE.md table from code; it can only be maintained by hand
Option 2 — Capability-based flat map (chosen)¶
- Pro: New axis values are purely additive — no existing dispatch code changes
- Pro: One flat
map[ProfileKey][]Capability]is directly enumerable and testable (Inconsistencies(),TestBuildRegistry_HasAllEightProfiles) - Pro: Guideline/ADR/scaffold lists are registered once, referenced by every profile that needs them, instead of duplicated per profile
- Con: Requires understanding the registry's resolution mechanism, not just one generator struct, to trace a single profile's full output
Option 3 — Capability-based nested map¶
- Pro:
map[Language]map[ProjectType][]Capability]mirrors the two-axis mental model directly - Con: Nil inner maps are a routine bug source (
m[lang][type]panics or silently returns the zero value depending on which level is missing) - Con: Less readable at the call site than a single flat key lookup
- Con: No benefit over Option 2 for this codebase's two-axis, closed profile set — the added structure buys nothing a flat map with a comparable struct key doesn't already provide
Links¶
internal/templating/capability/{capability.go,registry.go,generator.go}— the core modelinternal/templating/registry.go— composition root (BuildRegistry())internal/templating/gen/main.go— CLAUDE.md table generator, driven by the same registryinternal/acceptance/golden_test.go— characterization test suite that gated every migration step- Migration commit range:
git log --oneline a02b478..5b00937