Guides & specifications
Building on MMCA.Common by Hand
This is the long-form walkthrough: every project, every file, and every load-bearing line that goes into an application on the MMCA.Common framework, in the order you would create them. 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.
Starting a brand-new solution? Do not type phases 1 through 6.
dotnet new mmca-appwrites all of it, green, in one command. Take the Getting Started path instead, and come back here for the why.This page is for the two cases the scaffold does not cover: adding the framework to a solution that already exists, and understanding what the scaffold handed you well enough to change it safely. Phases 7 (upgrading) and 8 (extracting a service) are not scaffolded at all, and apply to every app however it was created.
The walkthrough builds monolith-first (the fastest path to a running app), then shows the
extraction of one module into its own service behind a gateway, so the "extract later" promise is
concrete rather than theoretical. Each phase opens with a note saying whether mmca-app generates it.
The worked names below are Contoso.Support (the solution), Orders (the first business module),
and Order (its aggregate root): the same names the templates guide uses, so
the two read together. Substitute your own throughout.
Everything here traces to real, working code. Wherever a step says "pattern source", that points at
MMCA.Helpdesk, the runnable reference app whose Tickets
module is the exact shape of the Orders module below (it is also the template content, staged at
pack time), or at the two production apps, MMCA.ADC and MMCA.Store.
Reading the framework itself: for the why behind each pattern, read the relevant ADR. For a type-by-type tour of the framework internals, see the onboarding guide.
What you will build
A modular monolith with one business module and two hosts:
- Orders (your business module): an
Orderaggregate withOrderCommentchildren, opened through aResult-returning factory, mutated through guarded methods that raise domain events, and exposed end-to-end through a REST controller. This is the module you later extract into its own service. - A Web API host (the monolith) and a Blazor Server + MudBlazor UI host that calls the API server-side through Aspire service discovery.
It runs issuer-less: with no Identity module it registers a bare auth scheme and the
controller is [AllowAnonymous], so you get a running app immediately. Identity is the standard
auth module you add when you want real RS256/JWKS authentication (and which becomes the JWKS issuer
when you extract Orders): that path is shown in Phase 2 and Phase 8.
By the end you will have a green-building solution, applied EF migrations, a running Aspire stack
(sql + web + ui), and passing architecture-fitness tests, plus a documented path to add Identity
and pull Orders out into a microservice.
Phase 0: Prerequisites and decisions
Install:
- .NET 10 SDK (the framework targets
net10.0withLangVersion: previewfor C# extension types). - SQL Server reachable locally (LocalDB, a container, or the one Aspire starts for you).
- Docker Desktop (Aspire provisions SQL Server as a container for the monolith; Redis and RabbitMQ join in the extraction phase).
- EF Core tools:
dotnet tool install --global dotnet-ef.
Decide how to consume MMCA.Common (two modes, switchable in one file):
- NuGet (nuget.org) is the production path for any standalone app. The
MMCA.Common.*packages are published to nuget.org, sodotnet add package MMCA.Common.API(or any other package in the set) restores with no token, no credentials, and no extra feed. GitHub Packages is kept as a mirror of the same releases rather than the primary path: its NuGet registry requires a personal access token withread:packageseven for public packages, so reach for it only if you already restore from it. See ADR-053. - Local source (
UseLocalMMCA) references../MMCA.Common/Source/directly vialocal.props. Use this when your app sits in the same workspace as MMCA.Common and you want to co-develop the framework and the app together. It needs no token (MMCA.Common itself restores only from nuget.org).
When using local source mode, after editing MMCA.Common source you must rebuild MMCA.Common in Debug before your app, or the IDE binds the stale last-built Debug reference assembly and reports phantom
CS0103errors against new members. Build MMCA.Common with-c Debug, then build your app.
Pick the framework version. Every package moves together. Use the latest released tag
(see FACTS.md for the current version; the 1.135.0 in the samples below was
current when this guide was last refreshed). Choose one version and use it for every MMCA.Common.* entry (Phase 1). See
ADR-016: there is no phased rollout and no version
skew across the set. The rule is enforced, not just advisory: the shared
FrameworkVersionConsistencyTestsBase fitness rule (Phase 6) fails the build when the MMCA.Common.*
pins in Directory.Packages.props disagree, so a half-finished sweep cannot ship.
Phase 1: Create the solution and the build plumbing
Scaffolded.
dotnet new mmca-appwrites every file in this phase. Read it to know what each one does; you do not need to type any of it.
The plumbing files are the load-bearing, easy-to-get-wrong part: about 1,170 lines across eight
files, including an 823-line .editorconfig and 114 package pins. Copy them from MMCA.Helpdesk
(already a trimmed single-module scaffold) or from MMCA.ADC. Lay out the repo like this:
Contoso.Support/
Contoso.Support.slnx
Directory.Build.props
Directory.Build.targets (local-source swap: PackageReference -> ProjectReference when UseLocalMMCA)
Directory.Packages.props
global.json
nuget.config
local.props.template (copy to local.props for local-source mode; gitignore it in a real app)
.editorconfig (copy MMCA.ADC's verbatim; it drives the 5 analyzers)
.gitignore
Source/
Modules/ (one folder per business module: Orders)
Hosts/ (runnable entry points)
Contoso.Support.Web (the monolith REST API host)
UI/Contoso.Support.UI.Web (the Blazor Server + MudBlazor front end)
Hosting/ (Aspire AppHost + per-DB migrations projects)
Services/ (added later, in the extraction phase)
Tests/
Modules/ Architecture/ (the reference app ships three projects here; Integration/E2E are optional adds)
Directory.Packages.props (Central Package Management)
Versions live here, not in individual .csproj files. List every MMCA.Common.* package you
consume at one version (the count is owned by FACTS.md), and keep MassTransit pinned
to v8 (v9 needs a commercial license, enforced by a build gate in MMCA.Common, see
ADR-016):
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- MMCA Common packages: all at one version, bumped in lockstep -->
<PackageVersion Include="MMCA.Common.Shared" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Domain" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Application" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Infrastructure" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.API" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Grpc" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.UI" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.UI.Web" Version="1.135.0" />
<!-- MAUI heads only: the one MAUI-TFM package (ADR-042); web-only apps skip it -->
<PackageVersion Include="MMCA.Common.UI.Maui" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Aspire" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Aspire.Hosting" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Testing" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Testing.E2E" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Testing.UI" Version="1.135.0" />
<PackageVersion Include="MMCA.Common.Testing.Architecture" Version="1.135.0" />
<!-- Third-party versions: copy the relevant rows from MMCA.ADC/Directory.Packages.props -->
<!-- (EF Core, FluentValidation, Riok.Mapperly, Scrutor, xunit.v3, Aspire.*, Yarp, the 5 analyzers, etc.) -->
</ItemGroup>
</Project>
Then in each .csproj you reference a package with no version:
<PackageReference Include="MMCA.Common.Domain" />.
Directory.Build.props
This sets the language/build mode, wires the five analyzers at error severity, links the per-module
identifier-alias files into every project, and declares the .Contracts gRPC convention. Copy
MMCA.Helpdesk/Directory.Build.props and adapt the module-alias <Compile Include ... Link> block to
your modules. The critical pieces:
<Project>
<!-- Optional: local.props sets UseLocalMMCA; the actual PackageReference -> ProjectReference swap
lives in a companion Directory.Build.targets (copy MMCA.Helpdesk's verbatim). -->
<Import Project="local.props" Condition="Exists('local.props')" />
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<AnalysisMode>All</AnalysisMode>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591;RMG020;EXTEXP0001</NoWarn>
</PropertyGroup>
<!-- The five analyzers, all at error severity (Meziantou, VS.Threading, Roslynator, Sonar, StyleCop) -->
<ItemGroup Condition="'$(MSBuildProjectExtension)' != '.dcproj'">
<PackageReference Include="Meziantou.Analyzer"> ... </PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers"> ... </PackageReference>
<PackageReference Include="Roslynator.Analyzers"> ... </PackageReference>
<PackageReference Include="SonarAnalyzer.CSharp"> ... </PackageReference>
<PackageReference Include="StyleCop.Analyzers"> ... </PackageReference>
</ItemGroup>
<!-- Identifier-type aliases linked into all projects (one block per module Shared project) -->
<ItemGroup Condition="'$(MSBuildProjectExtension)' != '.dcproj'">
<Compile Include="$(MSBuildThisFileDirectory)Source\Modules\Orders\Contoso.Support.Orders.Shared\Contoso.Support.Orders.GlobalUsings.IdentifierType.cs"
Link="GlobalUsings\Contoso.Support.Orders.GlobalUsings.IdentifierType.cs"
Condition="'$(MSBuildProjectName)' != 'Contoso.Support.Orders.Shared'" />
<!-- ...one more block per additional module's *.Shared alias file -->
</ItemGroup>
<!-- .Contracts convention: any *.Contracts project auto-compiles Protos/**/*.proto (server + client) -->
<ItemGroup Condition="$(MSBuildProjectName.EndsWith('.Contracts'))">
<PackageReference Include="Grpc.Tools"> ... </PackageReference>
<PackageReference Include="Google.Protobuf" />
<PackageReference Include="Grpc.Net.ClientFactory" />
<Protobuf Include="Protos\**\*.proto" GrpcServices="Both" />
</ItemGroup>
</Project>
Why the identifier-alias linking matters. Each module declares
global using {Entity}IdentifierType = int;(orGuid) in one file in its*.Sharedproject. The<Compile Include ... Link>block makes that alias visible in every project solution-wide. Always use the alias (OrderIdentifierType), never the rawint. See the Entity Identifier Convention inMMCA.Common/CLAUDE.md.
global.json, nuget.config, local.props.template
// global.json: all three apps run on Microsoft Testing Platform (xUnit v3), not VSTest
{ "test": { "runner": "Microsoft.Testing.Platform" } }
<!-- nuget.config (NuGet mode): everything, MMCA.* included, comes from nuget.org. No credentials.
The single `*` mapping is also supply-chain hygiene: it stops a package from another feed being
substituted for one you expect. This is MMCA.Common's own nuget.config shape. -->
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org"><package pattern="*" /></packageSource>
</packageSourceMapping>
</configuration>
You only need the GitHub Packages mirror in two cases: you are pinning a version released before
nuget.org publishing began (there was no backfill, so those versions exist on GitHub Packages only),
or your organization already restores MMCA.* from that feed. In that case add the feed beside
nuget.org, source-map MMCA.* to it, and supply a token. Note the auditSources block: GitHub
Packages serves no NuGet vulnerability data, so an unrestricted audit reports NU1900 against it.
<!-- Optional: MMCA.* from the GitHub Packages mirror. Needs a token with read:packages. -->
<configuration>
<packageSources>
<add key="github-mmca" value="https://nuget.pkg.github.com/ivanball/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<auditSources> <!-- GitHub Packages serves no vuln data; restrict audit to nuget.org -->
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</auditSources>
<packageSourceMapping>
<packageSource key="github-mmca"><package pattern="MMCA.*" /></packageSource>
<packageSource key="nuget.org"><package pattern="*" /></packageSource>
</packageSourceMapping>
<packageSourceCredentials>
<github-mmca>
<add key="Username" value="<your-user>" />
<add key="ClearTextPassword" value="%GITHUB_TOKEN%" />
</github-mmca>
</packageSourceCredentials>
</configuration>
<!-- local.props.template: copy to local.props (gitignored) to build against MMCA.Common source.
Directory.Build.targets reads UseLocalMMCA and swaps each MMCA.Common.* PackageReference for a
ProjectReference under LocalMMCAPath. MMCA.Helpdesk ships local.props ACTIVE (so it builds
straight against framework source); a generated app only gets one with --local-mmca. -->
<Project>
<PropertyGroup>
<UseLocalMMCA>true</UseLocalMMCA>
<LocalMMCAPath>$(MSBuildThisFileDirectory)..\MMCA.Common\Source\</LocalMMCAPath>
</PropertyGroup>
</Project>
Checkpoint: dotnet build Contoso.Support.slnx succeeds on an empty solution (no projects yet, but
the plumbing parses).
Phase 2: Scaffold the module project set
Scaffolded.
dotnet new mmca-appcreates this project set for your first module, anddotnet new mmca-moduleadds another one later (it prints the five wire-ups it cannot perform: the solution entries, the host and architecture-test project references, the identifier-alias link, the map lines, andAddErrorResources). See the templates guide.
Each business module is a set of layered projects under Source/Modules/<Module>/. Pattern source:
MMCA.Helpdesk/Source/Modules/Tickets/ (or the richer MMCA.ADC/Source/Modules/Conference/). For
Orders:
| Project | References | Holds |
|---|---|---|
Contoso.Support.Orders.Shared |
MMCA.Common.Shared, MMCA.Common.Domain |
DTOs, request records, identifier aliases, the status enum, integration events |
Contoso.Support.Orders.Domain |
MMCA.Common.Domain |
aggregate, child entities, invariants, domain events |
Contoso.Support.Orders.Application |
MMCA.Common.Application, Riok.Mapperly, the Shared + Domain projects |
use cases (command/query + handler), validators, mappers, event handlers, module DI |
Contoso.Support.Orders.Infrastructure |
MMCA.Common.Infrastructure, the Application project |
EF entity configurations, the abstract module DbContext, infra DI |
Contoso.Support.Orders.API |
MMCA.Common.API, the Application + Infrastructure projects |
REST controller, the IModule, the module-composition DI |
The layering is enforced twice by the framework (compile-time MSBuild guard + the NetArchTest rules in Phase 6), so a forbidden reference fails the build. See ADR-015.
Identity is optional and omitted here. Running issuer-less (a bare auth scheme + an
[AllowAnonymous] controller) is the fastest path to a running app. Add an
Identity module when you want real RS256/JWKS authentication or are about to extract a service (Phase
8): the fastest start is to copy MMCA.Store's or MMCA.ADC's Identity module and rename the namespaces
(Store's Identity is local-credential + RS256 only, the simpler base), then set
Authentication:JwtBearer:Authority and flip the controller back to [Authorize]. Identity is
intentionally generic across apps.
Phase 3: The vertical slice end-to-end (the heart of it)
Scaffolded. The generated module already contains this slice and six more, worked end to end.
dotnet new mmca-commandanddotnet new mmca-queryadd another one. This phase is the one to actually read: it is the path every feature you add will follow.
Implement Orders create and read. This traces the same path for every feature you will ever add (the
reference app then repeats it for update, delete, status change, and comment add/edit/remove). Pattern
source for each step is the MMCA.Helpdesk Ticket aggregate, which is this code under its own names.
3a. Domain: aggregate, invariants, events
Entities inherit the framework hierarchy: BaseEntity<TId> to AuditableBaseEntity<TId> (adds
soft-delete IsDeleted + audit fields) to AuditableAggregateRootEntity<TId> (adds the domain-events
collection and child-collection helpers). Aggregates use factory methods that return Result<T>,
never public constructors. See ADR-013.
Use database-generated integer ids (the [IdValueGenerated] attribute + the
OrderIdentifierType = int alias), as the reference app does. That has one important consequence:
the factory does not raise an "Added" domain event, because the id is still 0 at that point:
creation is signalled after the
commit by an integration event (see 3c). Mutations raise a single OrderChanged domain event
(EntityChangedEvent-derived) carrying the lifecycle state.
// Source/Modules/Orders/Contoso.Support.Orders.Domain/Orders/Order.cs
[IdValueGenerated]
public sealed class Order : AuditableAggregateRootEntity<OrderIdentifierType>
{
public string Title { get; private set; }
public string Description { get; private set; }
public OrderStatus Status { get; private set; }
public int RequesterUserId { get; private set; } // resolved from Identity once you add it
private readonly List<OrderComment> _comments = [];
[Navigation(IsCollection = true)]
public IReadOnlyCollection<OrderComment> Comments => _comments.AsReadOnly();
private Order(string title, string description, int requesterUserId) // EF + factory materializer
{
Title = title;
Description = description;
RequesterUserId = requesterUserId;
Status = OrderStatus.Open;
}
public static Result<Order> Create(OrderIdentifierType? id, string title, string description, int requesterUserId)
{
var validation = Result.Combine(
OrderInvariants.EnsureTitleIsValid(title, nameof(Create)),
OrderInvariants.EnsureDescriptionIsValid(description, nameof(Create)));
if (validation.IsFailure) { return Result.Failure<Order>(validation.Errors); }
// When the id is DB-generated, leave it default; the supplied id is only used for engines that
// do not generate keys. No "Added" domain event here (the id is still 0).
var order = new Order(title, description, requesterUserId)
{
Id = typeof(Order).IsIdValueGenerated ? default : id!.Value,
};
return Result.Success(order);
}
public Result ChangeStatus(OrderStatus newStatus)
{
if (Status == newStatus) { return Result.Success(); }
Status = newStatus;
AddDomainEvent(new OrderChanged(DomainEntityState.Updated, Id)); // dispatched after SaveChanges
return Result.Success();
}
public override Result Delete() // soft-delete + cascade to comments
{
var result = base.Delete();
if (result.IsFailure) { return result; }
foreach (var comment in _comments.Where(c => !c.IsDeleted)) { comment.Delete(); }
AddDomainEvent(new OrderChanged(DomainEntityState.Deleted, Id));
return result;
}
// AddComment / EditComment / RemoveComment / UpdateDetails follow the same shape: guard with an
// invariant, mutate, AddDomainEvent(new OrderChanged(DomainEntityState.Updated, Id)).
}
Invariants are static methods returning Result (each takes a source for error provenance),
combined with Result.Combine(...). Use Error.Invariant(...) for broken business rules:
public static class OrderInvariants
{
public const int TitleMaxLength = 200;
public static Result EnsureTitleIsValid(string title, string source) =>
string.IsNullOrWhiteSpace(title) || title.Length > TitleMaxLength
? Result.Failure(Error.Invariant(
code: "Order.Title.Invalid",
message: $"Title is required and must be at most {TitleMaxLength} characters.",
source: source,
target: nameof(title)))
: Result.Success();
}
OrderComment inherits AuditableBaseEntity<OrderCommentIdentifierType> and is [IdValueGenerated]
too, with its own Create(...) factory. Manage children through the aggregate root: Order
exposes AddComment / EditComment / RemoveComment that validate, mutate _comments, and raise
OrderChanged: never mutate the collection or a comment from outside the aggregate.
3b. Shared: DTO, request, integration event, aliases
// Contoso.Support.Orders.GlobalUsings.IdentifierType.cs (linked solution-wide via Directory.Build.props)
global using OrderIdentifierType = int;
global using OrderCommentIdentifierType = int;
The read model implements IBaseDTO<TId> (the framework read-side contract) and carries its children.
It also implements IConcurrencyAware, exposing the RowVersion the client echoes back on an
update so a conflicting concurrent edit surfaces as 409 instead of silently last-write-winning (see
ADR-035):
public record class OrderDTO : IBaseDTO<OrderIdentifierType>, IConcurrencyAware
{
public required OrderIdentifierType Id { get; init; }
public byte[]? RowVersion { get; init; } // ADR-035: echoed back on update
public required string Title { get; init; }
public required string Description { get; init; }
public required OrderStatus Status { get; init; } // the enum itself; serialized by name
public required int RequesterUserId { get; init; }
public IReadOnlyCollection<OrderCommentDTO> Comments { get; init; } = [];
}
// Integration events derive BaseIntegrationEvent, which supplies SchemaVersion (default 1,
// fitness-enforced): a breaking change uses a NEW event type + upcaster, never a reshape. See ADR-010.
public sealed record class OrderOpenedIntegrationEvent(OrderIdentifierType OrderId, int RequesterUserId)
: BaseIntegrationEvent;
Plain request bodies (e.g. OrderUpdateRequest, AddCommentRequest) live in Shared too. Name the
update body with the *UpdateRequest suffix and have it implement IConcurrencyAware: that exact
pairing is what the ConcurrencyConventionTestsBase fitness rule looks for (Phase 6), so an update
request that skips the token fails the build rather than quietly losing a concurrent edit.
public sealed record class OrderUpdateRequest : IConcurrencyAware
{
public byte[]? RowVersion { get; init; } // the token the client last read
public required string Title { get; init; }
public required string Description { get; init; }
}
The create request doubles as the command and is co-located with its use case in Application (next
section); it implements ICacheInvalidating so a successful create evicts cached order reads:
// Source/Modules/Orders/.../Application/Orders/UseCases/Create/OrderCreateRequest.cs
public record class OrderCreateRequest : ICreateRequest, ICacheInvalidating
{
public string CachePrefix => OrderCacheKeys.Prefix;
public required string Title { get; init; }
public required string Description { get; init; }
public required int RequesterUserId { get; init; }
}
OrderCacheKeys is the one place the module names cache entries, and the reason it exists is worth
reading in "Caching is a pair" below.
3c. Application: use case, validator, mapper, DI
A command handler implements ICommandHandler<TCommand, TResult> and stays thin: the decorator
pipeline supplies logging, caching, validation, and the transaction around it. The request is turned
into the aggregate by an IEntityRequestMapper (which calls the domain factory), and a FluentValidation
validator + the Mapperly *RequestMapper/*DTOMapper are all auto-discovered by convention scanning.
Because the id is DB-generated, the handler publishes the integration event after the commit, when
the real id exists:
public sealed class CreateOrderHandler(
IUnitOfWork unitOfWork,
IEntityRequestMapper<Order, OrderCreateRequest, OrderIdentifierType> requestMapper,
IEventBus eventBus,
OrderDTOMapper dtoMapper) : ICommandHandler<OrderCreateRequest, Result<OrderDTO>>
{
public async Task<Result<OrderDTO>> HandleAsync(OrderCreateRequest command, CancellationToken cancellationToken = default)
{
var result = await requestMapper.CreateEntityAsync(command, cancellationToken); // runs Order.Create
if (result.IsFailure) { return Result.Failure<OrderDTO>(result.Errors); }
var entity = result.Value!;
var repository = unitOfWork.GetRepository<Order, OrderIdentifierType>();
await repository.AddAsync(entity, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken); // stamps audit, captures domain events to outbox, dispatches
// After commit: the DB-generated entity.Id is now populated. PublishAsync writes to the outbox
// and dispatches in-process now, over the broker once Orders is extracted (no handler change).
await eventBus.PublishAsync(
new OrderOpenedIntegrationEvent(entity.Id, entity.RequesterUserId), cancellationToken);
return Result.Success(dtoMapper.MapToDTO(entity));
}
}
Module DI uses C# extension types (the framework's registration idiom). You make a few explicit
registrations (the entity query service that powers read endpoints, and a navigation populator), then
let ScanModuleApplicationServices find your handlers, validators, mappers, and event handlers by
convention:
public static class DependencyInjection
{
extension(IServiceCollection services)
{
public IServiceCollection AddModuleOrdersApplication(ApplicationSettings applicationSettings)
{
// A Null populator suffices when eager loading goes through repository includes; swap for a
// custom INavigationPopulator<Order> to batch-load comments instead.
services.TryAddScoped<INavigationPopulator<Order>, NullNavigationPopulator<Order>>();
services.TryAddScoped<IEntityQueryService<Order, OrderDTO, OrderIdentifierType>,
EntityQueryService<Order, OrderDTO, OrderIdentifierType>>();
services.ScanModuleApplicationServices<ClassReference>(); // ClassReference = an anchor type in this assembly
return services;
}
}
}
That scan is also what picks up the consuming side of both event paths, and the reference app ships one of each so the distinction is visible in running code rather than only in prose:
Orders/DomainEventHandlers/OrderChangedAuditHandlerimplementsIDomainEventHandler<OrderChanged>. Intra-module, dispatched in-process byDomainEventDispatcherafterSaveChangesAsync(deferred until after the commit when a transaction is open, so a handler never acts on state that rolls back).Orders/IntegrationEventHandlers/OrderOpenedHandlerimplementsIIntegrationEventHandler<OrderOpenedIntegrationEvent>. Cross-module, delivered through the outbox: in-process today, over the broker once Orders is extracted, with no change to this handler.
Neither is registered by hand. Put a handler in the module's Application assembly and the convention scan finds it.
3d. Infrastructure: EF configuration, the abstract module context, no concrete per-module context
There is exactly one concrete context at runtime, the framework's sealed SQLServerDbContext (one
instance per database). Each module declares an abstract ModuleApplicationDbContext : ApplicationDbContext that only lists the module's DbSets: it documents the module's entity set
and never gets instantiated. Never write a concrete per-module or per-app DbContext class (see
ADR-006 and the "Don't split SQLServerDbContext" rule):
public abstract class ModuleApplicationDbContext(
DbContextOptions options, IServiceProvider serviceProvider,
IEntityConfigurationAssemblyProvider assemblyProvider, PhysicalDataSource physicalDataSource)
: ApplicationDbContext(options, serviceProvider, assemblyProvider, physicalDataSource)
{
internal DbSet<Order> Orders { get; set; }
internal DbSet<OrderComment> OrderComments { get; set; }
}
You supply EF configurations that inherit EntityTypeConfigurationSQLServer<TEntity, TId> (the base
wires Id, IsDeleted + soft-delete query filter, audit fields, and the concurrency token):
internal sealed class OrderConfiguration : EntityTypeConfigurationSQLServer<Order, OrderIdentifierType>
{
public override void Configure(EntityTypeBuilder<Order> builder)
{
base.Configure(builder); // Id, IsDeleted + filter, audit fields, concurrency token
builder.Property(t => t.Title).HasMaxLength(OrderInvariants.TitleMaxLength).IsRequired();
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
builder.HasMany(t => t.Comments).WithOne(c => c.Order).HasForeignKey(c => c.OrderId).IsRequired();
}
}
Configurations are auto-discovered by assembly-name convention (the module's Infrastructure
assembly is registered for the design-time factory and the host), so the module's
Infrastructure/DependencyInjection.cs is a near no-op: AddModuleOrdersInfrastructure() just
returns services. You obtain a repository through
IUnitOfWork.GetRepository<Order, OrderIdentifierType>(); you never hand-write a context or a
repository class. The three module layers are composed in the API layer's DI
(AddOrdersModule calls Application + Infrastructure + API), which the module's IModule.Register
invokes (see Phase 5).
3e. API: controller and error mapping
Read endpoints (get-all / paged) come for free from EntityControllerBase<TEntity, TDTO, TId>, which
you parameterize with the entity query service; write endpoints inject handlers directly. On failure,
HandleFailure(result.Errors) maps the transport-agnostic ErrorType to the right HTTP status as RFC
9457 ProblemDetails (Validation/Invariant to 400, NotFound to 404, Conflict to 409, Unauthorized to
401, Forbidden to 403). See ADR-013. The controller is
[AllowAnonymous] while the app runs issuer-less; flip it to [Authorize] once you add Identity.
[ApiController]
[Route("[controller]")]
[ApiVersion("1.0")]
[AllowAnonymous] // issuer-less seed; switch to [Authorize] after adding Identity (Phase 8)
public sealed class OrdersController(
IEntityQueryService<Order, OrderDTO, OrderIdentifierType> queryService,
ICommandHandler<OrderCreateRequest, Result<OrderDTO>> createHandler,
ILogger<OrdersController> logger)
: EntityControllerBase<Order, OrderDTO, OrderIdentifierType>(queryService, logger)
{
[HttpPost]
public async Task<ActionResult<OrderDTO>> CreateAsync(OrderCreateRequest request, CancellationToken cancellationToken)
{
var result = await createHandler.HandleAsync(request, cancellationToken);
if (result.IsFailure) { return HandleFailure(result.Errors); }
// Build the Location URI directly: CreatedAtAction against the versioned base GetById route
// throws "No route matches the supplied values".
var dto = result.Value!;
return Created(new Uri($"Orders/{dto.Id}", UriKind.Relative), dto);
}
}
The reference app's controller goes further: GET {id}/details, PUT {id}, PUT {id}/status,
DELETE {id}, and POST|PUT|DELETE {id}/comments[/{commentId}], each one the same three lines: call
the handler, HandleFailure on failure, else return the success shape. PUT {id} also closes the
ADR-035 loop by forwarding the request's RowVersion onto the command
(new UpdateOrderCommand(id, request.Title, request.Description) { RowVersion = request.RowVersion }),
which is what turns a stale write into a 409 instead of an overwrite.
What the pipeline does for you
Once AddApplicationDecorators() runs (Phase 5), every handler is wrapped by the Scrutor decorator
chain (outermost first). See ADR-014:
Commands: FeatureGate -> Logging -> Caching -> Validating -> Transactional -> your handler
Queries: FeatureGate -> Logging -> Caching -> your handler
The order is load-bearing: validation runs before the transaction opens; cache invalidation happens
after a successful commit (outside the transaction); a business Result.Failure commits the
transaction but skips cache invalidation; an exception rolls the transaction back.
Caching is a pair, and you wire both halves
The Caching decorator is two extension points that only do something together, and the framework cannot check that you wired both:
- a query implements
IQueryCacheable(CacheKey+CacheDuration) and is served from cache, - a command implements
ICacheInvalidating(CachePrefix) and, on success, evicts every entry whose key starts with that prefix.
Implement only the command half and you get a write path that faithfully invalidates an empty cache: no error, no warning, no benefit. That is the easiest thing in the whole pipeline to get half-done, so the reference app wires both. Start from the key, because the decorator matches the two sides by string prefix and nothing at compile time is watching:
// Source/Modules/Orders/.../Application/Orders/OrderCacheKeys.cs
public static class OrderCacheKeys
{
// Derived from the aggregate type name so two modules cannot collide on a shared cache.
public static string Prefix { get; } = $"{typeof(Order).FullName}:";
// Every input that changes the result belongs in the key. This read has only an id.
public static string ById(OrderIdentifierType id) =>
string.Create(CultureInfo.InvariantCulture, $"{Prefix}ById:{id}");
}
The read side opts in by implementing IQueryCacheable. The duration is a staleness budget, not
a performance knob: a write through the pipeline evicts the entry immediately, so it only bounds the
window for changes that bypass the pipeline (a migration, a manual edit, another writer on the same
database):
// Source/Modules/Orders/.../Application/Orders/UseCases/GetById/GetOrderByIdQuery.cs
public sealed record GetOrderByIdQuery(OrderIdentifierType Id) : IQueryCacheable
{
public string CacheKey => OrderCacheKeys.ById(Id);
public TimeSpan CacheDuration => TimeSpan.FromMinutes(5);
}
The write side returns the prefix its write dirties. Every order command does, from the same constant, which is the point: a hand-typed literal in one of seven commands is one rename away from a read that is never evicted, and the symptom appears later as a stale order rather than as a failure at the write.
// Source/Modules/Orders/.../Application/Orders/UseCases/AddComment/AddCommentCommand.cs
public sealed record AddCommentCommand(OrderIdentifierType OrderId, string Body, int AuthorUserId)
: ICacheInvalidating
{
public string CachePrefix => OrderCacheKeys.Prefix;
}
Two properties of the decorator are worth knowing before you rely on it:
- Invalidation runs only on success. A
Result.Failurepersisted nothing, so evicting valid entries would be pure loss. Assert this, because it is invisible in normal use. - Invalidation runs outside the transaction, after the commit. It cannot roll back with the write, which is the correct trade: a spurious eviction costs one cache miss, whereas invalidating inside the transaction would leave the cache holding pre-write state if the commit then failed.
Both halves are testable without a database. Wire the two real decorators around stub handlers and a
dictionary-backed ICacheService, and the assertions say nothing about whether the substrate is
in-memory or Redis (source, in the reference app:
Tests/Modules/Tickets/MMCA.Helpdesk.Tickets.Application.Tests/Caching/TicketCacheInvalidationTests.cs):
var cache = new DictionaryCache(); // a plain Dictionary behind ICacheService
var handler = new CountingQueryHandler(OrderResult()); // counts how often the real read is reached
var read = new CachingQueryDecorator<GetOrderByIdQuery, Result<OrderDTO>>(handler, cache);
var write = new CachingCommandDecorator<ChangeOrderStatusCommand, Result<OrderDTO>>(
new StubCommandHandler(OrderResult(OrderStatus.Closed)), cache);
await read.HandleAsync(new GetOrderByIdQuery(OrderId)); // miss: runs the handler, caches
await read.HandleAsync(new GetOrderByIdQuery(OrderId)); // hit: handler not reached
handler.Invocations.Should().Be(1);
await write.HandleAsync(new ChangeOrderStatusCommand(OrderId, OrderStatus.Closed));
await read.HandleAsync(new GetOrderByIdQuery(OrderId)); // miss again: the write evicted it
handler.Invocations.Should().Be(2);
Add the negative case (a command returning Result.Failure must leave the entry in place) and one
guard that the read key still starts with the prefix every command returns. That last test is the
one that survives a rename.
Phase 4: DbContext model and migrations
Partly scaffolded. The migrations project and its design-time factory are generated. Running
dotnet ef migrations add InitialCreateis still yours, and for a module added later the generated migrations project ships deliberately empty so the first migration describes YOUR entities.
Create one migrations project per (future) service database, even while you are a monolith. This
costs nothing now and means extraction (Phase 8) needs zero migration rework. Pattern source:
MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.Migrations.SqlServer.Tickets/.
Source/Hosting/Contoso.Support.Migrations.SqlServer.Orders/
Contoso.Support.Migrations.SqlServer.Orders.csproj (refs EF Design + SqlServer + the Orders.Infrastructure project)
DesignTimeSQLServerDbContextFactory.cs
Migrations/ (generated)
The design-time factory uses the framework helper so dotnet ef can build a per-source context:
public sealed class DesignTimeSQLServerDbContextFactory : IDesignTimeDbContextFactory<SQLServerDbContext>
{
public SQLServerDbContext CreateDbContext(string[] args) =>
DesignTimeDbContextHelper.CreateSqlServer(args, options =>
{
options.DataSourceName = "Orders";
// A placeholder top-level string keeps the helper happy; migrations add/script never connect.
options.ConnectionStrings = new ConnectionStringSettings { SQLServerConnectionString = "Server=design-time-unused;" };
options.DataSources["Orders"] = new DataSourceEntrySettings
{
SQLServerConnectionString = Environment.GetEnvironmentVariable("SUPPORT_ORDERS_SQL")
?? "Server=localhost;Database=Support_Orders;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=True",
SQLServerMigrationsAssembly = typeof(DesignTimeSQLServerDbContextFactory).Assembly.GetName().Name!,
};
options.AddConfigurationAssembly(typeof(Contoso.Support.Orders.Infrastructure.AssemblyReference).Assembly);
});
}
Add the first migration (run per migrations project, always --context SQLServerDbContext):
dotnet ef migrations add InitialCreate \
--project Source/Hosting/Contoso.Support.Migrations.SqlServer.Orders \
--startup-project Source/Hosting/Contoso.Support.Migrations.SqlServer.Orders \
--context SQLServerDbContext
At runtime the host applies migrations via the framework's InitializeDatabaseAsync(...) driven by
ApplicationSettings.DatabaseInitStrategy: Migrate (production, the host is the sole migrator),
EnsureCreated (quick local), or None (throws if migrations are pending, a safety check).
Monolith collapse: with no
DataSourcessection in config, every entity collapses onto one physical database (one context, FK constraints intact) and behaves exactly like a classic single-DB monolith. The same configurations and migrations later route to separate databases when you addDataSourcesentries in the extraction phase. This collapse is what makes "monolith now, services later" free. See ADR-006.
Phase 5: Compose the monolith host and run it
Scaffolded. Both hosts, the AppHost, and the
.resxpairs are generated. Read this phase before you touch any of them: the DI sequence,WaitFor(sql)rather than the database resource, and the AppHostlaunchSettings.jsonall fail quietly rather than loudly.
The Web host
Create Source/Hosts/Contoso.Support.Web (the REST API host). Its Program.cs follows the fixed DI
sequence, and the load-bearing rule is that AddApplicationDecorators() comes last: decorators
wrap handlers that already exist, and the module handlers are registered by ModuleLoader (each
IModule.Register composes its Application+Infrastructure+API layers):
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); // from MMCA.Common.Aspire: OpenTelemetry, health checks, resilience
var services = builder.Services;
services.AddOptions<ApplicationSettings>().Bind(builder.Configuration.GetSection(ApplicationSettings.SectionName))
.ValidateDataAnnotations().ValidateOnStart();
var applicationSettings = builder.Configuration.GetSection(ApplicationSettings.SectionName).Get<ApplicationSettings>()!;
// Health checks. SQL is REQUIRED: a host that cannot resolve its own connection string should fail
// fast rather than report healthy and take traffic it cannot serve. Redis/RabbitMQ checks are tagged
// optional upstream, so they report on /health without gating readiness.
builder.AddInfrastructureHealthChecks(requireSqlServer: true);
// Cross-cutting edge.
services.AddCommonCors(builder.Configuration);
services.AddCommonApiVersioning();
services.AddCommonRateLimiting();
services.AddOutputCache(options => options.AddBasePolicy(policy => policy.NoCache()));
services.AddCommonResponseCompression();
// Auth. Issuer-less by default: with no Authentication:JwtBearer:Authority configured, register a bare
// scheme so the pipeline is satisfied and [AllowAnonymous] endpoints work. Set the authority (after you
// add Identity) to validate RS256 tokens against its JWKS instead.
var jwtAuthority = builder.Configuration["Authentication:JwtBearer:Authority"];
if (!string.IsNullOrWhiteSpace(jwtAuthority))
{
services.AddForwardedJwtBearer(authority: jwtAuthority, audience: builder.Configuration["Jwt:Audience"] ?? "support");
}
else
{
services.AddAuthentication();
services.AddAuthorization();
}
services.AddCommonExceptionHandlers();
services.AddApplication(); // core services, event dispatcher
services.AddInfrastructure(builder.Configuration); // repos, UoW, context, caching, outbox
var modulesSettings = builder.Configuration.GetSection(ModulesSettings.SectionName).Get<ModulesSettings>() ?? [];
services.AddAPI(modulesSettings); // controllers, idempotency, exception handlers
// Contribute the module's error-code translations to the edge localizer (ADR-027), so domain
// Error.Code values come back as localized ProblemDetails messages. One call per module.
services.AddErrorResources<OrdersErrorResources>();
using var loggerFactory = LoggerFactory.Create(logging => logging.AddConsole());
var moduleLoader = new ModuleLoader { Logger = loggerFactory.CreateLogger<ModuleLoader>() };
moduleLoader.DiscoverAndRegister(services, builder.Configuration, applicationSettings, modulesSettings, builder.Environment.EnvironmentName);
services.AddSingleton(moduleLoader);
services.AddBrokerMessaging(builder.Configuration); // InProcessMessageBus until a broker is configured
services.AddApplicationDecorators(); // MUST be last
services.AddModuleHealthChecks(moduleLoader); // per-module readiness, after the modules are known
var app = builder.Build();
await app.Services.InitializeDatabaseAsync(applicationSettings, moduleLoader); // applies migrations / seeds
app.MapDefaultEndpoints(); // /health, /alive
// exception -> correlation -> request localization -> forwarded headers -> HTTPS -> compression ->
// routing -> CORS -> authentication -> rate limiting -> soft-deleted-user filter -> authorization ->
// output cache -> JWKS/OIDC endpoints -> controllers. Rate limiting sits AFTER authentication on
// purpose (ADR-019): the partition keys off the authenticated principal.
app.UseCommonMiddlewarePipeline();
await app.RunAsync();
Modules are discovered by ModuleLoader and registered in topological dependency order (Kahn's
algorithm) from their IModule implementations. OrdersModule is a leaf (no dependencies); its
Register(...) calls AddOrdersModule, which wires the Application (handler/validator/mapper scan),
Infrastructure, and API layers. Disabled peers get stub registrations so cross-module interfaces still
resolve. See ADR-008. Monolith config: no DataSources
section (one DB) and no MessageBus:Provider, so the framework selects the InProcessMessageBus.
The Blazor UI host
Source/Hosts/UI/Contoso.Support.UI.Web is a Blazor Server + MudBlazor front end. It holds no domain
logic and no DbContext: it calls the API through a typed SupportApiClient registered with
AddHttpClient<SupportApiClient>(...), whose base address (https+http://web, from config) is
resolved by the service-discovery handler that AddServiceDefaults() installs. Because Blazor Server
runs the calls server-side, there is no browser CORS and no token to forward. On a failed response
the client calls ServiceExceptionHelper.ThrowIfDomainExceptionAsync to surface the RFC 9457
ProblemDetails message (e.g. "Comments cannot be added to a closed order.") before the generic
EnsureSuccessStatusCode fallback: the same pattern MMCA.Common.UI's EntityServiceBase uses, so
pages show a meaningful error. Program.cs is the standard Razor-components host plus
AddServiceDefaults() / MapDefaultEndpoints().
Two wiring details in the layout are easy to miss because nothing fails loudly without them:
<MmcaThemeProviders />owns the Mud providers. Drop that one framework component at the top ofMainLayout.razorinstead of hand-placingMudThemeProvider/MudPopoverProvider/MudDialogProvider/MudSnackbarProvider: it also carries the dark/light lifecycle from ADR-028, so<ThemeToggle />has something to toggle.<CultureSwitcher />needs anICultureApplier. The switcher delegates the actual switch to that service, whichAddUIShared()normally registers. A UI host that does not callAddUIShared(this seed does not: it has noApiSettings-backed client pipeline) must register the Blazor Web default itself,services.AddScoped<ICultureApplier, EndpointCultureApplier>(), or the menu silently no-ops.
Internationalization (ADR-027): every visible string follows the selected language
The framework ships multi-locale i18n (en-US + es) end to end; adopting it in a new app is five
mechanical steps, and MMCA.Helpdesk is the worked example for each:
- Externalize page strings to co-located
.resxpairs. InjectIStringLocalizer<YourPage> Land render@L["Key"]; putYourPage.resx+YourPage.es.resxnext to the page (seeMMCA.Helpdesk.UI.Web/Components/Pages/Tickets.resxand itsessibling). Snackbar/confirmation text uses whole-sentence keys (Snackbar.Created= "Order created successfully."); never compose sentences from fragments (the obsoletedErrorMessages.Success(entity, action)shows why: Spanish gender agreement breaks). Always add theenandesvalues in the same commit, or the completeness gate below fails the build. - Wire request localization + the culture switcher. API-layer hosts get it for free from
UseCommonMiddlewarePipeline()(which callsUseCommonRequestLocalization()) plusMapCultureEndpoint(); a UI host that deliberately does not referenceMMCA.Common.APIinlines the same lines againstSupportedCultures(seeMMCA.Helpdesk.UI.Web/Program.cs, which documents the inline variant). When you inline them, theUseRequestLocalizationallowlist and the/culture/setendpoint must admit the pseudo-locale under the sameIsDevelopment()condition the switcher uses to offer it, or picking it silently does nothing. Drop the shared<CultureSwitcher />(and<ThemeToggle />) into the layout; a WASM client also callsMmcaCultureBootstrap.SetBrowserCultureAsyncbeforeRunAsync(). - Localize backend error text by
Error.Code. Registerservices.AddErrorResources<YourModuleErrorResources>()in the host and keep aYourModuleErrorResources.{resx,es.resx}pair keyed by the module'sError.Codevalues; the HTTP edge then returns localized ProblemDetails messages while domain code stays culture-agnostic. - Subclass the two i18n fitness gates in your architecture-test project (Phase 6):
LocalizationResourceTestsBase(every base.resxmust have a complete, non-emptyessibling) andLocalizedTextConventionTestsBase(no hard-coded snackbar/title/<PageTitle>/breadcrumb/NavItemliterals; mark deliberate literals such as brand names with ani18n: allowcomment). - Verify visually with the pseudo-locale. In Development, pick
qps-Plocin the culture switcher: every properly externalized string renders with a[!!sentinel and ~40% padding, so a hard-coded literal or a clipped layout is immediately visible without translating anything.
MudBlazor's own component chrome (pager, pickers, filter menus) localizes automatically through the
framework's ResxMudLocalizer; nav menu items localize by giving each NavItem a TitleResource
(its Title/Group then act as resource keys resolved at render time).
The Aspire AppHost
Source/Hosting/Contoso.Support.AppHost orchestrates the local stack. For the monolith it is small:
using MMCA.Common.Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var sql = builder.AddSqlServer("sql").WithLifetime(ContainerLifetime.Persistent);
var db = sql.AddDatabase("support", "Support");
var web = builder.AddProject<Projects.Contoso_Support_Web>("web")
.WithSQLServerDataSource(db, "Orders") // injects ConnectionStrings__SQLServerConnectionString (collapses to one DB)
.WaitFor(sql) // wait on the SQL server, NOT the database (see warning below)
.WithExternalHttpEndpoints();
// The Blazor UI calls the API server-side; WithReference("web") gives its typed HttpClient the endpoint
// to resolve via service discovery, and WaitFor(web) gates the UI until the API is healthy.
builder.AddProject<Projects.Contoso_Support_UI_Web>("ui")
.WithReference(web)
.WaitFor(web)
.WithExternalHttpEndpoints();
await builder.Build().RunAsync();
WaitForthe SQL server, not the database resource. The host creates the database via EFMigrateat startup, soWaitFor(db)deadlocks: thedbresource is never "healthy" until the database exists, but the only thing that creates it is the host that is waiting on it. The app resource sits at "Waiting" forever. Wait on thesqlserver resource (healthy once the container accepts connections) and let EF create the database. This mirrors MMCA.ADC/Store, whichWaitForthe broker and peer services but never the database resource.
The AppHost also needs a Properties/launchSettings.json (the AppHost template always ships one).
Without it, the Aspire dashboard endpoints are never configured, so on F5 the dashboard never
opens, no browser launches, and the AppHost appears to hang at control-plane init. Copy ADC's and give
it its own ports:
// Source/Hosting/Contoso.Support.AppHost/Properties/launchSettings.json
{
"profiles": {
"https": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "https://localhost:17300;http://localhost:15300",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21300",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22300"
}
}
}
}
Run it:
dotnet run --project Source/Hosting/Contoso.Support.AppHost
Run it interactively, from a real terminal. The Aspire AppHost stalls at control-plane init if launched from a headless or background shell (no dashboard appears). Use an interactive terminal for any manual verification.
The dashboard lists three resources: sql, web (the API), and ui (the Blazor front end). Open the
ui endpoint to browse and open orders in the browser. To exercise the API directly, hit 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.
Phase 6: Tests and the architecture-fitness map
Scaffolded, with one deliberate gap. All three test projects and the map are generated. The
IntegrationEventContractTestssubclass is NOT: its frozen literal lists members alphabetically, so an inherited one stops being correct the moment the aggregate is renamed, and a wire contract inherited from someone else's sample module guarantees nothing anyway. Freeze your own, once. The generated README and the templates guide carry the class and the command that prints the value.
Test projects
Ship three test projects, all of which run anywhere with no database and finish in about a second (this is what the reference app carries):
Tests/Modules/Orders/...Domain.Tests: xUnit v3 + AwesomeAssertions. Test factory methods, invariants, state transitions, and domain events (including the assertion thatCreateraises no domain event, since the id is DB-generated; don't "fix" it).Tests/Modules/Orders/...Application.Tests: handler and decorator behavior against test doubles. This is where the caching pair is proved end to end (Caching/OrderCacheInvalidationTests.cs, shown in Phase 3); add mocked-IUnitOfWorkhandler tests here as the module grows.Tests/Architecture/...Architecture.Tests: the fitness functions (below).
Optional addition as the app grows:
Tests/Integration/...IntegrationTests: boot the host withWebApplicationFactoryand useIntegrationTestBase<TFixture>plusJwtTokenGenerator(both fromMMCA.Common.Testing). These need a reachable SQL Server, so run them in an environment that has one (Aspire, a container, or CI with a SQL service); they cannot run where no SQL is reachable.
The architecture-fitness map (mandatory)
The framework enforces layering and module isolation by running the same NetArchTest rule library
(shipped in MMCA.Common.Testing.Architecture) against a per-repo map. You implement one
IArchitectureMap by subclassing ArchitectureMapBase: declare a RepoToken and list every layer
assembly. See ADR-015. Pattern source: the
*.Architecture.Tests project in MMCA.Helpdesk, ADC, or Store.
internal sealed class SupportArchitectureMap : ArchitectureMapBase
{
public override string RepoToken => "Contoso.Support";
protected override IEnumerable<LayerRef> DefineLayers() =>
[
// Framework (MMCA.Common): one anchor type per layer assembly
Framework(Layer.Shared, typeof(MMCA.Common.Shared.Abstractions.Result).Assembly),
Framework(Layer.Domain, typeof(MMCA.Common.Domain.Entities.BaseEntity<>).Assembly),
Framework(Layer.Application, typeof(MMCA.Common.Application.Services.EntityQueryService<,,>).Assembly),
Framework(Layer.Infrastructure, typeof(MMCA.Common.Infrastructure.Persistence.DbContexts.ApplicationDbContext).Assembly),
Framework(Layer.Api, typeof(MMCA.Common.API.Controllers.ApiControllerBase).Assembly),
// Orders module
Module("Orders", Layer.Domain, typeof(Contoso.Support.Orders.Domain.Orders.Order).Assembly),
Module("Orders", Layer.Application, typeof(Contoso.Support.Orders.Application.ClassReference).Assembly),
Module("Orders", Layer.Infrastructure, typeof(Contoso.Support.Orders.Infrastructure.AssemblyReference).Assembly),
Module("Orders", Layer.Shared, typeof(Contoso.Support.Orders.Shared.Orders.OrderDTO).Assembly),
Module("Orders", Layer.Api, typeof(Contoso.Support.Orders.API.Controllers.OrdersController).Assembly),
// ...add the same five lines per additional module (e.g. Identity).
];
}
Each test class is then a tiny sealed subclass of a framework *TestsBase that supplies your map:
public sealed class LayerDependencyTests : LayerDependencyTestsBase
{
protected override IArchitectureMap Map { get; } = new SupportArchitectureMap();
}
The package ships far more bases than the four structural ones (the authoritative base and method counts live in FACTS.md), and the reference app subclasses nineteen. That is the realistic target for a new app, not a stretch goal: each base is a few lines. Start with the structural group and add the rest as the corresponding feature appears:
| Group | Bases | What it catches |
|---|---|---|
| Structure (start here) | LayerDependencyTestsBase, DomainPurityTestsBase, ModuleIsolationTestsBase, SharedLayerTestsBase |
the layer dependency flow, a Domain that reaches for infrastructure, cross-module internal references |
| Extraction | MicroserviceExtractionTestsBase, IntegrationEventContractTestsBase, EventConventionTestsBase |
MassTransit/gRPC leaking out of the edges; an integration event reshaped instead of versioned |
| Conventions | NamingConventionTestsBase, HandlerConventionTestsBase, ControllerConventionTestsBase, EntityConventionTestsBase, ImmutabilityTestsBase, SliceCohesionTestsBase, SpecificationConventionTestsBase |
factory-name, handler, controller, and entity drift away from the framework shape |
| Policy gates | ConcurrencyConventionTestsBase (ADR-035), FrameworkVersionConsistencyTestsBase (ADR-016), PiiConventionTestsBase (ADR-005) |
an *UpdateRequest with no RowVersion, a half-finished version sweep, unmarked personal data |
| i18n (ADR-027) | LocalizationResourceTestsBase, LocalizedTextConventionTestsBase |
a base .resx with no complete es sibling; a hard-coded user-visible literal |
Two things to know before you copy the list wholesale:
IntegrationEventContractTestsBasefreezes your wire contract. You overrideExpectedContractwith the literal shape of every integration event ("...OrderOpenedIntegrationEvent { RequesterUserId:Int32, OrderId:Int32 }"). Editing that list is the deliberate act of evolving the contract, so a silent reshape fails the build.- Some bases fail deliberately when they find nothing, which is an anti-vacuity guard rather than a
bug.
ConstructorDependencyCountTestsBasescans Application*Serviceclasses and fails when there are none, so the reference app leaves it unsubclassed until its first Application service exists. Others (DataResidencyTestsBase,BrandColorTokenTestsBase,FormsConventionTestsBase) stay unsubclassed for scope reasons. Record why you skipped one next to the ones you kept: an unsubclassed rule is invisible otherwise.
Register every layer assembly in the map. If you add a module or a layer and forget to add its
Module(...)/Framework(...)line here, the layering and isolation rules silently stop covering it.
Checkpoint: dotnet build Contoso.Support.slnx is warning-free (the five analyzers at error
severity), and all three test projects pass (dotnet test --solution Contoso.Support.slnx, no database
needed). To run one class or method, target the project and pass a Microsoft Testing Platform filter
after -- (these solutions run on MTP, not VSTest, so a bare --filter silently matches nothing):
dotnet test --project Tests/Architecture/Contoso.Support.Architecture.Tests/Contoso.Support.Architecture.Tests.csproj \
-- --filter-class "*ModuleIsolationTests*"
Phase 7: Upgrading the framework version
Not scaffolded.
dotnet new mmca-app --framework-versionpicks the version you START on; moving to a later one is this phase.
When a new MMCA.Common release ships, upgrade in one pass: bump every MMCA.Common.* entry in
Directory.Packages.props to the new version together. There is no phased rollout and no per-package
skew, and FrameworkVersionConsistencyTestsBase fails the build if you miss an entry. Keep MassTransit
at v8. See ADR-016 and the
versioning policy.
If your app commits packages.lock.json files (the reference app deliberately does not), the pin bump
is only half the upgrade: regenerate the locks with dotnet restore <your.slnx> --force-evaluate and
commit them alongside the pins, with local.props set aside so the restore runs in package mode.
For local framework co-development, flip UseLocalMMCA in local.props, and remember to rebuild
MMCA.Common in Debug before your app after editing framework source.
Phase 8: Extract a module into its own service (the payoff)
Not scaffolded. The generated solution carries the plumbing (the
.Contractsproto convention and the.ServiceOpenAPI block inDirectory.Build.props), but the extraction itself is a decision, not a rename.
This phase is the documented next step beyond the reference app (which is build- and test-verified as
the monolith through Phase 6); the scaffold already carries the plumbing: the .Contracts proto
convention and the .Service OpenAPI block in Directory.Build.props. Now make the "extract later,
without a rewrite" promise concrete. We pull Orders out of the monolith into its own service
behind a gateway. The Orders Domain, Application, Shared, Infrastructure, and API code is
unchanged: only host wiring and transport are added. This works because the application talks to
abstractions (IUnitOfWork, IMessageBus, gRPC service interfaces) and the framework keeps transport
at the edges. See ADR-008. This is also where you add the
Identity module, since an extracted service needs a real JWKS issuer to validate tokens against.
8a. A service host per module
Create Source/Services/Contoso.Support.Orders.Service (and one for Identity). Each boots exactly one
module (Modules:Orders:Enabled=true). Its Program.cs is the same DI sequence as the monolith host,
plus: Http2-only Kestrel on cleartext (h2c) for gRPC, AddGrpcServiceDefaults(), broker messaging,
and JWKS-validated auth. See ADR-012 (Profile A). Pattern source:
MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs.
builder.WebHost.ConfigureKestrel(k => k.ConfigureEndpointDefaults(o => o.Protocols = HttpProtocols.Http2));
// ...same AddApplication/AddInfrastructure/AddAPI/ModuleLoader sequence...
services.AddGrpcServiceDefaults();
services.AddBrokerMessaging(builder.Configuration, x => x.RegisterIntegrationEventConsumer<SomeEvent>());
8b. A .Contracts project for synchronous calls
If Orders needs a synchronous answer from Identity (for example, the requester's display name),
define it in Source/Services/Contoso.Support.Identity.Contracts as a .proto. The .Contracts
convention (from Directory.Build.props) auto-compiles it into server + client stubs. The consumer
registers a typed client with AddTypedGrpcClient<TClient>(serviceName) (from MMCA.Common.Grpc),
which resolves http://identity via Aspire service discovery over h2c, forwards the caller's JWT, and
wraps calls in the standard Polly pipeline. Failures cross the wire as Result via
GrpcResultExceptionInterceptor. See ADR-007.
8c. A YARP gateway
Source/Hosts/Contoso.Support.Gateway is a pure reverse proxy (no DbContext, no controllers). It maps
URL prefixes to backend services. The route map here is the source of truth for which service owns
which endpoint:
app.MapForwarder("/Orders/{**catch-all}", "http://orders", http2Config);
app.MapForwarder("/Auth/{**catch-all}", "http://identity", http2Config);
app.MapForwarder("/.well-known/{**catch-all}", "http://identity", http2Config); // JWKS, routed through the gateway
Set ForwardHttp2 = true (and RequestVersionExact) on the gRPC/JWKS routes so the proxy speaks
HTTP/2 to the Http2-only services. See ADR-012.
8d. The AppHost grows up
Now wire the distributed topology with the MMCA.Common.Aspire.Hosting extensions:
var sql = builder.AddSqlServer("sql").WithLifetime(ContainerLifetime.Persistent);
var identityDb = sql.AddDatabase("support-identity", "Support_Identity");
var ordersDb = sql.AddDatabase("support-orders", "Support_Orders");
var redis = builder.AddRedis("redis").WithLifetime(ContainerLifetime.Persistent);
var broker = builder.AddMessageBroker().WithLifetime(ContainerLifetime.Persistent); // RabbitMQ
var identity = builder.AddProject<Projects.Contoso_Support_Identity_Service>("identity")
.WithSQLServerDataSource(identityDb, "Identity").WithReference(redis).WithBroker(broker).WithExternalHttpEndpoints();
var orders = builder.AddProject<Projects.Contoso_Support_Orders_Service>("orders")
.WithSQLServerDataSource(ordersDb, "Orders").WithReference(redis).WithBroker(broker)
.WithReference(identity).WaitFor(identity).WithExternalHttpEndpoints();
var gateway = builder.AddProject<Projects.Contoso_Support_Gateway>("gateway")
.WithReference(identity).WithReference(orders).WithExternalHttpEndpoints()
.WithEndpoint("https", e => e.Port = 6001);
orders.WithJwksDiscovery(identity, gateway); // two-arg gateway form: orders validates Identity's JWKS through the gateway
Use the two-argument
WithJwksDiscovery(identity, gateway)form. The single-argument form points the backchannel directly at the Http2-only Identity HTTPS endpoint and fails the local ALPN negotiation; routing JWKS through the gateway (which terminates TLS) is what works.
What changed for the application code: nothing. WithSQLServerDataSource now gives each service its own database
(Support_Identity, Support_Orders, each with its own OutboxMessages table, so services never
race for each other's outbox rows, see ADR-006). The
OrderOpenedIntegrationEvent you wrote in Phase 3 now flows monolith-to-broker over MassTransit
instead of in-process, selected purely by configuration. Cross-service references become scalar columns
plus eventual consistency through the outbox, never cross-database foreign keys.
Verification checklist
- Build green:
dotnet build Contoso.Support.slnxwith no warnings (TreatWarningsAsErrors + five analyzers). This is the primary automatable gate. - Unit + architecture tests pass:
dotnet test --solution Contoso.Support.slnx(all three projects, no DB needed). TheIArchitectureMaprules must be green, including your own frozen integration-event contract (Phase 6). - Migrations:
dotnet ef migrations add InitialCreate ...succeeds for each migrations project (generates theOrder,OrderComment, and per-DBOutboxMessagestables). - Run (interactive):
dotnet run --project ...AppHost; the dashboard showssql,web, anduihealthy. Openuito use the app, or hitPOST /OrdersthenGET /Ordersonweb; confirm 201/200, stamped audit fields, soft-delete filtering, and an outbox row for theOrderOpenedIntegrationEvent. (Issuer-less, so no token is needed.) - Extraction smoke: after Phase 8, the dashboard shows Identity + Orders + Gateway healthy; a request through the gateway to Orders succeeds and JWKS-validates; the integration event is delivered over the broker.
Where to look next
- Getting Started: the one-command path that writes phases 1 through 6 for you. If you are starting a new solution rather than adding the framework to an existing one, that is the page you want.
- Templates: every parameter of
mmca-app,mmca-module,mmca-command, andmmca-query, plus the two one-time fixups a rename makes unavoidable. ADR-065 explains why the pack is derived from the reference app rather than maintained beside it. - MMCA.Helpdesk: the minimal, build- and test-verified
monolith this walkthrough is the companion to: every step above maps to real code there, under the
names
Helpdesk/Tickets/Ticket. Read itsREADME.mdandCLAUDE.mdfor the Helpdesk-specific picture (issuer-less auth, the two event paths, the abstract module DbContext). - The ADRs (index): the why behind every pattern you just used.
MMCA.Common/CLAUDE.md: the framework's layer rules, DI sequence, and extension points in depth.- 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 template (four modules, OAuth social login, SignalR notifications); Store is the simpler one.