Skip to content

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

  1. Identity-based switch dispatch (status quo)
  2. Capability-based flat map[ProfileKey][]Capability model ← chosen
  3. 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 a switch case 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 Registry the generator itself runs against (internal/templating/gen, wired via go 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 own specs/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 Capability string through the registry rather than reading a single generator struct top to bottom — mitigated by Registry.CapabilitiesFor being 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 update behavior 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 case in every dispatching switch
  • 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
  • internal/templating/capability/{capability.go,registry.go,generator.go} — the core model
  • internal/templating/registry.go — composition root (BuildRegistry())
  • internal/templating/gen/main.go — CLAUDE.md table generator, driven by the same registry
  • internal/acceptance/golden_test.go — characterization test suite that gated every migration step
  • Migration commit range: git log --oneline a02b478..5b00937