Guides & specifications
Getting Started: Build a New App on MMCA.Common
MMCA.Common is a .NET 10 framework for DDD, Clean Architecture, and CQRS, shipped as a set of lockstep-versioned NuGet packages (the authoritative list and count live in FACTS.md). Its core promise: build a modular monolith now, and extract a module into its own microservice later, without a rewrite.
Standing that up by hand means 12 projects and roughly 6,600 lines before a line of your own business logic, several of them load-bearing in ways nothing tells you about until much later. So you do not type it. One command writes the whole thing, green:
dotnet new install MMCA.Templates
dotnet new mmca-app -n Contoso.Support --module Orders --aggregate Order
The six steps below take that from nothing to a running, migrated, browsable app. Everything after them is optional depth.
Adding the framework to a solution that already exists? The template creates a solution; it cannot retrofit one. Take the build-by-hand walkthrough instead. It is also where to read what the scaffold handed you and why, once you want to change it.
No Docker, or nothing to orchestrate yet? Add
--database sqlite --no-aspireand the same command produces a two-host app over a single database file: no container, no server, no AppHost, and the whole framework otherwise intact. That shape has its own guide, Small Apps, including what is deliberately off in it and which switch to flip when an assumption stops holding. The rest of this page describes the full shape.
Before you start
- .NET 10 SDK. The framework targets
net10.0withLangVersion: previewfor C# extension types. - Docker Desktop. Aspire provisions SQL Server as a container, so you do not install one.
- EF Core tools:
dotnet tool install --global dotnet-ef.
No credentials, tokens, or extra feeds. MMCA.Templates and every MMCA.Common.* package restore
from nuget.org (see ADR-053).
One reference instead of six. A standard application host takes the framework's core six packages (Shared, Domain, Application, Infrastructure, API, Aspire). The
MMCA.Commonmetapackage bundles exactly those, so a hand-wired host can take onePackageReferenceand one version pin instead of six of each. It carries dependencies and no assembly, and it releases at the same version as everything else, so it is one more entry in the same lockstep sweep (ADR-101, ADR-016). The specialised packages (UI, UI.Web, Grpc, Gateway, Aspire.Hosting, theTesting.*set) stay separate and are added per project. The scaffold below still emits the explicit six, so this is something to reach for when you are wiring a host yourself.
1. Install the template pack
dotnet new install MMCA.Templates
Four templates arrive: mmca-app (a whole solution), mmca-module (a business module across all
five layers), and mmca-command / mmca-query (a single vertical slice).
2. Generate the solution
dotnet new mmca-app -n Contoso.Support --module Orders --aggregate Order
cd Contoso.Support
Three names, and they are independent: the solution (also your root namespace), the first
module in plural PascalCase, and that module's aggregate root in singular PascalCase.
--module Billing --aggregate Invoice is equally fine. Everything derived from them follows: routes,
the Aspire database resource, the identifier alias, the cache-key prefix, the resource strings, and
the Blazor pages.
Two more parameters are worth knowing on day one:
--framework-versionpins theMMCA.Common.*set, defaulting to the version the pack was cut against. Every package in the set moves together and there is no phased rollout (ADR-016); a fitness rule fails the build if the pins ever disagree.--local-mmcaemits alocal.propsthat builds against../MMCA.Common/SourcebyProjectReferenceinstead of the published packages. Use it only when your app sits beside the framework source in the same workspace.
The full parameter table is in the templates guide.
3. Build and test before you change anything
dotnet build Contoso.Support.slnx
dotnet test --solution Contoso.Support.slnx
That is a warning-free build with TreatWarningsAsErrors and all five analyzers at error severity,
and a passing test run including the architecture-fitness rules, with no database needed. If it
is not green, that is a template bug rather than yours: the pack is generated from the reference app
whose CI keeps it building, and a separate smoke job builds three generated solutions in package mode
on every change.
Getting a green baseline first is the point of this step. It is the line you bisect against later.
4. Create the first migration
The scaffold ships the migrations project and its design-time factory; the migration itself describes your entities, so it is yours to generate:
dotnet ef migrations add InitialCreate `
--project Source/Hosting/Contoso.Support.Migrations.SqlServer.Orders `
--startup-project Source/Hosting/Contoso.Support.Migrations.SqlServer.Orders `
--context SQLServerDbContext
Always pass --context SQLServerDbContext. There is exactly one concrete context class per database
engine; module contexts are abstract and only declare their DbSets
(ADR-006). You get one migrations project per (future)
service database even while you are a monolith, which is what makes extraction later cost no
migration rework.
5. Run it
dotnet run --project Source/Hosting/Contoso.Support.AppHost
Run this from a real, interactive terminal. Launched from a headless or background shell the Aspire AppHost stalls at control-plane init and no dashboard appears.
The dashboard lists three resources: sql, web (the REST API), and ui (Blazor Server +
MudBlazor). Open the ui endpoint to create and browse orders in the browser. To exercise the
API directly, POST /Orders then GET /Orders against the web endpoint; the API root / has no
page and returns 404 by design. Confirm 201 then 200, that audit fields are stamped, that
soft-deleted rows are filtered out, and that an outbox row was written for the
OrderOpenedIntegrationEvent.
The app runs issuer-less: with no Identity module it registers a bare auth scheme and the
controller is [AllowAnonymous], so nothing blocks you on day one. Adding real RS256/JWKS auth is
under Then what below.
6. The one-time fixup
The scaffold deliberately does not hand this one over, because renaming invalidates it and no fixed value is right for every name you could pick. It is covered in full in the templates guide.
Using-directive and alias order. using Contoso.Support.Orders.Shared; sorts above
MMCA.Common.*, but using Zeta.App.Orders.Shared; sorts below it, so no checked-in order survives
every name; SA1211 is the same story for the identifier-alias file. Both ship as suggestions in a
marked SCAFFOLD DELTA block at the bottom of the generated .editorconfig, alongside IDE0021
(which is there for the shape flags: turning several axes off can leave the aggregate's private
constructor with a single statement). Sort them, then delete the whole block once you have stopped
scaffolding:
dotnet format analyzers Contoso.Support.slnx --diagnostics SA1210 SA1211 --severity info
Your integration-event wire contract ships frozen. Integration events cross service boundaries,
so a renamed or retyped property breaks consumers elsewhere, and IntegrationEventContractTestsBase
fails the build on a silent reshape. The subclass is emitted by the template holding your event
under the names you scaffolded with, and it passes on arrival: member lists are compared as a set, so
nothing about the order of your properties has to be fixed up first. 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*"
What you were handed
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, an
integration event through the outbox, en-US and es resource pairs, a REST controller, and two
Blazor pages.
Eight of those lines are load-bearing and quiet about it. Read the linked phase before you touch the code around them:
| Know this | Because | Detail |
|---|---|---|
AddApplicationDecorators() is the last DI call |
decorators wrap handlers that already exist, and modules register theirs during ModuleLoader |
Phase 5 |
the AppHost does WaitFor(sql), never WaitFor(db) |
the host creates the database at startup, so waiting on the database resource deadlocks at "Waiting" forever | Phase 5 |
the AppHost needs its Properties/launchSettings.json |
without it the dashboard endpoints are never configured, and a missing dashboard presents as a hang | Phase 5 |
every module must appear in IArchitectureMap |
a module missing from the map is silently not covered by the layering and isolation rules | Phase 6 |
| caching is a pair, matched by string prefix | a cacheable query and its invalidating commands are wired independently, and half a pair fails silently forever | Phase 3 |
| the identifier alias is linked solution-wide | OrderIdentifierType is one global using made visible everywhere by a Directory.Build.props block; always use the alias, never the raw int |
Phase 1 |
| there is one concrete DbContext per engine | module contexts are abstract and only list DbSets; never write a concrete per-module context |
Phase 3d |
Result<T> replaces exceptions for business failure |
factories and handlers return it, and HandleFailure maps ErrorType to RFC 9457 ProblemDetails at the edge |
Phase 3e |
Add your next feature
A vertical slice (the path every feature follows) is one command, run from the module's
UseCases folder:
cd Source/Modules/Orders/Contoso.Support.Orders.Application/Orders/UseCases
dotnet new mmca-command -n TransferOrder --app Contoso.Support --module Orders --aggregate Order --domain-method TransferToRequester
dotnet new mmca-query -n GetOrderByNumber --app Contoso.Support --module Orders --aggregate Order --child-collection Comments
Handlers, validators, and mappers are convention-scanned, so there is no DI registration to add. Four things do need you.
Write the --domain-method on your aggregate first. The generated handler calls it, and the
scaffold cannot invent your business rule, so until the method exists the slice does not compile:
'Order' does not contain a definition for 'TransferToRequester'. The example (the order was opened
on behalf of the wrong customer; move it) is deliberately not a status transition: the scaffolded
ChangeStatus already owns that axis, and a second door to the same state would let callers bypass
whichever rule the new method added. It moves RequesterUserId, a field the scaffold already
persists, so nothing changes in the Shared layer or the database. Here is the whole of it, in the
two files it touches.
The rule goes in the invariants class, not in the method, so it can be composed with
Result.Combine and asserted directly in a domain test. It reuses a rule the scaffold already
enforces for comments: Closed is terminal:
// Source/Modules/Orders/Contoso.Support.Orders.Domain/Orders/OrderInvariants.cs
public static Result EnsureStatusAllowsTransfer(OrderStatus status, string source)
=> status == OrderStatus.Closed
? Result.Failure(Error.Invariant(
code: "Order.Transfer.Closed",
message: "A closed order cannot be transferred to another requester.",
source: source,
target: nameof(status)))
: Result.Success();
And the method itself:
// Source/Modules/Orders/Contoso.Support.Orders.Domain/Orders/Order.cs
public Result TransferToRequester(int requesterUserId)
{
if (RequesterUserId == requesterUserId)
{
return Result.Success();
}
var validation = OrderInvariants.EnsureStatusAllowsTransfer(Status, nameof(TransferToRequester));
if (validation.IsFailure)
{
return validation;
}
RequesterUserId = requesterUserId;
AddDomainEvent(new OrderChanged(DomainEntityState.Updated, Id));
return Result.Success();
}
Four things in that shape are the conventions, not decoration. It returns Result rather than
throwing, which is what lets the handler short-circuit on IsFailure and the edge map the failure to
RFC 9457 ProblemDetails. Transferring an order to the requester it already has succeeds rather than
failing, so a retried command is not an error. AddDomainEvent is what makes the change observable
in-process after SaveChanges. And the mutation goes through the aggregate, never through the
handler setting RequesterUserId itself. ChangeStatus in
Phase 3a is the same shape with a
different rule.
Give the command its payload. --domain-method carries only a name, so the scaffolded record
holds just the aggregate id and the generated handler calls order.TransferToRequester() with no
arguments. Two one-line edits finish the slice: add the field to the command record, and pass it at
the call site (rewrite the scaffolded summary comments while you are in there; they describe the
delete slice the template is staged from):
public sealed record TransferOrderCommand(OrderIdentifierType OrderId, int RequesterUserId) : ICacheInvalidating
var result = order.TransferToRequester(command.RequesterUserId);
Name a child collection when the handler needs one eager-loaded. Both slices load through
GetByIdAsync, whose includes: argument is required, so there is always a list. The
--child-collection Comments above names the one collection a scaffolded Order owns; pass whichever
navigation that handler needs, and pass a name your aggregate actually has, since this is the argument
that ends up inside nameof(...). Leave the parameter off and the handler passes an empty list, which
is what you want when the command only touches the aggregate root.
Keep the query's CacheKey inside your module's *CacheKeys.Prefix, because a key that drifts
out of the prefix goes stale silently.
A second module across all five layers plus its test and migrations projects:
pwsh build/add-module.ps1 -Name Billing -Aggregate Invoice
That script ships inside every solution mmca-app generates. It runs dotnet new mmca-module (the
same shape options, as PowerShell switches) and then applies all seven wire-ups the template can only
print, because dotnet new cannot patch files that already exist: the solution entries, the host and
architecture-test project references, the identifier-alias link, the five architecture-map lines,
AddErrorResources, the module's own Aspire database and data-source routing, and the first EF
migration. Until those are done the module is invisible to the host and to the fitness rules. The
templates guide documents the script and lists each wire-up as the manual
fallback.
Surface the slice at the edge
The scaffold stops at the handler, and the template's closing instructions tell you to map the
command in your module's controller. Every write in the generated app follows the same four touch
points, with ChangeStatus as the worked example to mirror in each one.
A request record in Shared, carrying only the payload; the order id comes from the route, the
same split ChangeOrderStatusRequest uses:
// Source/Modules/Orders/Contoso.Support.Orders.Shared/Orders/TransferOrderRequest.cs
public sealed record TransferOrderRequest(int RequesterUserId);
A controller endpoint. Reads come from EntityControllerBase; writes inject their handler
directly. Add ICommandHandler<TransferOrderCommand, Result> transferHandler to the controller's
primary constructor and map it:
/// <summary>Transfers an order to another requester.</summary>
[HttpPut("{id}/transfer")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> TransferAsync(
OrderIdentifierType id,
TransferOrderRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var result = await transferHandler.HandleAsync(
new TransferOrderCommand(id, request.RequesterUserId),
cancellationToken).ConfigureAwait(false);
return result.IsFailure ? HandleFailure(result.Errors) : NoContent();
}
The command returns plain Result, so success maps to 204 No Content; a command that returns the
refreshed DTO maps to Ok(result.Value) the way ChangeStatusAsync does.
A method on the typed client (SupportApiClient in the UI host), which runs server-side and
reaches the API through Aspire service discovery, so there is no CORS and no token. The client
returns a Result and throws nothing for a server answer, so the method signature says it can
fail:
public Task<Result> TransferOrderAsync(int id, int requesterUserId, CancellationToken cancellationToken = default) =>
HttpResultExecutor.ExecuteAsync(
async () =>
{
using var response = await httpClient
.PutAsJsonAsync(string.Create(CultureInfo.InvariantCulture, $"/Orders/{id}/transfer"), new { RequesterUserId = requesterUserId }, cancellationToken)
.ConfigureAwait(false);
return await ProblemDetailsResultReader.ReadAsync(response, cancellationToken).ConfigureAwait(false);
},
cancellationToken);
The two halves are what make the signature honest.
ProblemDetailsResultReader (MMCA.Common.Shared.Http) converts the response: a 2xx is a
success, and anything else is parsed back out of the RFC 9457 body into the errors the server
described, with the original ErrorType preserved. HttpResultExecutor (MMCA.Common.UI.Services)
converts the absence of a response: a refused connection, a DNS failure, a dropped socket or a
client timeout becomes a failure coded Http.TransportFailure or Http.Timeout instead of an
exception. Your own cancellation still propagates, so a disposed component is never reported back
as an error to render.
A method that returns a value reads the same way, with the generic overload:
public Task<Result<OrderDTO>> GetOrderAsync(int id, CancellationToken cancellationToken = default) =>
HttpResultExecutor.ExecuteAsync(
async () =>
{
using var response = await httpClient
.GetAsync(new Uri(string.Create(CultureInfo.InvariantCulture, $"Orders/{id}"), UriKind.Relative), cancellationToken)
.ConfigureAwait(false);
return await ProblemDetailsResultReader.ReadAsync<OrderDTO>(response, cancellationToken: cancellationToken).ConfigureAwait(false);
},
cancellationToken);
Neither type needs registering: both are static, so the client just needs
using MMCA.Common.Shared.Abstractions; (for Result), using MMCA.Common.Shared.Http; and
using MMCA.Common.UI.Services;. The host still registers only
AddHttpClient<SupportApiClient>(...).
The page plus its resource pair. Add a panel to OrderDetail.razor shaped like the Status one (a
MudNumericField for the new requester id and a button), with a @code handler shaped like
ChangeStatusAsync. Because the client returns a Result, the handler branches instead of
catching:
var result = await Api.TransferOrderAsync(Id, _transferRequesterUserId);
if (result.IsSuccess)
{
Snackbar.Add(L["Snackbar.Transferred"], Severity.Success);
await LoadAsync();
}
else
{
Snackbar.Add(L["Snackbar.TransferFailed", result.LocalizedErrorMessage(L) ?? string.Empty], Severity.Error);
}
LocalizedErrorMessage comes from ResultUiExtensions (@using MMCA.Common.UI.Common in
_Imports.razor); it composes the failure's distinct messages, most severe first, resolving each as
a resource key with pass-through, so a message the API already translated renders as-is. For a
result that carries a value, result.TryGetValue(out var dto) unwraps it inside the same
conditional. A form that wants an inline block rather than a snackbar can drop the shared
<ErrorSummary Result="_result" Localizer="L" /> component into its markup instead, which renders
nothing when there is nothing to say.
Every new L[...] key needs an entry in both OrderDetail.resx and OrderDetail.es.resx; a
key missing from one language renders as the raw key name, not a fallback.
Two conventions pay off here without extra work. The command's ICacheInvalidating prefix means the
page's reload after a transfer reads fresh data, not a stale cache entry. And transferring a closed
order exercises the whole error pipeline end to end: the invariant fails, HandleFailure maps it to
RFC 9457 ProblemDetails, ProblemDetailsResultReader reads it back into a failed Result with its
category intact, and the snackbar shows "A closed order cannot be transferred to another requester."
Then what
- Upgrade the framework. Bump every
MMCA.Common.*entry inDirectory.Packages.propstogether, in one pass. See Phase 7 and the versioning policy. - Add real authentication. Copy MMCA.Store's or MMCA.ADC's Identity module and rename the
namespaces (Store's is local-credential + RS256 only, the simpler base), set
Authentication:JwtBearer:Authority, and flip the controller back to[Authorize]. See Phase 2. - Extract a module into its own service. The generated solution already carries the plumbing (the
.Contractsproto convention and the.ServiceOpenAPI block). Your module code does not change: only host wiring and transport are added. See Phase 8.
Verification checklist
dotnet new mmca-app -n <YourApp>produced a solution that builds and tests green before you changed anything.dotnet build <YourApp>.slnxis warning-free (TreatWarningsAsErrors + five analyzers). This is the primary automatable gate.dotnet test --solution <YourApp>.slnxpasses all three projects with no database, including your own frozen integration-event contract.dotnet ef migrations add InitialCreate ...succeeds and generates your aggregate, its child entity, and the per-databaseOutboxMessagestable.- Run interactively: the dashboard shows
sql,web, anduihealthy; aPOSTthenGETreturns 201 then 200 with audit fields stamped, soft-deleted rows filtered, and an outbox row written.
Where to look next
- Templates: every parameter of all four templates, dropping the Blazor UI host, and how the pack is built. ADR-065 explains why it is derived from the reference app rather than maintained beside it.
- Small apps: the same scaffold with
--database sqlite --no-aspire, the lowest floor the framework has, plus the honest list of what that shape gives up. - Build by hand: the same solution, phase by phase, for retrofitting an existing solution or for understanding what you were handed.
- MMCA.Helpdesk: the runnable reference app. It is the template content, staged at pack time, so what you generated is that repo under your own names.
- Build MMCA.ECommerce: the next step after this guide: the same scaffold taken to a two-module store (Products + Orders with line items), with the minimum hand-written code.
- The ADRs (index): the why behind every pattern you just used.
- The onboarding guide: a type-by-type tour of the framework internals.
- MMCA.ADC and MMCA.Store: two complete, production apps to copy patterns from. ADC is the richer one (four modules, OAuth social login, SignalR notifications); Store is the simpler one.