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. Revised
2026-09-19: the seed's fitness map declares 25 sealed subclasses rather than 24
(FeatureFlagLifecycleTests joined it), and the seed no longer documents a run count of its own, so
the generated-app test figure is stated only as unpinned on both sides. Revised 2026-10-01: the
frozen wire contract is keyed by each event's [EventName] value and its failure points at
MMCA_CONTRACT_SNAPSHOT_OUT rather than printing the live value. Revised 2026-10-06: the seed
measurements were re-taken at MMCA.Helpdesk f6ef4b0, and Directory.Build.targets is now a 7-line
import of the local-source swap MMCA.Common ships rather than the swap itself. Revised 2026-10-21:
the seed measurements were re-taken at MMCA.Helpdesk f10bc45 (139 files, 12,157 lines, 57 pins),
the --database text now says only sqlite and postgresql carry a computed symbol with
sqlserver the fallback, and the staging, README, smoke and FACTS anchors were re-pointed.
Revised 2026-10-26: the overlay README now names MMCA_CONTRACT_SNAPSHOT_OUT rather than saying
the failure prints the live value, and the seed measurements were re-taken at MMCA.Helpdesk
5fd74de (143 files, 12,571 lines, 57 pins) with the staging guard and README anchors re-pointed; the
mmca-command row now lists the validator and the cache-invalidation test builder the slice ships.
Revised 2026-10-28: the seed measurements were re-taken at MMCA.Helpdesk c41fc93 (148 files,
12,597 lines, 57 pins) and the required-check anchor was re-pointed.
Context
Build by hand is accurate and complete, and phases 1 through 6
of it are transcription work (common-BUILD-BY-HAND.md:98 through :1299). Its own instruction for
the load-bearing parts is "copy MMCA.Helpdesk/Directory.Build.props" (:171), "copy MMCA.ADC's
.editorconfig verbatim" (:116), "copy the relevant rows from MMCA.ADC/Directory.Packages.props"
(:159). 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, 148 files, and 12,597 lines before a line of its own
business logic. The method, re-run on 2026-10-28 at MMCA.Helpdesk c41fc93 and stated here so the
figures can be reproduced rather than trusted: every tracked file under Source/ and Tests/ (141
files: 137 text files carrying 11,523 lines, plus four PNG icons under the web host's
wwwroot/icons/ that count as files but not as lines), plus the seven root build files below (1,074 lines). Those
seven are an 827-line .editorconfig, a 99-line Directory.Packages.props carrying 57 pins,
a 77-line Directory.Build.props, a 7-line Directory.Build.targets that imports the local-source
swap from MMCA.Common's build/LocalSource/MMCA.Common.LocalSource.targets
(Directory.Build.targets:6), a 5-line global.json, a 26-line nuget.config, and the 33-line
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, validator, handler), plus its cache-invalidation test builder written into the module's Application.Tests project |
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:1261-1263); 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:1258). The generated README carriesdotnet format analyzers MMCA.Helpdesk.slnx --diagnostics SA1210 SA1211 --severity info(build/templates/overlay/mmca-app/README.md:115), 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:121-124).
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; each line is keyed by the
event's [EventName] value rather than its CLR type name (IntegrationEventContractTestsBase.cs:29-31),
so the seed's literal is Tickets.TicketOpened.v1 { RequesterUserId:Int32, TicketId:Int32 }
(ArchitectureTests.cs:107, from TicketOpenedIntegrationEvent.cs:16), carries no namespace, and its
module and aggregate names 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:1322-1333), 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
(build/templates/overlay/mmca-app/README.md:126-146). The failure lists each difference (missing or
new event, missing, extra or retyped member) and names MMCA_CONTRACT_SNAPSHOT_OUT, which, set to a
file path, writes the live contract as paste-ready literals on the next run
(IntegrationEventContractTestsBase.cs:27, :40-52), and the README gives the adopter that same
variable with a paste-ready command (README.md:137-144).
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, two host edits (the module
assembly joins the list handed to ModuleLoader.DiscoverAndRegister, then the
AddErrorResources<> call; step 5 in both entries, template.json:268, :271), 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). Two of them have a computed symbol, useSqlite and usePostgreSQL
(MMCA.Helpdesk/.template.config/template.json:250-277, the symbols at :270 and :274, and
their peers at templates/mmca-module/.template.config/template.json:179-186); sqlserver has none
and is the shape that applies when neither is set. 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 package ids (the EF provider Npgsql.EntityFrameworkCore.PostgreSQL and the readiness
check AspNetCore.HealthChecks.NpgSql) 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:143, :163).
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-810), including the migration (:812-830) and a rerun that must fail at
the preflight before editing anything (:835).
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/AGENTS.md:92); 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:20), 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 25 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 documents no run count of its own either. A generated app that turns off shape axes runs fewer than the seed, and no gate pins the figure on either side:smoke.ps1:361,:487and:910all 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:1331-1332throws 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.
Revision (2026-10-01)
No decision or rationale changed. The frozen wire contract is now keyed by each event's [EventName]
value rather than its CLR type name (IntegrationEventContractTestsBase.cs:29-31), so the seed's
literal reads Tickets.TicketOpened.v1 { ... } (ArchitectureTests.cs:107) and holds no namespace;
it still renames through the module and aggregate symbols, and the Decision text now says so. The
contract failure lists per-event differences and points at MMCA_CONTRACT_SNAPSHOT_OUT for
paste-ready literals (IntegrationEventContractTestsBase.cs:40-52) rather than printing the live
value itself; the overlay README (README.md:134) and the staging comment in stage.ps1 still carry
the older wording. The mmca-module host wire-up names both of its edits, the ModuleLoader.DiscoverAndRegister
entry and AddErrorResources<> (template.json:268, :271; build/add-module.ps1:41), and the
PostgreSQL pair swapped through the sqlserverOrSqlite region is one EF provider and one health-check
package (stage.ps1:143-147). The seed measurements were re-taken at MMCA.Helpdesk b9c97e9
(136 files, 11,532 lines; 129 files, 10,394 lines under Source/ and Tests/), and the
build-by-hand, staging, FACTS and required-check citations were re-anchored (the required check now
reads from MMCA.Helpdesk/AGENTS.md:104, since that repo's CLAUDE.md imports AGENTS.md).
Revision (2026-10-06)
No decision or rationale changed.
- The seed measurements were re-taken at MMCA.Helpdesk
f6ef4b0with the same method: 130 files and 10,500 lines underSource/andTests/, plus 1,071 lines across the seven root build files, for 137 files and 11,571 lines. The method still reproduces the 2026-10-01 pair atb9c97e9; only the tree moved (one new test file,TicketsExportScopeTests.cs, and growth elsewhere). Directory.Build.targetsis no longer the 74-line local-source swap: it is 7 lines and importsbuild/LocalSource/MMCA.Common.LocalSource.targetsfrom the sibling MMCA.Common checkout (Directory.Build.targets:6). The 2026-10-01 root-file total (1,138 lines) predated that change.- Anchors were re-verified against current source; the build-by-hand copy instruction for
Directory.Build.propsmoved tocommon-BUILD-BY-HAND.md:171.
Revision (2026-10-21)
Re-verified against current source. No decision or rationale changed: the staged analyzer delta, the
shipped wire-contract freeze and its staging guard, the printed mmca-module wire-ups and the smoke
cases behave as described. The seed measurements and several anchors moved with the tree.
- The seed measurements were re-taken at MMCA.Helpdesk
f10bc45with the same method: 132 files and 11,068 lines underSource/andTests/, plus 1,089 lines across the seven root build files (.editorconfig827,Directory.Packages.props99 with 57PackageVersionentries,Directory.Build.props92,Directory.Build.targets7,global.json5,nuget.config26,MMCA.Helpdesk.slnx33), for 139 files and 12,157 lines. The 12 projects underSource/andTests/and the local-source import (Directory.Build.targets:6) are unchanged. --databasehas two computed symbols, not three:useSqliteandusePostgreSQL(.template.config/template.json:270,:274;templates/mmca-module/.template.config/template.json:179,:183). Nosqlserversymbol exists; it is the shape that applies when neither is set. The Decision text now says so.- Anchors re-verified against current source: the staged delta at
stage.ps1:1261-1263with its header at:1258; the contract guard atstage.ps1:1321-1332(the count test at:1330, the throw at:1331); the overlay README fixup command atREADME.md:115, theIDE0021note at:121-124, the freeze section at:126-143and its still-stale "prints the live value" line at:138; the smoke wire-up step atsmoke.ps1:701-810, the migration step at:812-830, the rerun step at:835and the third--minimum-expected-tests 1at:910; the packable-projects statement atMMCA.Common/FACTS.md:20.
Revision (2026-10-26)
Re-verified against current source. No decision or rationale changed: the staged analyzer delta, the
shipped wire-contract freeze and its staging guard, the printed mmca-module wire-ups and the smoke
cases behave as described. The overlay README's contract wording, the mmca-command contents, the
seed measurements and the guard and README anchors moved with the tree.
- The overlay README no longer says the contract failure prints the live value: its freeze section
names
MMCA_CONTRACT_SNAPSHOT_OUTand gives a paste-ready command for writing the live contract as literals (build/templates/overlay/mmca-app/README.md:137-144). The Decision text now says so. - The seed measurements were re-taken at MMCA.Helpdesk
5fd74dewith the same method: 136 files and 11,497 lines underSource/andTests/, plus 1,074 lines across the seven root build files (.editorconfig827,Directory.Packages.props99 with 57PackageVersionentries,Directory.Build.props77,Directory.Build.targets7,global.json5,nuget.config26,MMCA.Helpdesk.slnx33), for 143 files and 12,571 lines. The method still reproduces the 2026-10-21 pair atf10bc45; only the tree moved (Directory.Build.propslost 15 lines). The 12 projects and the local-source import (Directory.Build.targets:6) are unchanged. mmca-commandships more than a command and handler. It stages the command, its validator and the handler (build/templates/stage.ps1:1371, rationale at:1363-1368: the generated app's validator-coverage rule fails a data-carrying command with no validator), and it also ships the slice's cache-invalidation test builder,DeleteTicketCommandCacheBuilder.cs, staged undertests/(stage.ps1:1409-1426) and written by the template's second source seven levels up, at the solution root, into the module's Application.Tests project (templates/mmca-command/.template.config/template.json:102-103). The module's cache-invalidation test fails by name on a command with no builder, so the slice is green on arrival. The Decision table now says so.- Anchors re-verified against current source: the staged delta at
stage.ps1:1261-1263with its header at:1258; the contract guard atstage.ps1:1322-1333(the count at:1329, the test at:1331, the throw at:1332); the overlay README fixup command atREADME.md:115, theIDE0021note at:121-124, and the freeze section at:126-146;IntegrationEventContractTestsatArchitectureTests.cs:99; the--minimum-expected-tests 1runs atsmoke.ps1:361,:487,:910and.github/workflows/ci.yml:104.
Revision (2026-10-28)
Re-verified against current source. No decision or rationale changed: the 12 projects and the
local-source import (Directory.Build.targets:6) are unchanged, the seven root build files keep
the same line counts (only the Directory.Packages.props pin versions moved), and the
required check is still build-and-test with template-smoke and the PostgreSQL canary advisory
beside it. The seed measurements and the required-check anchor moved with the tree.
- The seed measurements were re-taken at MMCA.Helpdesk
c41fc93with the same method: 141 files underSource/andTests/(137 text files with 11,523 lines, plus four binary PNG icons underMMCA.Helpdesk.UI.Web/wwwroot/icons/, the first binary files in that tree, counted as files and excluded from the line count), plus 1,074 lines across the seven root build files (.editorconfig827,Directory.Packages.props99 with 57PackageVersionentries,Directory.Build.props77,Directory.Build.targets7,global.json5,nuget.config26,MMCA.Helpdesk.slnx33), for 148 files and 12,597 lines. The root build files carry the same line counts as at5fd74de;Directory.Packages.propschanged in place (the MMCA.Common version bumps) without changing its line or pin count. The Context text now says so. - Anchors re-verified against current source: the required-check sentence naming
build-and-testand its two advisory jobs atMMCA.Helpdesk/AGENTS.md:92, and the end of Phase 6 of the by-hand guide atcommon-BUILD-BY-HAND.md:1299(Phase 7 opens at:1300), which the Context range now ends on.