Engine study II — Internal architecture & technology selection¶
Date: August 2026 · Version: 1.0 · Status: study for ADR-0006 Scope: the shapes (language-independent internal architecture) and the technologies (three candidate stacks with concrete libraries). Feeds ADR-0006.
Part 1 — Shapes: internal architecture (language-independent)¶
These decisions hold regardless of the implementation language. They come from lessons in the state of the art (study I §3) and from Templetry's own requirements.
1.1 Module decomposition¶
templetry-engine
├── manifest/ # parse + validate template.yml, derive casings
├── source/ # obtain the template as a FileSet (local dir first; tarball later)
├── planner/ # (manifest, inputs, FileSet) → Plan [pure]
├── ops/ # the operation types: data, not behavior
├── render/ # execute a Plan against a virtual file tree [pure]
├── verify/ # run the manifest's verify command in Docker [effectful]
└── cli/ # thin shell over the library
1.2 The five shape decisions¶
-
Virtual file tree (lesson from Nx generators). Every operation executes against an in-memory tree; disk is touched only at the very end. This buys three things at once: dry-run for free (render without the final write), trivial golden tests (compare trees, no temp dirs), and atomicity (no half-rendered output on failure).
-
The Plan is data. The planner produces a serializable list of operations (
CopyFile,RenamePath,FilterDirectives,ApplyPatch,WriteAnswers). Being data, it can be printed (the dry-run UX from study I §7.4), diffed in tests, and logged. The renderer is a dumb executor. -
Pure core, effectful edges.
planner/andrender/are pure functions over values — no filesystem, no network, no clock. Everything effectful (fetching, disk I/O, Docker) lives at the edges. This is what makes NFR4 (determinism) testable rather than aspirational. -
Two phases: plan, then apply. Terraform's model, already implicit in study I's pipeline (fetch → plan → render → verify → deliver). The CLI exposes both:
templetry plan(show me) andtempletry render(do it). -
Structured errors with position. The directive scanner reports
file:lineon every failure (unclosedtpl:if, unknown feature key). Errors are values with location, not strings — the web app will need to surface them in forms later.
1.3 Determinism rules (NFR4)¶
- Stable ordering: files processed in sorted path order, always.
- No timestamps, no randomness, no locale-dependent behavior in the core.
- Output normalized to LF (study I §6);
.gitattributesin templates. - Same (template commit, inputs) → byte-identical output tree. This is the golden-test contract.
1.4 Testing shape¶
- Golden tests as the backbone: fixture template + fixed inputs → expected output tree, versioned in the repo. Comparing virtual trees keeps them fast and diff-friendly.
- Property tests for casing derivation (round-trips, idempotence, collisions between casings).
- The directive scanner is the highest-risk unit (per-language comment styles, nesting, unclosed blocks) — it gets the densest test suite.
- Fixture templates are miniatures, not real projects: 5–10 files each, exercising one mechanism at a time (rename-only, directives-only, patches-only, all-combined).
Part 2 — Technologies: the three candidate stacks¶
What the engine actually does, 80% of the time: parse YAML, walk file trees, match globs, scan comment lines, replace strings with casing awareness, apply JSON patches, unpack tarballs, shell out to Docker. The comparison below maps each need to concrete, maintained libraries.
2.1 Kotlin (JVM + GraalVM native image)¶
| Need | Library |
|---|---|
| CLI framework | Clikt |
| YAML | kaml (kotlinx.serialization) |
| JSON Patch RFC 6902 | zjsonpatch |
| Casing derivation | KaseChange |
| Globs | java.nio PathMatcher (or small custom matcher) |
| Tarballs | Apache Commons Compress |
| Docker | shell out to docker run (no SDK needed) |
| Manifest schema | kotlinx.serialization + hand validation, or everit json-schema |
| Testing | Kotest (data-driven; golden via file snapshots) |
| Distribution | GraalVM native image — mature in 2026, ~50–100 ms startups; JVM fallback |
For: the author's strongest language (velocity, joy, long-term maintenance). Coherent with the product's flagship templates (Android/KMP). The engine embeds directly into a future Ktor backend — same artifact serves CLI and web. Against: the library ecosystem for this niche is Java-flavored and adequate rather than tailor-made. GraalVM adds a build-time toolchain (MSVC on Windows) and occasional reflection config — mitigated here because kotlinx.serialization is compile-time.
2.2 TypeScript (Bun runtime)¶
| Need | Library |
|---|---|
| CLI framework | citty or clipanion |
| YAML | yaml |
| JSON Patch RFC 6902 | fast-json-patch |
| Casing derivation | change-case (the reference implementation of this exact problem) |
| Globs | micromatch / Bun.Glob |
| Tarballs | tar |
| Docker | Bun.spawn / execa |
| Manifest schema | zod — one definition yields runtime validation + static types + JSON Schema for the web form |
| Testing | bun test — snapshot testing IS golden testing, native |
| Distribution | bun build --compile — production-ready in 2026, cross-compiles, ~60–90 MB binaries (Claude Code ships this way) |
For: the ecosystem is tailor-made for this niche — string/path/glob processing is JavaScript-tooling's home turf. Snapshot testing gives golden tests natively. The zod point is strategic: the manifest is Templetry's public API, and one zod schema becomes the single source of truth for engine validation and the web app's dynamic forms (phase 3), end-to-end typed. One language across the whole product. Against: second language for the author (good but not native). Larger binaries (irrelevant in practice). Bun-on-Windows is solid now but younger than JVM/Go.
2.3 Go¶
| Need | Library |
|---|---|
| CLI framework | cobra |
| YAML | goccy/go-yaml |
| JSON Patch RFC 6902 | evanphx/json-patch |
| Casing derivation | iancoleman/strcase (fewer variants than change-case) |
| Globs | bmatcuk/doublestar |
| Tarballs | stdlib archive/tar (best-in-class) |
| Docker | os/exec |
| Manifest schema | santhosh-tekuri/jsonschema |
| Testing | stdlib + testdata/ golden files (idiomatic Go pattern) |
| Distribution | unbeatable: small static binary, trivial cross-compile |
For: the strongest stdlib for tar/files, the leanest binaries, golden files as an idiomatic native pattern (the Go clones in study I §3 exist for a reason). Against: the author's weakest language — highest friction for a solo project whose success depends on sustained personal velocity. Zero code sharing with the web phase.
Part 3 — The 2026 finding: distribution no longer discriminates¶
The ADR-0006 table gave Go a distribution edge. Verified in August 2026, that edge has largely evaporated:
- GraalVM native image is production-mature for CLI tools: millisecond startups, no JVM at runtime (overview, reference).
bun build --compileis production-ready, cross-compiles, and is how Claude Code ships to millions of developers (docs, write-up).
All three stacks deliver a standalone binary. The decision therefore hinges on three factors only:
- Author affinity — solo-project velocity and joy (favors Kotlin).
- Niche ecosystem fit — libraries for string/glob/casing/snapshot work (favors TypeScript).
- Reuse with phase 3 — sharing the manifest schema and engine with the web app (favors TypeScript strongly; Kotlin partially via Ktor).
Part 4 — Analysis and proposal¶
Go exits the race: its historical advantage was distribution, now neutralized, and it scores lowest on affinity and reuse. The real choice is Kotlin vs TypeScript:
- Choose Kotlin to optimize for the author: mastery, enjoyment, and a coherent identity with the Android/KMP templates that motivated the project.
- Choose TypeScript + Bun to optimize for the product: tailor-made libraries, golden tests as native snapshots, and — decisive — the zod manifest schema as a single source of truth reused by the phase-3 web forms. The manifest is the public API (ADR-0002); having exactly one definition of it across the entire product removes the most dangerous class of drift.
Proposal: TypeScript on Bun, with the engine as a pure library (@templetry/engine) plus a thin CLI (templetry), compiled with bun build --compile for distribution. Kotlin remains a fully defensible alternative if the affinity factor is weighted first — the shapes in Part 1 are identical either way, so no architectural work is lost if the choice is ever revisited.
Part 5 — Runtime efficiency deep-dive (2026-08 addendum)¶
Requested follow-up: with the project scoped to engine only for now (web reuse off the table), which language is the most efficient? Efficiency is meaningless without a workload, so first the workload, then the numbers.
5.1 The engine's actual workload¶
One render = one short-lived CLI process that: parses one small YAML file, walks 100–3,000 mostly-small text files (1–30 MB total), matches globs over paths, scans lines for directives, performs casing-aware string replacements, applies a handful of JSON patches, and writes the tree once. CPU work is O(total bytes) string processing; I/O is small-file reads/writes. No long-lived state, no concurrency pressure, no network in the core.
Order of magnitude: 30 MB of text at the 100 MB/s–1 GB/s string-processing rates of any compiled or JIT'd language = 30–300 ms of compute. The whole render sits far below one second in every candidate stack. What actually differs measurably between stacks is process startup, memory footprint, and parallel file processing.
5.2 The numbers¶
| Metric (one-shot CLI) | Go | Kotlin (GraalVM native) | TypeScript (Bun) | Kotlin (JVM) |
|---|---|---|---|---|
| Process startup | ~2–10 ms | ~50–100 ms | ~10–30 ms | ~300–1,000 ms |
| Memory footprint | ~5–20 MB | ~20–60 MB | ~50–150 MB | ~100–300 MB |
| String/file throughput | excellent (compiled) | good (AOT, slightly below JIT peak) | very good (JSC is strong on strings) | excellent once warm — but a one-shot CLI never warms up |
| Trivial parallelism (per-file) | ★★★ goroutines | ★★ coroutines | ★ single-threaded (workers are awkward) | ★★ coroutines |
| Binary size | ~5–15 MB | ~20–50 MB | ~60–90 MB | needs JVM |
Sources: WWT cross-language benchmark (Go clearly wins memory; Bun highest of the compared set), Bun vs Go server micro-benchmarks (throughput comparable, grain of salt), GraalVM native image performance profile (millisecond startups, memory competitive with Go's class), runtime guide 2026 (Bun ~3× faster boot than Node; instant-feeling CLIs).
5.3 Verdict on the efficiency criterion¶
Ranking: 1. Go · 2. Kotlin/GraalVM · 3. Bun (close third) · 4. Kotlin/JVM. Go wins every efficiency column and re-enters the race it had exited in Part 4 — it left because its distribution edge was neutralized, but on runtime efficiency it was never beaten.
The honest engineering caveat, stated plainly: the deltas are tens of milliseconds and tens of megabytes, once per render, against a 30-second success metric (study I §1) — and the pipeline's real costs (network fetch: seconds; Docker verify: minutes) dwarf all of them by 2–4 orders of magnitude. Efficiency is a real ranking with a clear winner, and it is also the criterion least capable of making a user-perceivable difference in this particular tool.
5.4 The decision matrix, final form¶
With web reuse temporarily out of scope, each finalist owns exactly one axis:
| Axis | Winner |
|---|---|
| Machine efficiency | Go |
| Author velocity & affinity | Kotlin |
| Niche library fit (+ future web reuse when phase 3 arrives) | TypeScript |
ADR-0006 is now a question of which axis the project's owner weights first — all three are legitimate, none is wrong, and the Part 1 shapes transfer intact to any of them.