Architecture Decision Record
ADR-065: Scaffolding templates derived from the reference app
Status: Accepted (2026-08-02). Revised 2026-08-07: the staged analyzer delta relaxes three rules
rather than one; mmca-module prints seven wire-ups rather than five, and a solution generated by
mmca-app now applies them through its own build/add-module.ps1; the seed measurements and the
guide reference were refreshed against HEAD. Revised 2026-08-18: the seed measurements were re-taken
against MMCA.Helpdesk with the counting method now stated inline (Directory.Packages.props has
since been pruned to 72 lines and 58 pins, and the file and line totals moved with the tree), the
staged analyzer delta and the wire-contract removal were re-anchored, and the seed's fitness map
declares 20 sealed subclasses rather than 19. Revised 2026-08-23: the seed measurements were re-taken
at HEAD (three standalone fitness-test files landed in the architecture-test project since the
previous re-measure, moving the file and line totals), and the generated-app test figure is now
sourced to the seed's own documented run rather than to an undated absolute pair. Revised 2026-08-31:
the wire-contract freeze now ships rather than being removed at stage time, so the generated app
arrives with its own contract frozen and green, and the scaffold hands over one thing rather than
two; mmca-module carries a database-conditioned split (sqlserver / sqlite) in its printed
wire-ups; the seed measurements, the fitness-subclass count (21), the documented seed run (117
tests), and every staging, README, template and smoke anchor were re-taken at HEAD. Revised
2026-09-11: --database offers three engines rather than two (postgresql joins sqlserver and
sqlite, ADR-113), a second generation path (the postgresql-canary CI job) sits beside
template-smoke, and the seed measurements, the fitness-subclass count (24), the documented seed run
(128 tests), and every staging, README, template and CI anchor were re-taken at HEAD.
Context
Build by hand is accurate and complete, and phases 1 through 6
of it are transcription work (common-BUILD-BY-HAND.md:96 through :1238). Its own instruction for
the load-bearing parts is "copy MMCA.Helpdesk/Directory.Build.props" (:170), "copy MMCA.ADC's
.editorconfig verbatim" (:114), "copy the relevant rows from MMCA.ADC/Directory.Packages.props"
(:157). That walkthrough was the whole of
Getting Started when this decision was taken; Getting Started
is now the six-step dotnet new install MMCA.Templates path
(common-GETTING-STARTED.md:10-22) and the by-hand transcription moved to its own guide.
Measured against MMCA.Helpdesk, the deliberately minimal seed, a brand-new app on the framework
starts by hand-creating 12 projects, 133 files, and 10,662 lines before a line of its own
business logic. The method, re-run on 2026-09-11 and stated here so the figures can be reproduced
rather than trusted: every tracked file under Source/ and Tests/ (126 files, 9,524 lines), plus
the seven root build files below (1,138 lines). Those
seven are an 827-line .editorconfig, a 100-line Directory.Packages.props carrying 58 pins,
a 77-line Directory.Build.props, the 74-line local-source swap in Directory.Build.targets,
global.json, nuget.config, and the solution file. Under Source/ and Tests/ sit the module
project set, the vertical slice, the migrations project and its design-time factory, two hosts, the
Aspire AppHost, three .resx pairs, and the architecture map with its fitness subclasses.
Several of those lines are load-bearing and quiet about it. AddApplicationDecorators() must be the
last DI call. The AppHost must WaitFor the SQL server and not the database resource. A missing
AppHost launchSettings.json presents as a hang. A module absent from IArchitectureMap silently
stops being covered by the layering and isolation rules (ADR-015).
The framework is published credential-free on nuget.org (ADR-053), so the distance between "read the guide" and "have a green solution" is now the adoption bottleneck rather than access.
Decision
Ship a dotnet new template pack, MMCA.Templates, containing four templates:
| Short name | Generates |
|---|---|
mmca-app |
the whole solution: build plumbing, one module across five layers, both hosts, the AppHost, migrations, and the test projects |
mmca-module |
a new business module: five layer projects, both test projects, a migrations project |
mmca-command |
one write-side vertical slice (command record + handler) |
mmca-query |
one read-side vertical slice (cacheable query record + handler) |
The template content is the MMCA.Helpdesk reference application itself, staged at pack time.
.template.config/ lives at that repo's root; build/templates/stage.ps1 copies the tree, drops
the files belonging to the seed's own repo rather than to a generated app, and lays a small overlay
on top. No copy of the solution exists anywhere else. Everything else (renaming MMCA.Helpdesk to
the adopter's name, Tickets to their module, Ticket to their aggregate) is dotnet new doing
symbol replacement at instantiation.
The pack is published from MMCA.Helpdesk on a templates-vX.Y.Z tag, under its own nuget.org
trusted-publishing policy.
One thing the scaffold deliberately does not hand over, because a rename or a shape flag invalidates it and no fixed value is correct for every generated name:
- Declaration order, and one constructor shape. An app namespace sorts above
MMCA.Common.*forContoso.Supportand below it forZeta.App.SA1210has no notion of blank-line-separated groups, so no checked-in order survives both.SA1211is the same story one level down, on the identifier-alias file whose two aliases re-sort when--childrenames them.IDE0021is the shape flags rather than the renames: the aggregate's private constructor assigns one property per optional axis, so--no-status --no-description --no-ownertogether leave it with a single statement, which the baseline then requires as an expression body. Staging appends a scoped delta dropping those three tosuggestionin the staged.editorconfig(stage.ps1:1249-1251); the seed's own copy, which is the shared analyzer baseline thatTools/Scripts/compare-analyzer-config.ps1holds identical across the four repos, is untouched, and it declares none of the three among its 215 explicitdotnet_diagnostic.*.severitylines. Every other analyzer stays at error, as the delta's own header says (stage.ps1:1246). The generated README carriesdotnet format analyzers MMCA.Helpdesk.slnx --diagnostics SA1210 SA1211 --severity info(build/templates/overlay/mmca-app/README.md:111), which restores the two ordering rules.IDE0021is not fixable by that command, so the README tells the adopter to fold the constructor by hand and delete the line (README.md:117-120).
The wire-contract freeze ships, guarded rather than removed.
IntegrationEventContractTestsBase compares each event's members as a set rather than a
sequence, so the aggregate's own id moving position is not a difference; the namespace and the event
type name are ordinary symbol substitutions the template already performs everywhere else; and the
one member a shape flag can remove, RequesterUserId, is a comma-separated list element that
stage.ps1's $optionalAxisLines rewrites like any other. The generated app therefore arrives with
its own contract already frozen, under its own names, green on the first test run. Staging asserts
exactly one IntegrationEventContractTests class in the staged fitness map and throws otherwise
(stage.ps1:1309-1320), because a class the staging pass cannot find is a template about to ship an
unfrozen wire contract behind a README that says it is frozen. That README section tells the adopter
what the test guards and how to evolve it: an event added or reshaped on purpose is versioned
(ADR-010) and ExpectedContract updated in the same commit, with the failure printing the live value
to paste (build/templates/overlay/mmca-app/README.md:122-134).
mmca-module additionally prints seven numbered wire-ups dotnet new cannot perform
(templates/mmca-module/.template.config/template.json:261-277): the solution entries for the eight
new projects, the host and architecture-test project references, the identifier-alias
<Compile Include ... Link> block, the five IArchitectureMap lines, the host's
AddErrorResources<> call, the module's own database (the AppHost AddDatabase /
WithSQLServerDataSource pair, the appsettings.json Modules / DataSources / Outbox entries,
and the removal of the now-conflicting top-level SQLServerMigrationsAssembly), and the first EF
migration. That text now opens by telling anyone whose solution came from mmca-app to run
pwsh build/add-module.ps1 instead, which performs all seven; the printed steps are the fallback
for a hand-built solution.
--database carries three choices in both templates, sqlserver (the default), sqlite, and
postgresql (ADR-113), each with its own computed symbol
(MMCA.Helpdesk/.template.config/template.json:250-277, and useSqlite / usePostgreSQL at
templates/mmca-module/.template.config/template.json:179-186). The engine is a configuration-base
choice plus a connection string, so the three shapes are the same application code: a derived
engineName symbol supplies the Pascal spelling that renames the migrations project, the Aspire
hosting integration package id, and the design-time helper call (template.json:278-285). The two
PostgreSQL EF provider ids share no spelling with their SQL Server peers, so staging swaps them
through a sqlserverOrSqlite marker region conditioned on !usePostgreSQL rather than by substring
(build/templates/stage.ps1:142, :162).
The printed instructions come in two manualInstructions entries, split on the solution's
database engine. The sqlite entry is conditioned on useSqlite and sits first, since dotnet new
takes the first entry whose condition holds, and the unconditioned entry is the fallback that serves
both server engines, naming the <server> value each takes (template.json:265-272, the condition
at :267). The two differ by more than spelling: SQLite declares no container
resource in the AppHost, the settings keys are the framework's other engine spelling
(SqliteConnectionString / SqliteMigrationsAssembly), and the first migration stops being optional,
because a SQLite source that names a migrations assembly is migrated at startup rather than created
outright. That split exists because --database reaches mmca-module as well as mmca-app: a
module's EF configurations inherit an engine-specific base and its migrations project references an
engine-specific provider, so a SQL-Server-shaped module dropped into a SQLite app does not compile.
The correctness gate is a template-smoke CI job in MMCA.Helpdesk (.github/workflows/ci.yml:120),
not the seed's own build. The
seed builds in local-source mode against MMCA.Common@main; a generated app builds in package mode
against a released version, and a source-mode build can pass where package-mode Release fails on an
analyzer. The smoke generates three solutions whose names share no substring with the seed
(smoke.ps1:115-134): two module shapes (one --flat --no-status --no-owner with a --title
rename, one fully default) plus one solution shape (--database sqlite --no-aspire, which is also
the only case that drops the AppHost). Each is swept for residual Helpdesk /
Ticket tokens, built package-mode, and tested. Two of the three then add a second module
through that generated app's own build/add-module.ps1, launched in a child pwsh so the run proves
the exit code an adopter would see (smoke.ps1:603-615). The SQLite case proves the engine reaches
the second module and is thin on purpose (smoke.ps1:371-487); the first case proves the wire-ups.
That script performs all seven printed wire-ups plus the frozen-contract append and the first
migration, and the smoke asserts the result on the files rather than on the script's output, wire-up
by wire-up (smoke.ps1:701-809), including the migration (:811-829) and a rerun that must fail at
the preflight before editing anything (:834).
A second generation path sits beside it: the postgresql-canary job (ci.yml:146) runs
build/templates/canary-postgresql.ps1 (ci.yml:191-193) against a postgres:17 service container,
generating with --database postgresql, building and testing package-mode, then scaffolding and
applying the first migration on a real server and asserting the schema it left behind. The smoke
proves the template's SQL Server and SQLite shapes; the canary is the only path that proves a
generated PostgreSQL app against the engine rather than against a compiler. Both jobs sit outside the
required check, which is build-and-test (MMCA.Helpdesk/CLAUDE.md:393-394); the canary also carries
continue-on-error: true (ci.yml:155), so a red canary reports without failing the run.
Rationale
Deriving from the seed rather than maintaining a template tree is the whole design. A hand-maintained copy of a 12-project solution drifts within one release, and drift in a scaffold is invisible until an adopter's first build fails. Because the seed is the template, the app whose CI keeps it green is exactly what adopters receive.
A template pack rather than a workspace script. A PowerShell generator under Tools/ would have
been faster to write and is useless to anyone outside this workspace. The framework is public and
credential-free; dotnet new install MMCA.Templates is the install path an outside adopter expects.
Named MMCA.Templates, not MMCA.Common.Templates. The MMCA.Common.* names carry the ADR-016
lockstep-versioning contract and ship from the MMCA.Common repo under its own trusted-publishing
policy. This package ships from a different repo on a different cadence and pins the framework
version as a --framework-version parameter instead. Keeping it outside that family also leaves the
package count in
FACTS.md unchanged, since its generator
counts only packable projects under MMCA.Common/Source/ (MMCA.Common/FACTS.md:75), so the CI
drift gate is unaffected.
The token sweep is in the gate on purpose. sourceName and the symbol replacements run as
separate passes, so a token that only ever appears nested inside another (Ticket inside Tickets,
Helpdesk inside MMCA.Helpdesk) is precisely where a rename half-applies, and it half-applies
silently: the output still compiles, it just carries someone else's domain vocabulary.
Trade-offs
- One documented one-time fixup in every generated app, above, covering all three relaxed
rules. The alternative to the
SA1210half of the delta was moving every app-localusinginto per-project global usings, which is not rename-stable either once a project's usings span more than one second-level segment, and which costs the seed the didactic value of showing where each type comes from. mmca-modulealone cannot finish the job. Seven edits are beyond it becausedotnet newcannot patch existing files. For a solution generated bymmca-appthat is no longer manual: the overlay shipsbuild/add-module.ps1(build/templates/overlay/mmca-app/build/add-module.ps1, declaredcopyOnlyatMMCA.Helpdesk/.template.config/template.json:345and exempted from the smoke's rename sweeps atsmoke.ps1:144), which runsdotnet new mmca-moduleand then performs all seven, plus an eighth the printed list does not carry (the new module's integration event joinsExpectedContract, so a second module does not turn the adopter's next test run red:build/add-module.ps1:42), plus the first migration, on the same code path CI exercises. The manual path survives only for a solution the template did not generate, where the printed instructions are the fallback and a new module is not usable until a human applies them.- The template lags a framework release by one step.
--framework-versiondefaults to whatever the seed pins, so aMMCA.Commonrelease needs the seed bumped and the pack re-tagged before the default is current. Adopters can pass the flag in the meantime. release-templates.yml's filename is load-bearing. Trusted publishing is keyless OIDC pinned to owner, repository, and workflow filename, with no API-key fallback, so renaming that file breaks publishing silently. Same property as the MMCA.Common release workflow (ADR-053).- The generated app's test count is not pinned anywhere, and shape flags move it. The scaffold
hands over the whole fitness map: all 24 sealed subclasses in
Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.csreach a generated app,IntegrationEventContractTests(at:99) included, and it is frozen on its own event rather than the seed's, so it passes on arrival. The seed's own documented run is 128 tests (MMCA.Helpdesk/CLAUDE.md:185). A generated app that turns off shape axes runs fewer, and no gate pins the figure on either side:smoke.ps1:361,:487and:909all pass--minimum-expected-tests 1, as does the seed's own CI (.github/workflows/ci.yml:104). The cost of shipping the freeze is that staging has to keep finding the class:stage.ps1:1318-1319throws when the match count is not exactly one, so renaming or reshaping that subclass fails the pack rather than shipping an unfrozen contract behind a README that promises a frozen one.