Lesson 07 / 12
Template and Scaffold Generation
How many files, lines, and decisions it takes to start a new service by hand, and how many of those a scaffold removes. Five services are generated as real files and compared line by line against a hand-written service, then the number of services left behind once the template is updated, and what catching that drift costs, is measured.
Published Updated
In this lesson
- Measures the cost of starting a new service with three numbers: files, lines, and decisions
- Compares the generated scaffold against a hand-written service line by line and turns the gap into a number
- Shows why generated code falls behind once the template is updated, and the cost of the two ways to catch it
Contents
The previous lesson measured the path that makes an environment exist the moment it is requested: waiting dropped to ten hours, and idle environment-days fell by nearly half. The environment is now ready, but what goes inside it still starts from an empty directory. When a sixth service needs to be added to the measurement network, the team makes every decision over again, from log format to health endpoint, from pipeline steps to the metrics that will be published — and the five teams make these decisions without knowing what the others chose.
Scaffold generation is the flow that generates a new service’s starting files from a template repository. The template’s own cost — the number of variables, conditional branches, drift between copies — was measured in M22/K03’s packaging and templating lesson and is not repeated here. This lesson measures two things: what goes unwritten at start time, and what the template leaves behind after generation.
The measurement runs on the regional measurement network: fictional software that collects water-meter readings, validates them, and opens field work orders. The files in this lesson are actually written to disk and counted by reading them back; the file formats carry no product name — they are our own small format, defined within the lesson.
PC7. A service added to the measurement network starts with six files: service definition, pipeline definition, metric definition, log definition, smoke test, and documentation. PC8. The template repository asks three questions in version 1: name, owner, port. PC9. The meter-reading team hand-wrote its own service before the scaffold existed; those files stand as-is in the model, and the comparison is done against them. PC10. Generation actually runs; the files and lines counted are read back from disk. PC11. The local edits made after generation are written in advance, with no randomness. PC12. Template version 2 brings three changes and adds one new question. Generated files are not tied to the template; they do not change on their own when the template changes.
Template Repository and Three Questions
// template.mjs — template repository (version 1), five services' answers, one service written by hand. Model. export const VERSION = 1; export const TEMPLATE = { "service.def": `service {{name}} owner {{owner}} port {{port}} health-endpoint /health log-format line-json log-level summary`, "pipeline.def": `pipeline {{name}} step build step unit-test step package step deploy-staging step smoke-test step deploy-production`, "metric.def": `metric {{name}} counter processed_record counter failed_record gauge request_duration_ms`, "log.def": `log {{name}} field time field level field request-id`, "smoke.def": `smoke {{name}} call /health expect 200 call /metric expect 200`, "DOC.md": `# {{name}} owner {{owner}}, port {{port}}`, }; export const ANSWER = [ // version 1 asks three questions { name: "reading-collector", owner: "meter-reading", port: 8080 }, { name: "validation-flow", owner: "validation", port: 8081 }, { name: "billing-calculator", owner: "billing", port: 8082 }, { name: "field-order", owner: "field", port: 8083 }, { name: "report-compiler", owner: "reporting", port: 8084 }]; export const generate = (s, c) => s.replace(/\{\{([\w-]+)\}\}/g, (_, a) => String(c[a] ?? `<missing:${a}>`)); export const lines = (m) => m.split("\n").filter((s) => s.trim()).length; export const MANUAL = { // the meter-reading team hand-wrote this before the scaffold existed "service.def": `service reading-collector owner meter-reading port 8080 health-endpoint /status log-format plain-text`, "pipeline.def": `pipeline reading-collector step build step unit-test step package step manual-approval step deploy-production`, "metric.def": `metric reading-collector counter record_count gauge request_duration_ms`, "log.def": `log reading-collector field time field level`, "DOC.md": `# reading-collector`, };
Most of the values inside the template are never asked as questions. The health endpoint’s path, the log format, the order of the pipeline’s six steps, and the names of the three metrics to be published are fixed in the template; only three things are asked of the team. This distinction is the very thing the lesson measures: every field that is not asked is a decision the team did not make — and at the same time, a decision that team can no longer see.
Generated Versus Hand-Written
// generate.mjs — five services are generated as real files and compared against the hand-written one. import { mkdirSync, writeFileSync, readFileSync, readdirSync } from "node:fs"; import { TEMPLATE, ANSWER, MANUAL, VERSION, generate, lines } from "./template.mjs"; const FILES = Object.keys(TEMPLATE), start = process.hrtime.bigint(); for (const c of ANSWER) { mkdirSync(`services/${c.name}`, { recursive: true }); for (const d of FILES) writeFileSync(`services/${c.name}/${d}`, `# generator ${VERSION}\n${generate(TEMPLATE[d], c)}\n`); } const elapsed = Number(process.hrtime.bigint() - start) / 1e6; const onDisk = readdirSync("services").flatMap((s) => // was it really written readdirSync(`services/${s}`).map((d) => `services/${s}/${d}`)); const diskLines = onDisk.reduce((a, y) => a + lines(readFileSync(y, "utf8")), 0); const fields = Object.values(TEMPLATE).join("\n").split("\n").filter((s) => s.startsWith(" ")).length; const variables = new Set(Object.values(TEMPLATE).join("\n").match(/\{\{([\w-]+)\}\}/g)).size; const bandMax = FILES.length * ANSWER.length * 20; // the expected upper bound derives from the input console.log(`template ${FILES.length} files, ${lines(Object.values(TEMPLATE).join("\n"))} lines,` + ` ${fields} fields, ${variables} variables`); console.log(`manual bootstrap : per service ${FILES.length} files, ` + `${lines(Object.values(TEMPLATE).join("\n"))} lines, ${fields} decisions` + ` for five services ${fields * ANSWER.length} decisions`); console.log(`with scaffold : per service 0 files, 0 lines, ${variables} answers` + ` for five services ${variables * ANSWER.length} answers`); console.log(`written to disk ${onDisk.length} files, ${diskLines} lines` + ` generation time in the ${0}-${bandMax} ms band: ${elapsed <= bandMax ? "yes" : "no"}`); const key = (s) => s.trim().split(" ")[0]; // generated versus hand-written const unique = (l, k) => l.filter((x) => key(x) === k).length === 1; let totalShared = 0, totalDifferent = 0, totalMissing = 0, totalExtra = 0; console.log(`\nhand-written reading-collector ${Object.keys(MANUAL).length} files ` + `${lines(Object.values(MANUAL).join("\n"))} lines | generated ${FILES.length} files ` + `${lines(Object.values(TEMPLATE).join("\n"))} lines`); for (const d of FILES) { const g = generate(TEMPLATE[d], ANSWER[0]).split("\n").filter((s) => s.trim()); const e = (MANUAL[d] ?? "").split("\n").filter((s) => s.trim()); const diffs = []; let s = 0, f = 0, m = 0; for (const l of g) { if (e.some((x) => x.trim() === l.trim())) s++; else if (unique(g, key(l)) && unique(e, key(l))) { f++; diffs.push(`${l.trim()} -> ${e.find((x) => key(x) === key(l)).trim()}`); } else m++; } const x = e.filter((v) => !g.some((l) => l.trim() === v.trim()) && !diffs.some((p) => key(p) === key(v))).length; [totalShared, totalDifferent, totalMissing, totalExtra] = [totalShared + s, totalDifferent + f, totalMissing + m, totalExtra + x]; console.log(` ${d.padEnd(14)} shared ${s} different ${f} missing ${m} extra ${x}` + `${MANUAL[d] === undefined ? " (file never written)" : ""}` + `${diffs.length ? " [" + diffs.join("; ") + "]" : ""}`); } console.log(`total: shared ${totalShared} different value ${totalDifferent} missing ${totalMissing} extra ${totalExtra}`);
template 6 files, 26 lines, 19 fields, 3 variables manual bootstrap : per service 6 files, 26 lines, 19 decisions for five services 95 decisions with scaffold : per service 0 files, 0 lines, 3 answers for five services 15 answers written to disk 30 files, 160 lines generation time in the 0-600 ms band: yes hand-written reading-collector 5 files 18 lines | generated 6 files 26 lines service.def shared 3 different 2 missing 1 extra 0 [health-endpoint /health -> health-endpoint /status; log-format line-json -> log-format plain-text] pipeline.def shared 5 different 0 missing 2 extra 1 metric.def shared 2 different 0 missing 2 extra 1 log.def shared 3 different 0 missing 1 extra 0 smoke.def shared 0 different 0 missing 3 extra 0 (file never written) DOC.md shared 1 different 0 missing 1 extra 0 total: shared 14 different value 2 missing 10 extra 2
The bootstrap cost is in three numbers, and not all three move the same way. The file and line counts are the visible gain: six files and twenty-six lines per service are no longer written, dropping a hundred thirty lines for five services. The real item is the third: nineteen decisions collapse into three answers. Fifteen answers are given for five services instead of ninety-five decisions; the remaining eighty decisions were made once in the template and are never asked again.
The comparison against the hand-written service shows what those eighty decisions mean. The meter-reading
team’s own service is eighteen lines and overlaps with the generated twenty-six lines in only fourteen
lines. Two fields have different values — the health endpoint is /status instead of /health, the log
format is plain text instead of line-json — and these two differences produce no error, they only keep
shared tooling from recognizing that service. Ten lines are missing: two pipeline steps, two counters in
the metric definition, the request-id field in the log, the entire smoke-test file, one line in the
service definition and one line in the doc file. Two lines are extra and have no counterpart in the
template: the manual-approval step in the pipeline and a counter written under its own name.
For this team to move onto the golden path it needs to fix twelve lines and add one file; the two extra lines have no place in the template, so it will either give them up or stay outside. The cost of staying outside is twelve lines of fixing and giving up two habits, per service.
What Happens When the Template Is Updated
// drift.mjs — the template is updated after generation; services left behind are counted from disk. import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { TEMPLATE, ANSWER, generate, lines } from "./template.mjs"; const LOCAL = { // local edits made after generation, written in advance "reading-collector": { "pipeline.def": [" step schema-check", " step load-test"], "metric.def": [" counter skipped_record"] }, "validation-flow": { "log.def": [" field source-meter"] }, "billing-calculator": { "pipeline.def": [" step reconciliation"], "metric.def": [" gauge invoice_amount"], "smoke.def": [" call /reconciliation expect 200"] }, "field-order": {}, "report-compiler": { "smoke.def": [" call /report expect 200"], "DOC.md": ["nightly run note"] } }; let local = 0; for (const [s, d] of Object.entries(LOCAL)) for (const [file, extraLines] of Object.entries(d)) { writeFileSync(`services/${s}/${file}`, readFileSync(`services/${s}/${file}`, "utf8") + extraLines.join("\n") + "\n"); local += extraLines.length; } const p = `services/validation-flow/service.def`; // the validation team saw the announcement and patched by hand writeFileSync(p, readFileSync(p, "utf8").replace("health-endpoint /health", "health-endpoint /status/health\n data-class measurement")); writeFileSync("services/validation-flow/pipeline.def", readFileSync("services/validation-flow/pipeline.def", "utf8") + " step signing\n"); const TEMPLATE2 = { ...TEMPLATE, "service.def": TEMPLATE["service.def"] .replace("health-endpoint /health", "health-endpoint /status/health\n data-class {{data-class}}"), "pipeline.def": TEMPLATE["pipeline.def"] + "\n step signing" }; const RULES = [["service.def", "health-endpoint /status/health"], ["service.def", "data-class "], ["pipeline.def", " step signing"]]; const marker = ANSWER.filter((c) => // first scan: the generator-version line readFileSync(`services/${c.name}/service.def`, "utf8").startsWith("# generator 1")); let missingLines = 0, filesRead = 0; const content = ANSWER.filter((c) => { const missing = RULES.filter(([d, k]) => !readFileSync(`services/${c.name}/${d}`, "utf8").includes(k)); filesRead += new Set(RULES.map(([d]) => d)).size; missingLines += missing.length; return missing.length > 0; }); mkdirSync("trial", { recursive: true }); // a regeneration attempt with no answers let gaps = 0; for (const c of ANSWER) { const m = generate(TEMPLATE2["service.def"], c); writeFileSync(`trial/${c.name}.def`, m); gaps += (m.match(/<missing:/g) ?? []).length; } const newAnswers = ANSWER.filter((c) => c["data-class"] === undefined).length; console.log(`local edits ${local} lines, ${Object.values(LOCAL).filter((d) => Object.keys(d).length).length}/${ANSWER.length} services`); console.log(`template version 2: ${RULES.length} changes, ${lines(TEMPLATE2["service.def"] + "\n" + TEMPLATE2["pipeline.def"]) - lines(TEMPLATE["service.def"] + "\n" + TEMPLATE["pipeline.def"])}` + ` new lines, 1 new question; none of the generated services change on their own`); console.log(`marker scan : behind ${marker.length}/${ANSWER.length} services,` + ` files read ${ANSWER.length}, false positives ${marker.length - content.length}`); console.log(`content scan : behind ${content.length}/${ANSWER.length} services,` + ` missing lines ${missingLines}, files read ${filesRead}` + ` (${content.map((c) => c.name).join(", ")})`); console.log(`regeneration : ${ANSWER.length * Object.keys(TEMPLATE).length} files rewritten,` + ` local lines lost ${local}, new answers to collect first ${newAnswers}` + `; gaps in the answerless trial ${gaps}`); console.log(`manual patch : ${missingLines} touches, local lines lost 0,` + ` the same work is required again in the next version`);
local edits 9 lines, 4/5 services template version 2: 3 changes, 2 new lines, 1 new question; none of the generated services change on their own marker scan : behind 5/5 services, files read 5, false positives 1 content scan : behind 4/5 services, missing lines 12, files read 10 (reading-collector, billing-calculator, field-order, report-compiler) regeneration : 30 files rewritten, local lines lost 9, new answers to collect first 5; gaps in the answerless trial 5 manual patch : 12 touches, local lines lost 0, the same work is required again in the next version
Template version 2 changes the health endpoint, makes the data-class field mandatory, and adds a signing step to the pipeline. None of the five services on disk become aware of this, because the generated file is not tied to the generator. The template was read once, the output was written, and the link broke right there. This is the exact opposite kind of leak from the previous lesson’s environment abstraction: there, the hidden layer leaked back; here, the hiding never lasts at all.
There are two ways to find who is left behind, and they give different numbers. Reading the generator version finishes with five files read and shows all five of the five services as behind; but the validation team saw the announcement and made the three changes by hand, so this scan produces one false positive. Checking the content requires reading ten files, gives four services as left behind, and names the twelve missing lines in total. The correct result costs twice the reading.
Catching it also has two ways. Regeneration rewrites all thirty files and finishes in one move, but it
has two costs: nine local lines are lost — pipeline steps, metrics, smoke-test calls — and because the
new question’s answer is not recorded anywhere, five new answers have to be collected for five services.
Running the trial with no answers shows this directly: five files are left with a <missing:data-class>
gap. The manual patch, by contrast, finishes with twelve touches and loses no local lines, but because
the link to the template still is not made, the same work is required again from scratch when version 3
arrives.
The one-sentence balance of gain and cost is this: the scaffold cut ninety-five decisions at start time down to fifteen answers, and took over zero decisions after start time. The four services that made local edits stepped outside the template by exactly as many lines as they edited locally; field-order, the one service that made no edits at all, loses nothing from regeneration.
Summary
- Starting a new service by hand means 6 files, 26 lines, and 19 decisions per service; 95 decisions come out for five services, and the scaffold gives 15 answers instead.
- Five services were actually generated: 30 files and 160 lines were written to disk, and generation time stayed within the band derived from the input.
- The hand-written service overlapped the generated one in only 14 of 26 lines: 2 fields with different values, 10 lines missing, 2 lines extra; the cost of moving onto the golden path is 12 lines of fixing and giving up 2 habits.
- Once the template moved to version 2, none of the generated services changed on their own; the scan based on the generator version said 5/5 behind and produced 1 false positive, while the content scan found 4/5 behind and 12 missing lines, at the cost of reading twice as many files.
- Both ways of catching it had a cost: regeneration deleted 9 local lines and required 5 new answers, the manual patch finished with 12 touches but, because it made no link to the template, required the same work again in the next version.
Next Step
Five services were generated, their files sit on disk, and each one’s definition has an owner line. That line was correct the day it was written; nobody knows whether it is correct today. When the billing calculator goes down at midnight, the person answering the page first has to find out: who owns this service, what does it depend on, and who depends on it. The next lesson builds an ownership and dependency graph from catalog records; orphaned services, orphan dependencies pointing to a service that does not exist, and the time to reach the right person are counted both with and without the catalog. The record’s own cost is also measured: how many records are updated by hand, and how many records disagree with reality.
To keep your progress and take notes, Log in
My notes
Log in to take notes.