↑↓ to navigate Enter to open "…" all these words ANDOR to combine

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.* for Contoso.Support and below it for Zeta.App. SA1210 has no notion of blank-line-separated groups, so no checked-in order survives both. SA1211 is the same story one level down, on the identifier-alias file whose two aliases re-sort when --child renames them. IDE0021 is the shape flags rather than the renames: the aggregate's private constructor assigns one property per optional axis, so --no-status --no-description --no-owner together leave it with a single statement, which the baseline then requires as an expression body. Staging appends a scoped delta dropping those three to suggestion in the staged .editorconfig (stage.ps1:1261-1263); the seed's own copy, which is the shared analyzer baseline that Tools/Scripts/compare-analyzer-config.ps1 holds identical across the four repos, is untouched, and it declares none of the three among its 215 explicit dotnet_diagnostic.*.severity lines. Every other analyzer stays at error, as the delta's own header says (stage.ps1:1258). The generated README carries dotnet format analyzers MMCA.Helpdesk.slnx --diagnostics SA1210 SA1211 --severity info (build/templates/overlay/mmca-app/README.md:115), which restores the two ordering rules. IDE0021 is 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 SA1210 half of the delta was moving every app-local using into 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-module alone cannot finish the job. Seven edits are beyond it because dotnet new cannot patch existing files. For a solution generated by mmca-app that is no longer manual: the overlay ships build/add-module.ps1 (build/templates/overlay/mmca-app/build/add-module.ps1, declared copyOnly at MMCA.Helpdesk/.template.config/template.json:345 and exempted from the smoke's rename sweeps at smoke.ps1:144), which runs dotnet new mmca-module and then performs all seven, plus an eighth the printed list does not carry (the new module's integration event joins ExpectedContract, 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-version defaults to whatever the seed pins, so a MMCA.Common release 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.cs reach 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, :487 and :910 all 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-1332 throws 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 f6ef4b0 with the same method: 130 files and 10,500 lines under Source/ and Tests/, 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 at b9c97e9; only the tree moved (one new test file, TicketsExportScopeTests.cs, and growth elsewhere).
  • Directory.Build.targets is no longer the 74-line local-source swap: it is 7 lines and imports build/LocalSource/MMCA.Common.LocalSource.targets from 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.props moved to common-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.

  1. The seed measurements were re-taken at MMCA.Helpdesk f10bc45 with the same method: 132 files and 11,068 lines under Source/ and Tests/, plus 1,089 lines across the seven root build files (.editorconfig 827, Directory.Packages.props 99 with 57 PackageVersion entries, Directory.Build.props 92, Directory.Build.targets 7, global.json 5, nuget.config 26, MMCA.Helpdesk.slnx 33), for 139 files and 12,157 lines. The 12 projects under Source/ and Tests/ and the local-source import (Directory.Build.targets:6) are unchanged.
  2. --database has two computed symbols, not three: useSqlite and usePostgreSQL (.template.config/template.json:270, :274; templates/mmca-module/.template.config/template.json:179, :183). No sqlserver symbol exists; it is the shape that applies when neither is set. The Decision text now says so.
  3. Anchors re-verified against current source: the staged delta at stage.ps1:1261-1263 with its header at :1258; the contract guard at stage.ps1:1321-1332 (the count test at :1330, the throw at :1331); the overlay README fixup command at README.md:115, the IDE0021 note at :121-124, the freeze section at :126-143 and its still-stale "prints the live value" line at :138; the smoke wire-up step at smoke.ps1:701-810, the migration step at :812-830, the rerun step at :835 and the third --minimum-expected-tests 1 at :910; the packable-projects statement at MMCA.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.

  1. The overlay README no longer says the contract failure prints the live value: its freeze section names MMCA_CONTRACT_SNAPSHOT_OUT and 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.
  2. The seed measurements were re-taken at MMCA.Helpdesk 5fd74de with the same method: 136 files and 11,497 lines under Source/ and Tests/, plus 1,074 lines across the seven root build files (.editorconfig 827, Directory.Packages.props 99 with 57 PackageVersion entries, Directory.Build.props 77, Directory.Build.targets 7, global.json 5, nuget.config 26, MMCA.Helpdesk.slnx 33), for 143 files and 12,571 lines. The method still reproduces the 2026-10-21 pair at f10bc45; only the tree moved (Directory.Build.props lost 15 lines). The 12 projects and the local-source import (Directory.Build.targets:6) are unchanged.
  3. mmca-command ships 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 under tests/ (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.
  4. Anchors re-verified against current source: the staged delta at stage.ps1:1261-1263 with its header at :1258; the contract guard at stage.ps1:1322-1333 (the count at :1329, the test at :1331, the throw at :1332); the overlay README fixup command at README.md:115, the IDE0021 note at :121-124, and the freeze section at :126-146; IntegrationEventContractTests at ArchitectureTests.cs:99; the --minimum-expected-tests 1 runs at smoke.ps1:361, :487, :910 and .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.

  1. The seed measurements were re-taken at MMCA.Helpdesk c41fc93 with the same method: 141 files under Source/ and Tests/ (137 text files with 11,523 lines, plus four binary PNG icons under MMCA.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 (.editorconfig 827, Directory.Packages.props 99 with 57 PackageVersion entries, Directory.Build.props 77, Directory.Build.targets 7, global.json 5, nuget.config 26, MMCA.Helpdesk.slnx 33), for 148 files and 12,597 lines. The root build files carry the same line counts as at 5fd74de; Directory.Packages.props changed in place (the MMCA.Common version bumps) without changing its line or pin count. The Context text now says so.
  2. Anchors re-verified against current source: the required-check sentence naming build-and-test and its two advisory jobs at MMCA.Helpdesk/AGENTS.md:92, and the end of Phase 6 of the by-hand guide at common-BUILD-BY-HAND.md:1299 (Phase 7 opens at :1300), which the Context range now ends on.