Guides & specifications
Templates: scaffolding an MMCA app
MMCA.Templates is a dotnet new pack that scaffolds solutions, modules, and vertical slices on the
MMCA.Common framework. It exists because standing up
a new app by hand meant 12 projects and roughly 5,300 lines before a line of business logic, several
of them load-bearing in ways nothing tells you about until much later. See
ADR-065 for the reasoning,
Getting Started for the six-step path from nothing to a running app,
Small Apps for the --database sqlite --no-aspire floor, and
Building by Hand for what the generated code actually does, phase by phase.
dotnet new install MMCA.Templates
dotnet new mmca-app -n Contoso.Support --module Orders --aggregate Order
cd Contoso.Support
dotnet build Contoso.Support.slnx
dotnet test --solution Contoso.Support.slnx
That is a warning-free build under all five analyzers and a passing test run, including the architecture-fitness rules, with no database needed.
| Short name | Generates | Run it from |
|---|---|---|
mmca-app |
the whole solution | anywhere; it creates the solution directory |
mmca-module |
a business module across all five layers, both test projects, a migrations project | your solution root |
mmca-command |
one write-side slice: command record + validator + handler | your module's UseCases folder |
mmca-query |
one read-side slice: cacheable query record + handler | your module's UseCases folder |
mmca-app also lays down build/add-module.ps1 inside the solution it generates (its own section
below): it drives mmca-module and then performs every wire-up dotnet new cannot reach.
mmca-app
| Parameter | Default | Meaning |
|---|---|---|
-n, --name |
MMCA.App |
solution name and root namespace, for example Contoso.Support |
-m, --module |
Tickets |
the first business module, plural PascalCase |
-p:a, --aggregate |
Ticket |
that module's aggregate root, singular PascalCase |
-c, --child |
Comment |
the aggregate's child entity, singular PascalCase (1.3.0) |
--title |
Title |
the aggregate's main text property, singular PascalCase (1.4.0) |
--event-verb |
Opened |
verb naming the creation integration event, past tense PascalCase (1.4.0) |
--flat |
off | generate the module with no child collection at all (1.3.0) |
--no-status |
off | generate the module with no status axis (1.3.0) |
--no-description |
off | generate the module with no long-text property (1.4.0) |
--no-owner |
off | generate the module with no owning-user property (1.4.0) |
--database |
sqlserver |
relational engine for the sample module: sqlserver or sqlite |
--no-aspire |
off | generate the solution with no Aspire AppHost |
-f, --framework-version |
the version the pack was cut against | the MMCA.Common.* version to pin |
--local-mmca |
off | emit a local.props that builds against ../MMCA.Common/Source instead of the published packages |
--no-restore |
off | skip the restore after generation |
The module and aggregate names are independent, so --module Billing --aggregate Invoice is fine.
Everything derived from them follows: routes, the Aspire database resource, the design-time
connection-string environment variable, the identifier alias, the cache-key prefix, the resource
strings, and the Blazor pages.
Shaping the sample module
Six options decide what the generated aggregate is made of, so you stop deleting or renaming the
sample's shape by hand after the fact. --child, --flat and --no-status arrived in
MMCA.Templates 1.3.0; --title, --event-verb, --no-description and --no-owner in
1.4.0. Four of them are shape decisions and two are renames.
The four shape flags remove an axis. The code for an axis you turn off is never generated, so there is nothing to delete afterwards:
| Option | What it drops |
|---|---|
--flat |
the child collection entirely: no child entity, DTO, requests, mapper, EF configuration, Add/Edit/Remove slices, controller endpoints, identifier alias, or tests |
--no-status |
the status axis: no status enum, no ChangeStatus slice, request, or endpoint, no Status property, no status invariant or tests |
--no-description |
the long-text property: no Description property or invariant, no max-length constant, no DTO, request, or command field, no validator rule, no EF max-length configuration, no error-resource entries, no UI field, no tests |
--no-owner |
the owning-user property: no RequesterUserId property, no create-request field or validator rule, no member on the creation integration event, no EF index, no UI field or column, no tests |
Reach for them when the aggregate genuinely lacks the axis, which is more common than the sample suggests: a catalog product, a customer, a price list, a tax rate. Passing all four leaves a leaf aggregate that is nothing but its own scalar fields, which is the smallest honest starting point the pack can generate, and it still arrives with the full five-layer wiring and a green test run.
The two renames are plain substring replacements rather than shape decisions. --child renames
the child concept everywhere; the generated type is <aggregate><child>, so:
dotnet new mmca-app -n Contoso.Shipping --module Shipments --aggregate Shipment --child Line
gives you a ShipmentLine entity and DTO, AddLine / EditLine / RemoveLine use-case slices,
/lines routes on the controller, an AddLineRequest / EditLineRequest pair, and a
ShipmentLineIdentifierType alias. The plural comes from an English pluralizer (Line to Lines,
Entry to Entries, Box to Boxes), so an irregular noun is the one case you rename by hand.
--child is ignored under --flat, since there is no child left to name.
--title renames the aggregate's main text property, and everything that names it moves with it: the
domain property and its invariant, the <Aggregate>.<Title>.Empty / .TooLong error codes, the
<Title>MaxLength constant, the DTO and request properties, the EF column, the FluentValidation
rule, the UI field and column resources, and the domain tests. A camel-case form is derived for
parameters, private fields, named arguments and nameof() targets, using first-letter lowering
rather than full lowering so a multi-word value stays a valid identifier (CustomerName to
customerName, not customername). The price of a substring replacement is that the camel-case form
also reaches English prose: with a multi-word --title, the two shipped error messages arrive
reading "Order customerName cannot be empty." Identifiers have to compile; those two strings are a
one-line edit in the module's error resources after generating.
--event-verb names the creation integration event's verb, past tense. --aggregate Order --event-verb Placed gives OrderPlacedIntegrationEvent and its OrderPlacedHandler consumer. The
id is database-generated, so this event is published AFTER the commit by the create handler rather
than raised as a domain event by the factory, which is why naming it is a rename and not a shape
decision.
Passing any of the four shape flags also drops the sample's checked-in migration, because that
migration describes the full shape (child table, status column, owner index) and would be wrong on
arrival. Generate your own InitialCreate against the shape you asked for;
Migrations/.editorconfig still ships, since dotnet ef never recreates it.
What you get (names shown for -n Contoso.Support --module Orders --aggregate Order):
Contoso.Support.slnx
.editorconfig the five analyzers at error severity
Directory.Build.props language settings, analyzers, the identifier-alias links
Directory.Build.targets the local-source PackageReference -> ProjectReference swap
Directory.Packages.props Central Package Management
Source/
Modules/Orders/ Shared, Domain, Application, Infrastructure, API
Hosts/Contoso.Support.Web the monolith REST API host
Hosts/UI/Contoso.Support.UI.Web Blazor Server + MudBlazor
Hosting/Contoso.Support.AppHost Aspire orchestration
Hosting/Contoso.Support.Migrations.SqlServer.Orders
Tests/
Modules/Orders/ domain + application tests
Architecture/ the fitness functions, parameterized by SupportArchitectureMap
The Order aggregate arrives fully worked: a Result-returning factory, invariants, guarded
mutations raising domain events, a child entity, soft-delete cascade, the caching pair (a cacheable
read plus invalidating commands, both keyed through one *CacheKeys type), an integration event
through the outbox, en-US and es resource pairs, a REST controller, and two Blazor pages. The
child entity and the status axis are optional: see the shape options below.
That is twelve projects. Two of them are decided by the solution-shape options below: the AppHost
disappears under --no-aspire, and the migrations project is named for the engine
(Contoso.Support.Migrations.Sqlite.Orders under --database sqlite).
Shaping the solution
Two options decide the shape of the solution rather than of the module. They compose with each
other and with all six module options, and --database sqlite --no-aspire together are the
small-app floor that Small Apps is written about.
| Option | Default | What changes |
|---|---|---|
--database sqlserver|sqlite |
sqlserver |
The relational engine. Under sqlite: entity configurations inherit EntityTypeConfigurationSqlite, the migrations project becomes <App>.Migrations.Sqlite.<Module> against Microsoft.EntityFrameworkCore.Sqlite, the design-time factory becomes DesignTimeSqliteDbContextFactory over SqliteDbContext, the API host asks for a database rather than for SQL Server specifically (AddInfrastructureHealthChecks(requireDatabase: true)), the app is configured single-tenant, and the whole database is one file beside the host. |
--no-aspire |
off | No orchestration project: no AppHost, no Aspire.Hosting.* pins, and the UI host reaches the API at the fixed dev address https://localhost:60801 instead of through service discovery. Both hosts keep AddServiceDefaults() and MapDefaultEndpoints(), so telemetry, /health + /alive and the resilience pipeline are all still there and adding an AppHost later is additive. |
dotnet new mmca-app -n Contoso.Notes --module Notes --aggregate Note --database sqlite --no-aspire
Three properties are worth knowing before you pick:
--databaseis a swap, not a removal. Unlike the four module-shape flags, which take an axis away, this one exchanges one engine's code for another's. Everything above the configuration base is untouched: the same module, handlers, controller and tests build against either engine, which is the point of the framework's data-source routing (ADR-018).--database sqlitedrops the checked-in sample migration, for the same reason any shape flag does and for a second one: those migrations are SQL Server DDL and their model snapshot namesSQLServerDbContext. Rundotnet ef migrations add InitialCreateagainst the generated.Migrations.Sqlite.<Module>project with--context SqliteDbContext, and run it before the first run of the API host. The generatedDataSourcesentry names aSqliteMigrationsAssembly, and a SQLite source that names one is migrated at startup rather than created outright, so an empty migrations assembly leaves the host with an empty database file and a first request that fails on a missing table. The generated README says the same thing in its own "Before the first run" section.--databasereachesmmca-module,--no-aspiredoes not, and neither reaches the slice templates. A module is not engine-neutral (its EF configurations inherit an engine-specific configuration base and its migrations project references an engine-specific provider), sommca-moduledeclares the engine option in its own right andbuild/add-module.ps1passes it through. Only the app owns an orchestration project, so--no-aspirestaysmmca-app's alone;mmca-commandandmmca-querycarry no engine coupling at all.
Dropping the Blazor UI host
There is no --ui flag: a conditional big enough to remove a host cannot be expressed without
#if regions that would either break the reference app's own build or leave it compiling the
wrong variant. Removing it afterwards is five steps, and four of them fail loudly while the fifth
does not, so do them together:
- Delete
Source/Hosts/UI/. - Remove the
<Project Path="Source/Hosts/UI/..." />line from the.slnx. - Remove the UI
ProjectReferencefrom<App>.AppHost.csproj. Aspire generates theProjects.<App>_UI_Webtype from that reference, so the AppHost will not compile until it goes. - Remove the
builder.AddProject<Projects.<App>_UI_Web>("ui")block from the AppHostProgram.cs. - Drop the
var web =prefix from the block above it.webexisted only for the UI'sWithReference(web)/WaitFor(web). Left assigned and unread it is an unused local, which isIDE0059andS1481, both at error severity, so the build fails on a line you never touched.
The API host, the module, the migrations project, and all three test projects are unaffected.
The one-time fixup
One thing the scaffold deliberately does not hand over, because renaming invalidates it and no fixed value is right for every name you could pick.
Using-directive and alias order. using Contoso.Support.Orders.Shared; sorts above
MMCA.Common.*, but using Zeta.App.Orders.Shared; sorts below it. SA1210 has no notion of
blank-line-separated groups, so no checked-in order survives both, and SA1211 is the same story for
the identifier-alias file, whose aliases sort differently depending on the aggregate and child names
you asked for. Both ship as suggestions, via a clearly marked SCAFFOLD DELTA block at the bottom of
the generated .editorconfig. Sort them with:
dotnet format analyzers Contoso.Support.slnx --diagnostics SA1210 SA1211 --severity info
That block relaxes a third rule, IDE0021, and it is there for the shape flags rather than the
renames: the aggregate's private constructor assigns one property per optional axis, so turning
several of them off can leave it with a single statement, which the baseline would then require you
to write as an expression body. Fold it to an expression body by hand (or add your own second
property) and the line has nothing left to relax. Only those three rules are relaxed; every other
analyzer stays at error severity throughout. Every mmca-command / mmca-query slice arrives with
the same using skew, so either re-run the format command after scaffolding, or leave the block until
you have stopped scaffolding and then delete the whole thing.
Why the pack does not run that command for you. A dotnet new run-script post action needs
--allow-scripts yes on the command line and prompts otherwise, which would stall the pack's own
smoke job and every adopter's scripted generation; it would also run before the solution has been
built. A post action was evaluated on that basis and deliberately not added. The .editorconfig
delta plus the command above is the supported answer.
Your integration-event contract ships frozen
This used to be a second fixup: a subclass to paste in, run once, and fill from the failure. It is now emitted by the template, holding your event under the names you scaffolded with, and it passes on arrival.
What made that possible is that IntegrationEventContractTestsBase compares each event's member
list as a set rather than a sequence. JSON carries no member order, so reordering two properties
changes nothing a consumer can observe, and the aggregate's own id moving position in the literal is
no longer a difference. Everything observable is still a failure: a missing member, an extra member,
a changed type, and any change to the set of events itself.
build/add-module.ps1 appends each new module's integration event to ExpectedContract as one of
its wire-ups, so adding a second module does not turn your next test run red either. When you evolve
an event on purpose, version it (ADR-010) and
update the literal in the same commit; the failure prints the live value to paste:
dotnet test --project Tests/Architecture/Contoso.Support.Architecture.Tests/Contoso.Support.Architecture.Tests.csproj `
-- --filter-class "*IntegrationEventContract*"
mmca-module
cd Contoso.Support
dotnet new mmca-module -n Billing --app Contoso.Support --aggregate Invoice
| Parameter | Default | Meaning |
|---|---|---|
-n, --name |
none | the module, plural PascalCase, for example Billing |
--app |
none | your solution / root namespace, for example Contoso.Support (required) |
-p:a, --aggregate |
none | the module's aggregate root, singular PascalCase (required) |
-c, --child |
Comment |
the aggregate's child entity, singular PascalCase (1.3.0) |
--title |
Title |
the aggregate's main text property, singular PascalCase (1.4.0) |
--event-verb |
Opened |
verb naming the creation integration event, past tense PascalCase (1.4.0) |
--flat |
off | generate the module with no child collection at all (1.3.0) |
--no-status |
off | generate the module with no status axis (1.3.0) |
--no-description |
off | generate the module with no long-text property (1.4.0) |
--no-owner |
off | generate the module with no owning-user property (1.4.0) |
--database |
sqlserver |
relational engine for this module: sqlserver or sqlite (1.7.0) |
The six shape options behave exactly as they do for mmca-app, and they are per module: a solution
can hold a flat, status-less catalog module beside one whose aggregate owns a growing child
collection and a guarded lifecycle. The one difference is that --title here does not reach UI
resources, because mmca-module generates no Blazor pages.
--database is the seventh, and it is not a shape option: it must match the engine the surrounding
solution was generated with, because the engine decides which configuration base the module's EF
configurations inherit and which provider package its migrations project references, and a module
built for the other engine does not compile in the solution it was added to. Under
--database sqlite the migrations project arrives named .Migrations.Sqlite.<Module> against
Microsoft.EntityFrameworkCore.Sqlite, with a DesignTimeSqliteDbContextFactory over
SqliteDbContext, and the wire-up instructions it prints name SqliteMigrationsAssembly.
build/add-module.ps1 detects the solution's engine and passes this flag for you.
If your solution came from mmca-app, use build/add-module.ps1 instead (below): it runs this
template and then applies every wire-up the template can only print.
dotnet new mmca-module -n Orders --app Contoso.Shop --aggregate Order --child Item
That produces an OrderItem entity and DTO, AddItem / EditItem / RemoveItem slices, /items
routes, and an OrderItemIdentifierType alias, with no post-generation renaming.
Generates eight projects into the right places: the five layer projects under
Source/Modules/<Module>/, both test projects under Tests/Modules/<Module>/, and a migrations
project under Source/Hosting/. The migrations project ships without a Migrations/ folder: you
create the first one against your own entities.
The wire-ups it prints
dotnet new cannot patch files that already exist, so it prints seven items (the six below plus the
first migration). build/add-module.ps1 performs all of them for you, so read this list as the
manual fallback, or as the answer to "what did that script just do to my solution". Until they are
done the module is invisible to the host and to the fitness rules; the first two are what make it
compile.
- Solution.
dotnet sln <App>.slnx add Source/Modules/<Module>/*/*.csproj Tests/Modules/<Module>/*/*.csproj Source/Hosting/<App>.Migrations.SqlServer.<Module>/*.csproj(bash globbing; in PowerShell expand with(Get-ChildItem ...).FullName, as the printed instructions now show) - Project references.
<App>.Web.csprojneeds<App>.<Module>.APIand<App>.Migrations.SqlServer.<Module>.<App>.Architecture.Tests.csprojneeds all five layer projects, because the map in step 4 names a type from each. - Identifier alias. Copy the existing
<Compile Include ... Link>block inDirectory.Build.propsand point it at the new module's*.GlobalUsings.IdentifierType.cs. Without it the alias is invisible outside its own project. - Architecture map. Five lines in your
*ArchitectureMap.cs, one per layer. A module missing from the map is silently not covered by the layering and isolation rules (ADR-015). - Host.
services.AddErrorResources<<Module>ErrorResources>();next to the existing ones.ModuleLoaderdiscovers theIModuleitself, so nothing else needs registering. - Database. An AppHost database resource plus a
DataSourcesentry per module in the Web host's appsettings, the top-level migrations-assembly pin deleted, and a top-level"Outbox": { "DatabaseName": "<FirstModule>" }pin (IEventBus writes handler-published integration events to one configured outbox source per host). The ecommerce sample guide walks each edit.
Then create the first migration:
dotnet ef migrations add InitialCreate `
--project Source/Hosting/Contoso.Support.Migrations.SqlServer.Billing `
--startup-project Source/Hosting/Contoso.Support.Migrations.SqlServer.Billing `
--context SQLServerDbContext
Every path and context name above carries the engine, so on a --database sqlite module the project
is Contoso.Support.Migrations.Sqlite.Billing and the context is SqliteDbContext. There the
migration is required before the next run of the API host rather than optional: a SQLite source that
names a migrations assembly is migrated at startup instead of created outright.
build/add-module.ps1
Since 1.4.0 every solution mmca-app generates ships this script, and it is the supported way to
add a second module. It runs mmca-module with your shape options passed through, then performs the
seven printed wire-ups plus an eighth of its own: the new module's integration event is appended to
ExpectedContract in your architecture tests, so the frozen wire contract stays green. The
orchestration step is skipped rather than failed when the solution has no AppHost (a --no-aspire
app), where a DataSources entry is the whole database wire-up.
pwsh build/add-module.ps1 -Name Orders -Aggregate Order -Child Item -EventVerb Placed
| Parameter | Maps to | Meaning |
|---|---|---|
-Name |
-n |
the module, plural PascalCase |
-Aggregate |
--aggregate |
the module's aggregate root, singular PascalCase |
-Child |
--child |
rename the child entity |
-Title |
--title |
rename the aggregate's main text property |
-EventVerb |
--event-verb |
verb of the creation integration event |
-Flat |
--flat |
no child collection |
-NoStatus |
--no-status |
no status axis |
-NoOwner |
--no-owner |
no owning-user property |
-NoDescription |
--no-description |
no long-text property |
-Database |
--database |
override the engine the solution is read to be running on: sqlserver or sqlite (1.7.0) |
-SkipMigration |
print the dotnet ef migrations add command instead of running it |
Run it with -? for the full help, and run it from the solution root: it refuses to run anywhere
else, and everything else it discovers there at run time (the solution file, which is also the app's
root namespace, the modules already present, the relational engine the solution runs on, and the web
host, AppHost and architecture-test projects by glob). Nothing about the app that generated it is
baked in, which is why it ships copyOnly,
verbatim, with no token replacement at all: the flag names it passes through have to survive whatever
--title / --event-verb / --child values your solution was generated with.
Four properties worth knowing before you run it:
- The engine is detected, so
-Databaseis the exception rather than the rule. The script reads it off the API host's ownappsettings.json, from the key spelling inside the top-levelConnectionStringssection (SQLServerConnectionStringorSqliteConnectionString). That file was picked over the alternatives because it is the file the script also writes: detecting from the same place the new data source is written into is what makes the two impossible to disagree. The existing<App>.Migrations.<Engine>.<Module>folder is then cross-checked against that reading, and a disagreement stops the run rather than adding a project half the solution cannot compile against. Pass-Databasewhen a solution has grown a second engine and the detection can no longer answer for the module you are adding; either way the preflight summary echoes the engine and says whether it was read or passed. - It fails fast rather than half-applying. A
Namealready underSource/Modulesis refused before anything is generated, and a.slnxcount other than one, a missing host project, or an ambiguous architecture map all stop the run at preflight. - Every edit is anchored, and a missing anchor throws with the manual edit printed. The edits are
anchored text insertions, never a parse-and-reserialize round trip, so
git diffafter the run shows the added lines and nothing else. If the scaffold's shape has moved (or you reworked a file by hand), the script writes nothing and hands you the exact edit to make instead. - A rerun is safe. Each step detects its own work and skips it with a note, so a run that died at step 7 can be fixed and rerun from the top.
-SkipMigration is worth passing when the module is about to be reshaped: the migration would
describe the scaffolded entities, and you would delete and regenerate it anyway. The script also
degrades to printing the command on its own when the dotnet-ef tool is not installed, rather than
failing a run whose other steps landed. On a SQLite solution it warns in both of those cases that the
migration is required before the next run of the API host, since the host migrates a SQLite source
that names a migrations assembly instead of creating it outright.
Two things it deliberately does not do: the Blazor UI pages for the new module, and that module's page-level localization resources. It prints both reminders when it finishes. The ecommerce sample guide is a worked two-module run.
mmca-command and mmca-query
Run these from the module's UseCases folder. Each creates a folder named after the slice holding
its two files.
cd Source/Modules/Billing/Contoso.Support.Billing.Application/Billing/UseCases
dotnet new mmca-command -n ArchiveInvoice --app Contoso.Support --module Billing `
--aggregate Invoice --domain-method Archive
dotnet new mmca-query -n GetInvoiceByNumber --app Contoso.Support --module Billing `
--aggregate Invoice --child-collection Comments
| Parameter | Applies to | Meaning |
|---|---|---|
-n, --name |
both | the use case, PascalCase; names the folder, the namespace segment, and both types |
--app |
both | your solution / root namespace (required) |
-m, --module |
both | the module this slice goes into (required) |
-p:a, --aggregate |
both | the aggregate the handler loads (required) |
--domain-method |
mmca-command |
the guarded method the command calls on the aggregate |
--child-collection |
both | navigation to eager-load; unset loads the aggregate root alone |
--child-collection exists because both handlers load through GetByIdAsync, whose includes:
argument is required: there is always a list, so the only question is what goes in it. Naming a
navigation eager-loads it, and leaving the parameter off passes an empty list. The name goes straight
into nameof(...), so pass one your aggregate actually has (Comments is the only one a scaffolded
aggregate owns). Before MMCA.Templates 1.1.0 the slices named the reference app's own child
collection unconditionally, which did not compile on an aggregate shaped differently.
Handlers are convention-scanned by Scrutor, so there is no DI registration to add. Two things do need you:
- The command slice calls
--domain-methodon your aggregate, and the scaffold cannot invent it. Add that method returningResultbefore the slice compiles, or the generated handler fails with'Invoice' does not contain a definition for 'Archive'.dotnet newprints the same reminder as a post-action; the getting-started guide shows the shape to copy. - Keep the query's
CacheKeyinside your module's*CacheKeys.Prefix. The caching decorator matches cacheable reads to invalidating commands by string prefix, so a key that drifts out of the prefix goes stale silently and nothing fails.
Map both in your module's controller: reads generally come from EntityControllerBase, and writes
inject handlers directly.
How the pack is built
The template content is the MMCA.Helpdesk reference application itself, staged at pack time. There is no second copy of the solution, so the template cannot drift from the app whose CI keeps it building.
That also means the seed's green CI is not the template's gate. The seed builds in local-source
mode against MMCA.Common@main, while 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. A separate
template-smoke job generates three solutions whose names share no substring with the seed,
sweeps each for residual and for missing tokens, builds package-mode and runs the tests. The three
are shapes rather than names: one with every module axis turned off plus a --title rename, one
fully default (which is what makes the absence sweeps non-vacuous), and one solution shape,
--database sqlite --no-aspire.
Two of the three go on to add a second module through build/add-module.ps1, for reasons neither
subsumes. The first proves the wire-ups: every edit the script makes, the child rename, the
module-only axes, the slices, and the refused rerun, on the default engine. The sqlite case proves
the engine reaches the second module, because --database is declared twice, once per
template.json, staged by two separate passes, so a module added to a sqlite solution can come out
SQL-Server-shaped with every one of the first case's assertions still green. It asserts what a second
sqlite module has to be: a .Migrations.Sqlite.<Module> project with a DesignTimeSqliteDbContextFactory
and no SQL Server residue in any of its files, two DataSources entries pinned to their own
SqliteMigrationsAssembly values and pointing at separate files (one shared file would collide
the two modules' outbox tables), the outbox pinned to the first module, a migration created against
SqliteDbContext, and a green Release build and test run of the two-module solution. That run is
deliberately thin (no slices, no child rename, no rerun check) to keep the third case's wall clock
down, and only the first case scaffolds slices.
To work on the templates:
git clone https://github.com/ivanball/MMCA.Helpdesk
cd MMCA.Helpdesk
pwsh build/templates/smoke.ps1 # stage, pack, install, generate, build, test