---
title: 'Template and Scaffold Generation'
source: 'https://academia.sh/en/courses/platform-engineering/template-and-scaffold-generation'
course: 'Platform Engineering'
language: en
updated: '2026-09-13T05:08:29+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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

```js
// 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

```js
// 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

```js
// 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.
