ADR-017: Templ Adoption for Type-Safe Templates
Date: 2026-04-04
Status
Section titled “Status”Accepted (supersedes ADR-008; amends the rendering sections of ADR-007 and ADR-012)
Context
Section titled “Context”ADR-008 chose Go’s html/template for UI rendering. While functional, this approach had
significant drawbacks at scale:
- No compile-time safety. Template errors (missing fields, wrong types) only surface at runtime, often in production.
- Stringly-typed data passing. Handlers pass
map[string]interface{}— no IDE autocompletion, no refactoring support, no type checking. - No component model. Partials are included via string names (
{{template "name" .}}), making dependency tracking and dead-code detection impossible. - Fragment rendering bug. The old
RenderTemplatealways executed the base layout, even for HTMX partial responses, sending full-page HTML where only a fragment was needed.
Decision
Section titled “Decision”Replace html/template with templ for all server-rendered HTML.
Architecture
Section titled “Architecture”internal/view/├── layouts/base.templ # Base layout (header, footer, dark mode, toasts)├── pages/*.templ # Full-page components (wrap in @layouts.Base)├── partials/*.templ # HTMX fragment components (no layout wrapper)├── render.go # view.Render(w, r, status, component)├── props.go # BaseProps + NewBaseProps helper├── helpers.go # HTMX request/response utilities└── models.go # Shared view models (PatternSection, QuizScore, etc.)Key patterns
Section titled “Key patterns”- Typed props: Every page defines a props struct embedding
view.BaseProps. Handlers construct props with concrete types — nomap[string]interface{}. - Layout composition: Pages call
@layouts.Base(props.BaseProps) { children }. Partials render standalone (no layout wrapper) for correct HTMX fragment responses. - Single render path:
view.Render(w, r, status, component)handles all responses. Handlers choose between full-page and partial components based onview.IsHTMXRequest(r). - Compile-time validation:
templ generateproduces Go code. Type mismatches and missing fields are caught bygo build, not by users in production.
What was removed
Section titled “What was removed”web/templates/directory (all.htmlfiles)webutil.RenderTemplateandRenderTemplateWithErrorsfunctionswebutil.FormErrorstypeview.TemplateFuncs()function map
What was kept
Section titled “What was kept”- HTMX helpers (
SetHXTrigger,SetHXRedirect, etc.) — now ininternal/view/helpers.go webutilcontext helpers (GetUserFromContext,GetUserRepoFromContext)- HTMX + Alpine.js frontend architecture (unchanged)
Consequences
Section titled “Consequences”- All template errors are now compile-time errors.
- IDE support (autocompletion, go-to-definition, rename) works across templates.
- HTMX partial responses are correct — fragments render without the base layout.
templ generateadds a build step, integrated into CI and Taskfile.- Developers must learn templ syntax (minimal — it’s Go with HTML).
Enforcement
Section titled “Enforcement”- Testable consequences:
- TC-1: No file imports
html/template. - TC-2: No
map[string]interface{}insideinternal/view— props are typed structs. - TC-3: Generated templ output compiles.
- TC-1: No file imports
- Checks:
- TC-1 →
adr017-no-html-templateinscripts/adrcheck(status: block, promoted 2026-09-30) - TC-2 →
adr017-typed-view-propsinscripts/adrcheck(status: block, promoted 2026-09-30) - TC-3 →
templ generate+go buildintask ci(status: block, pre-existing)
- TC-1 →
- Not machine-checkable: Single-render-path discipline (
view.Rendereverywhere) and partial/page composition judgment. - Amendment 2026-08-19 (#90): TC-4 — committed
*_templ.goequals whattempl generateproduces from the.templsources (stale generated files with no source are flagged too) →task check:generated(scripts/checkgenerated) intask ci(status: block since 2026-09-30 via-mode=blockinTaskfile.yml). Closes the ADR-033 TODO. - Graduation log:
- 2026-09-30 —
adr017-no-html-templateandadr017-typed-view-propspromoted warn → block (ADR-033 §4): clean since 2026-07-12 (80 days, zero false positives; the rule asks for 7).checks/enforcement.config.jsoncarriesgraduated: 2026-09-30. Demotion back to warn is allowed with the same trail. - 2026-09-30 —
task check:generatedpromoted warn → block (-mode=blockinTaskfile.yml, ADR-033 §4): clean since 2026-08-19 (42 days). It caught the real templ-CLI drift class the Docker builder had (CHANGELOG 0.9.0) before it was wired, which is the other promotion trigger.
- 2026-09-30 —