Onboarding guide
Primer, the concepts, stack, and conventions you need first
This chapter teaches the cross-cutting things once, so the per-type chapters can stay focused.
Read it before the group chapters (start with group-01).
Everything here is either an architectural style the codebase commits to,
an external dependency (BCL/NuGet, "external Level 0"), a build/language convention, or the
architecture-evaluation lens the guide tags against. Later sections cross-reference back here.
1. The big picture
Two codebases are in scope:
MMCA.Common: a framework, published as seventeen NuGet packages (the count and list are owned byMMCA.Common/FACTS.md) to nuget.org (the documented install path) and mirrored to GitHub Packages (ADR-053) (four core:.Shared,.Domain,.Application,.Infrastructure; five presentation:.API,.Grpc,.UI,.UI.Maui,.UI.Web; three hosting:.Aspire,.Aspire.Hosting,.Gateway; four testing:.Testing,.Testing.E2E,.Testing.UI,.Testing.Architecture; plus theMMCA.Commonmetapackage that bundles the six a standard host always takes, ADR-101). It is not a runnable app; it ships the base classes and infrastructure for building modular monoliths with DDD + Clean Architecture + CQRS, plus the extension points to extract a module into its own microservice later. The packages release in lockstep (one version tags all of them).MMCA.ADC: the Atlanta Developers Conference application, a consumer of those packages. It has modules (Conference, Engagement, Identity, Notification), a Blazor UI, a YARP gateway, and Azure infrastructure.
MMCA.ADC depends on MMCA.Common; MMCA.Common depends on neither. That is why the Common
framework groups come first in this guide and the ADC business-module groups build on them, and
within every group, the per-type sections run in ascending dependency Level.
The layered dependency flow (Clean Architecture)
MMCA.Common's own layering, enforced top-to-bottom (MMCA.Common/CLAUDE.md, "Architecture"):
API / Grpc (presentation / transport)
↓
Infrastructure (EF Core, caching, JWT, JWKS, outbox, message bus, SignalR)
↓
Application (CQRS handlers, decorators, module system, IMessageBus)
↓
Domain (entities, aggregates, domain events, specifications)
↓
Shared (Result pattern, errors, DTOs, value objects)
Each layer references only layers below it, the dependency rule of Clean Architecture: source
dependencies point inward, toward the domain, and the domain depends on nothing framework-specific.
Two deliberate exceptions: UI and Grpc depend on Shared only, UI for Blazor
WebAssembly compatibility, Grpc because it is pure transport that must not couple to business
layers. The two host-support presentation packages sit above those: UI.Maui may reference
UI and Shared only, and UI.Web (the Blazor Web host bridge) references UI, API, and
Aspire, never Domain/Application/Infrastructure directly, which is why it disables transitive
project references and carries its own boundary check
(MMCA.Common/Source/Build/MMCA.Common.LayerEnforcement.targets:90-123).
MMCA.ADC repeats the same layering per module: each of Conference/Engagement/Identity has
.Shared, .Domain, .Application, .Infrastructure, .API, and .UI projects following the
same inward rule.
2. Architectural styles this codebase commits to
These are the recurring ideas. Each is taught fully at its first concrete appearance in a group chapter; here is the orientation so the vocabulary is familiar.
Domain-Driven Design (DDD). The model mirrors the business. Aggregates (a root entity plus the children it owns) enforce invariants inside their boundary; references between aggregates are by ID, not object graph. Value objects (Money, Address, Email) model concepts with no identity and are immutable. Domain events announce meaningful state changes. Factory methods return
Result<T>so an invalid entity cannot be constructed. First concrete code:group-02(ValueObject,IBaseEntity<T>) andgroup-04(IDomainEvent).Clean Architecture. See §1. The domain layer is free of EF/ASP.NET/serialization attributes; the application layer defines ports (interfaces) that infrastructure implements as adapters.
CQRS (Command/Query Responsibility Segregation). Writes (commands, which mutate and return a
Result) are separated from reads (queries, side-effect-free). Both flow through a decorator pipeline: commands runFeatureGate → Logging → Caching → Validating → Transactional → handler, queries runFeatureGate → Logging → Caching → handler(no validation and no transaction on the read side). The order is set by the registrations inAddApplicationDecorators, which Scrutor'sTryDecorateapplies in reverse, so the last registered is the outermost (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:93-102). Cross-cutting concerns live in the pipeline, not in each handler. First concrete code: theICommandHandler/IQueryHandlercontracts and their decorators ingroup-05.One shared HTTP middleware pipeline (ADR-079). The HTTP-side sibling of the decorator chain: every REST/gRPC service host builds its request pipeline from one
UseCommonMiddlewarePipelinecall, which fixes the middleware order once (exception handler through controllers) with the load-bearing adjacencies commented in code (authentication before the rate limiter and before tenant resolution, forwarded headers before both). Conditional middleware registers unconditionally and stays inert by config, so hosts differ by configuration, not by pipeline shape. Adopted by all seven ADC/Store service hosts, Helpdesk, and the template; the gateways and Blazor UI hosts sit deliberately outside it. First concrete code:group-12.Vertical Slice Architecture. Within a module, a feature is a cohesive slice (command/query + handler + validator + DTO + mapper together), not scattered across horizontal
Services/,Repositories/,Validators/folders. Adding a feature means adding a slice.Modular Monolith → extractable services. Modules implement a common
IModulecontract and are discovered and registered in dependency (topological) order by a module loader. Each module can later run as its own service host behind a YARP gateway without a rewrite, because application code talks to abstractions (IMessageBus, typed gRPC clients) and the transport choice lives at the edges. (ADRs 007 "gRPC extraction", 008 "service-extraction topology".)Cross-service auth without shared secrets (ADR-004). Only the Identity service ever holds token-signing key material. The moment auth crosses a service boundary, tokens are signed asymmetrically (RS256) and every other service validates them via JWKS / OIDC discovery:
AddForwardedJwtBearerpoints the bearer middleware at an authority, fetches/.well-known/openid-configuration, and followsjwks_urito the published public key. So a compromised non-Identity service cannot mint tokens, and key rotation is publish-once at the issuer. The issuer is deliberately taken from the discovery document rather than pinned, because the internal service-discovery hostname differs from the public gateway origin, and both validators pinValidAlgorithmsso an attacker cannot force an algorithm swap. Downstream gRPC calls forward the already-validated JWT (ADR-007'sJwtForwardingClientInterceptor). This is the auth half of the extraction promise in the previous bullet: token validation survives the issuer/validator split without a rewrite. Adoption note (verified by source): the guarantee is scoped to cross-service auth. The in-process monolith default stays HS256 with a shared symmetric secret behind the sameJwtSettings.SigningAlgorithmswitch, and MMCA.Helpdesk (the monolith seed) runs issuer-less. ADC's Conference, Engagement, and Notification services all validate through the gateway-routed discovery path today. First concrete code:group-08.Write-once UI, render everywhere (Blazor + .NET MAUI Hybrid). A UI page is authored once as a Razor component in a per-module Razor Class Library (
MMCA.ADC.{Module}.UI, e.g.Conference.UI'sEventList.razor/EventDetail.razor). Both the web host (MMCA.ADC.UI.Web/.Web.Client, Blazor Server + WebAssembly) and the .NET MAUI host (MMCA.ADC.UI)ProjectReferencethe same UI libraries, so one page renders across Web, Android, iOS, macOS, and Windows with no per-platform reimplementation, MAUI hosts the shared components in aBlazorWebView(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MainPage.xaml:16, wired byAddMauiBlazorWebView()inMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:64). The only platform-specific code is tiny entry points (App/AppDelegate/MainApplication,MauiProgram). First concrete code: the MAUI bootstraps and host shells ingroup-25; the supported device/browser matrix is inMMCA.ADC/CLAUDE.md.Event-driven integration + the Outbox pattern. When an aggregate changes, its domain events are serialized into an
OutboxMessagerow in the same transaction as the data, then a background processor delivers them at-least-once. This avoids the "save then publish and hope" dual-write bug. (ADR-003 "outbox dual-dispatch".)Database-per-service. Each module/service owns its own SQL database and its own outbox table; there is one concrete
SQLServerDbContextclass but one instance per database. Cross-source relationships auto-degrade (the FK is dropped, navigation flows through batch loaders), and the outbox is the cross-source consistency mechanism. (ADR-006.)Engine-agnostic entities, the storage provider is a one-token choice (ADR-018). A domain entity carries no persistence-engine choice; it is a plain class. What decides whether it is stored in SQL Server, Cosmos DB, or SQLite is a single
[UseDataSource(<engine>)]attribute on itsInfrastructure/Persistence/EntityConfiguration/{Entity}Configurationclass, carried for you by one of three thin engine shim base classes (EntityTypeConfigurationSQLServer<TEntity, TId>,…Cosmos<…>,…Sqlite<…>). All three derive from a single engine-awareEntityTypeConfiguration<TEntity, TId>base (which reads the attribute and applies the matching table/container/schema/key conventions) overEntityTypeConfigurationBase<TEntity, TId>,group-07. So swapping just that base (or attribute) re-points the same entity to a different engine with no configuration-body edits and zero change to the domain, application layer, or entity, the engine is resolved up front by theEntityDataSourceRegistry, the rightDbContextis built per data source, cross-source relationships auto-degrade, and a cross-source filter goes throughCrossSourceSpecification(so even a "published-event" predicate stays translatable). First concrete code: the configuration hierarchy ingroup-07; this is the per-entity half of database-per-service (ADR-006) plus the polyglot story (ADR-018). Adoption note (verified by source): this is a real, tested capability that no entity routes to yet. Today every entity configuration in ADC derives from the…SQLServerbase (ADC runs SQL Server only, four databases:ADC_Identity,ADC_Conference,ADC_Engagement,ADC_Notification,MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:32-35), and the ADR-018 work shipped the full polyglot machinery (unified base, cross-source spec + fitness rule, Cosmos-index skip, SQLiteEnsureCreated, Cosmos/SQLite Aspire helpers, portability tests). An end-to-end trial moving ADC Conference'sSessionto Cosmos andRoomto SQLite was built and tested locally, then deliberately reverted to all-SQL-Server with every framework extension point kept. Treat Cosmos/SQLite as supported, exercised extension points, see the coverage audit's extension-point inventory.The Result pattern. Expected error paths use a
Result/Result<T>return value carryingErrors, not exceptions. This is the single most pervasive idiom in the codebase, taught in full ingroup-01(ErrorType,Error,Result).Soft-delete + audit fields. Entities are never hard-deleted; an
IsDeletedflag plus EF global query filters exclude them.CreatedOn/ByandLastModifiedOn/Byare stamped centrally inSaveChangesAsync. For genuine erasure (GDPR/CCPA) there is a separate anonymize path. (ADR-005.) Since ADR-075 the same idea extends to an opt-in field-level audit trail: a thirdSaveChangesInterceptor, registered last so it diffs freshly stamped values, writes per-propertyAuditTrailEntriesin the same transaction as the data, with[Pii]values captured redacted (opt-in twice:AddAuditTrailplus anIAuditedEntitymarker per entity; retention is an ADR-074 scheduled purge).Multi-tenancy as a persistence-layer commitment (ADR-073). Tenancy is not per-handler filtering: a second named EF query filter,
"Tenant", composes by AND with the"SoftDelete"filter and embeds the executing tenant as a SQL parameter, so one cached model per source serves every tenant (a null tenant is the system context and sees all).ITenantContextresolves claim-then-header behindTenantResolutionMiddleware, a dedicatedSaveChangesInterceptorstamps writes and refuses cross-tenant ones, the outbox drains per(source, tenant)pair, the caching decorators prefix keys with the tenant, and DB-per-tenant is a per-tenant connection-string override under the sameDataSourceKey. Adoption note (verified by source): like the polyglot machinery above, this is a real, tested capability with one reference adopter: MMCA.Helpdesk runs it end to end, while ADC and Store stay single-tenant (the filter is inert without opt-in). First concrete code:group-07(filters, interceptor) andgroup-12(resolution middleware).Primitive identifier type aliases (ADR-048). Each entity's ID type is a per-module
global using XIdentifierType = int;(or= System.Guid;) alias, linked into every project viaDirectory.Build.props. Code saysEventIdentifierType, not bareint, so the ID type can change in one place: ADC'sSpeakerIdentifierTypeis aSystem.Guidbeside fourteenintsiblings in the same file (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18, inMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/). These are aliases, not wrapper structs: the alias is erased at compile time, so it buys readability and one-place change, not compile-time protection against passing the wrong same-typed ID. (The identifier-alias mechanism is covered with the entity contracts ingroup-02.)
The decision records (ADRs) this guide tags
The why behind these patterns lives in the accepted ADRs (the Website repo's
docs-src/adr/README.md owns the count and range), version-controlled in
Website/docs-src/adr/ and published at https://ivanball.github.io/docs/adr/ (its README.md is the
canonical index with one-line summaries). Group chapters tag the relevant one inline (e.g. "ADR-003");
the full set, for orientation:
| ADR | Decision (one line) | First/most relevant chapter |
|---|---|---|
| 001 | Manual DTO mapping (Mapperly), not reflection-based AutoMapper | g12 |
| 002 | INavigationPopulator<T> for cross-container/cross-source eager loading |
g11 |
| 003 | Outbox + in-process dispatch + background processor (at-least-once); since ADR-100 the outbox is resolved from the messaging mode (MessageBus:EnableOutbox is bool?, unset means enabled iff the provider is not InProcess) |
g04 |
| 004 | JWKS discovery + fallback for cross-service token validation | g08 |
| 005 | Soft-delete for lifecycle; IAnonymizable + outbox purge for GDPR/CCPA erasure |
g02/g24 |
| 006 | Database-per-service: each owns its DB + outbox; one SQLServerDbContext class, one instance per DB |
g07 |
| 007 | *.Contracts + typed gRPC clients + Result-over-the-wire for synchronous inter-service calls |
g13 |
| 008 | One service host per module behind a YARP gateway; transport at the edge keeps extraction reversible | g16/g25 |
| 009 | Standard resilience handler on every outbound client; declared RTO/RPO + drilled restore | g13/devops-runbooks |
| 010 | Every integration event carries a SchemaVersion; breaking changes use a new type + upcaster |
g04 |
| 011 | g15 | |
| 012 | gRPC host transport: both consumers now default to Http2-only h2c (Profile A); Http1AndHttp2 survives on the WebSocket hosts only (ADC Notification, which adds a dedicated Http2 gRPC endpoint beside it, and Store Sales) |
g16/g20 |
| 013 | Expected failures are Result/ErrorType values; only the edge maps to HTTP/gRPC |
g01 |
| 014 | CQRS decorator chain: FeatureGate → Logging → Caching → Validating → Transactional → Handler | g05 |
| 015 | Architecture fitness functions: compile-time layer guard + shared NetArchTest rule library | g27 |
| 016 | Lockstep versioning of every package (one tag, one version, no phased rollout); MassTransit pinned to v8 (build-gated) | devops-cicd |
| 017 | [Idempotent] action filter dedups client retries via an Idempotency-Key header (24h replay) |
g12 |
| 018 | Polyglot persistence: three engines (SQL Server / Cosmos / SQLite) behind one entity model, engine via [UseDataSource] |
g07/g03 |
| 019 | Layered rate limiting: an always-on global limiter caps only authenticated callers; anonymous/infra traffic is exempted, with output cache + login-protection for the other layers | g08/g12 |
| 020 | Permission-based authorization: [HasPermission(...)] over an IPermissionRegistry, opt-in atop RBAC |
g08 |
| 021 | Consumer-side inbox idempotency: EfInboxStore dedups broker redeliveries by MessageId |
g04 |
| 022 | Browser session-cookie auth: HttpOnly cookies + a non-validating SSR scheme so [Authorize] passes on prerender |
g08 |
| 023 | Security-response headers + pluggable CSP (ICspPolicyProvider); the baseline CSP omits script-src/style-src so it cannot break Blazor |
g16/g25 |
| 024 | Two-channel notifications: a durable UserNotification inbox and a transient SignalR push, behind IPushNotificationSender |
g10 |
| 025 | Startup warm-up + readiness gating: WarmupHostedService + a ready-tagged WarmupReadinessGate hold /health/ready until warm |
g16 |
| 026 | Two-tier caching: a swappable ICacheService substrate (Memory/Redis) + an HTTP output-cache edge tier; amended 2026-09-01 with an optional third, client-side tier (IUiReadCache, a per-circuit read-through cache over the API client, registered by AddUIShared but opt-in per UI service and adopted by no consumer today) |
g09/g15 |
| 027 | Multi-locale i18n (supersedes 011): en-US + es via .resx/IStringLocalizer; backend errors localized at the edge by Error.Code |
g12/g15 |
| 028 | Day/Dark theme: ThemeService binds MudThemeProvider's IsDarkMode, persisting cookie/localStorage/PreferredTheme |
g15 |
| 029 | Auth brute-force protection: ILoginProtectionService throttles the anonymous surface (email-keyed lockout + per-IP registration cap) |
g08 |
| 030 | Startup sole-migrator: each service self-applies its EF migrations at boot (DatabaseInitStrategy=Migrate), no sqlcmd backstop |
g07/g12 |
| 031 | Feature-flag management: [FeatureGate] (404) + the IFeatureGated decorator for one config-driven flag name |
g12/g05 |
| 032 | g08 | |
| 033 | Resource-ownership authorization: OwnerOrAdminFilter/OwnershipHelper row-scope a single resource beside RBAC |
g08 |
| 034 | Generic entity controllers + dynamic query contract (EntityControllerBase; fields/filter/sort/paging); the write side (generic create/update/delete, CrudEntityControllerBase) is completed by ADR-099 |
g12/g03 |
| 035 | Optimistic concurrency: a RowVersion token round-trips through IConcurrencyAware DTOs; a stale write maps to HTTP 409 |
g07/g12 |
| 036 | External OAuth login (Google/GitHub): OAuthControllerBase swaps a single-use 2-minute code for the local JWT pair (tokens never ride the redirect URL) |
g08/g12 |
| 037 | Field-level encryption at rest: EncryptedStringConverter (AES-256-GCM), shipped + tested but unadopted (no entity config wires it yet) |
g07 |
| 038 | Supply-chain provenance: SBOM release gate + committed lock files + transitive vuln audit + packageSourceMapping |
devops-cicd |
| 039 | Live channel push: hub JoinChannel/LeaveChannel groups + ILiveChannelPublisher publish ephemeral events over the one notification WebSocket |
g10/g15/g23 |
| 040 | Authenticated output caching for public reads: PublicEndpointOutputCachePolicy stops a Bearer header from bypassing the output cache on [AllowAnonymous], user-independent GETs |
g12 |
| 041 | Observability strategy: shared OTel baseline + CQRS RED histograms + outbox dead-letter counter + correlation middleware, with head-sampling and poll-span-filter cost knobs | g16/devops-aspire |
| 042 | Device capability abstraction (MAUI Blazor Hybrid): per-capability contracts + TryAdd null/browser fallbacks + MAUI-native overrides, IDeepLinkDispatcher; MMCA.Common.UI.Maui is the one MAUI-TFM package, built outside the main solution |
g26 |
| 043 | Mobile deep links + app association + native OAuth callback: allow-listed custom-scheme redirect of the single-use code; assetlinks.json/AASA served by the UI.Web host |
g12/g26 |
| 044 | Native push delivery (third channel, amends 024): INativePushSender/IPushDeviceRegistrar (Azure Notification Hubs, Null defaults) reach backgrounded/killed apps; non-fatal after the inbox+SignalR legs |
g10/g07 |
| 045 | Managed file storage + avatars: IFileStorageService (Azure Blob/Null) + IImageProcessor (crop, strip metadata, re-encode); 2 MB in, 256x256 JPEG out, [Pii] URL nulled on anonymize |
g07/g24 |
| 046 | HTTP API versioning: one AddCommonApiVersioning (header api-version, default 1.0); ServiceInfoControllerBase v1.0-deprecated + v2.0 exemplar, fitness-asserted per repo |
g12/g20 |
| 047 | Soft-deleted-user session revocation: SoftDeletedUserMiddleware 401s an authenticated caller whose User.IsDeleted, via a 30s-cached ISoftDeletedUserValidator, bounding the stateless-JWT revocation window |
g12 |
| 048 | Primitive identifier type aliases: entity IDs are primitives behind per-module global using {Entity}IdentifierType, chosen over strongly-typed ID structs (readability + zero EF/serializer friction) |
g02/g14 |
| 049 | Library-scoped ConfigureAwait(false) policy: packaged non-UI framework code is build-gated (CA2007 warning for Source/** in Common's .editorconfig delta, UI packages excluded); protects the MAUI head and any non-ASP.NET consumer from context-capture deadlocks |
devops-cicd |
| 050 | g08 | |
| 051 | Client-side auth token lifecycle across render modes: one ITokenRefresher with two head strategies (browser heads refresh through the same-origin proxy per ADR-022; MAUI refreshes directly and persists the rotated pair in OS SecureStorage) |
g15/g25 |
| 052 | Background job execution: work outliving a request runs as a bounded Channel<T> plus a SingleReader hosted drain, never an untracked Task from a controller, so the host can cancel and await it on shutdown; full mode encodes what the work is worth (DropOldest vs Wait) |
g23 |
| 053 | Dual-registry package publishing: one tag pushes the same nupkgs to nuget.org (the documented install path) and GitHub Packages (mirror), authenticated keylessly through GitHub OIDC trusted publishing rather than a stored API key | devops-cicd |
| 054 | Saga compensation + reconciliation backstop: each workflow step raises a domain event and its compensating action runs in its own handler and DI scope, committing after the originating transaction; idempotency is a persisted aggregate marker written by the same SaveChanges as the compensating writes |
g04 |
| 055 | Repository + Specification contract: the read side is ISP-split into IEntityReader (id lookups) and IEntityQuerier (collections, projections, counts), and a fitness rule fails the build on raw IQueryable surfaces in Application code |
g03/g07 |
| 056 | One render mode for the whole routable tree, chosen at the root on the shared Routes component: InteractiveAuto with prerendering left on, and the resulting SSR-to-interactive double fetch removed in DataGridListPageBase through PersistentComponentState rather than by weakening the mode |
g15 |
| 057 | Expand/contract schema evolution enforced in CI: adding columns, tables and indexes is safe in any release, while a migration added by a PR whose Up() drops one fails the merge check unless it carries an EXPAND-CONTRACT-OVERRIDE marker, because production rollback is revision-only and never un-migrates |
devops-cicd/g07 |
| 058 | Runtime conformance suites shipped in MMCA.Common.Testing: six abstract behavioral bases (problem details, OpenAPI, /ServiceInfo versioning, security headers, graceful shutdown, decorator order) that a host subclasses and that run against a really booted host, picking up where ADR-015's structural fitness tests stop |
g27 |
| 059 | IModule is the one composition contract (five members, three defaulted): reflection discovery over the AppDomain, Kahn topological registration order, and a disabled module represented by null-object stub registrations rather than by absence |
g14 |
| 060 | Performance-regression gate: a performance-smoke job runs the BenchmarkDotNet suite on every code PR and verifies it against a committed perf-baseline.json of absolute allocation ceilings plus benchmark-to-benchmark ratio floors; no absolute wall-clock threshold, since a shared runner cannot deliver one |
devops-cicd |
| 061 | Runtime secrets live in Azure Key Vault and reach each Container App as a keyVaultUrl reference resolved by one shared user-assigned managed identity, consumed only through secretRef (no inline values); SQL managed-identity auth is staged behind useManagedIdentitySql, still false by default |
devops-iac |
| 062 | SLO alerting as code: sloAlertSpecs in each consumer's Bicep materializes KQL scheduled query rules (401/499 and hub traffic excluded) on one action group, and a framework test base pairs every alert with a severity-correct OPERATIONS.md triage section, failing the build in either direction |
devops-iac/g27 |
| 063 | WCAG 2.1 AA as a shipped test contract: AxeOptions.Wcag21Aa pins the four WCAG tag sets and excludes axe's advisory rules, a violation throws instead of reporting, and the scan is a cross-browser required merge check in Common plus a chromium deploy gate in both apps |
g27/devops-cicd |
| 064 | Deploy preconditions as proof of recency: dr-freshness, load-freshness and cross-service-freshness sit in deploy.needs and fail when the newest successful DR drill (8 days), k6 load run (35) or broker round-trip (5) is older than its window; no successful run at all fails too |
devops-cicd |
| 065 | Scaffolding templates derived from the reference app: the dotnet new pack MMCA.Templates (mmca-app/mmca-module/mmca-command/mmca-query) is staged at pack time from the MMCA.Helpdesk tree itself (no second copy to drift); generated apps ship build/add-module.ps1, which performs the seven wire-ups the template can only print, plus the first migration; a template-smoke CI job builds a generated app package-mode and sweeps for residual Helpdesk/Ticket tokens |
g14/devops-cicd |
| 066 | Broker transport selection + dev/prod parity: one IMessageBus with three MessageBusProvider values (InProcess for tests and the monolith, RabbitMQ wired by the AppHost's WithBroker locally, Azure Service Bus injected by both apps' Bicep in production), identical exponential retry per transport, and a non-gating Service Bus emulator test tier proving the production binding |
g04/g16 |
| 067 | Shared Blazor shell + IUIModule composition: the framework package ships the router, layout, nav menu and routable shell pages; each module contributes NavItems, an Assembly for AdditionalAssemblies route discovery, and app-bar/layout extension points, enumerated by Routes.razor at runtime (the UI-layer counterpart of ADR-059's IModule; Helpdesk keeps its own shell) |
g15/g25 |
| 068 | Value objects as validated domain primitives: seven sealed record types over an abstract ValueObject base, each a private constructor plus a Result-returning Create factory (fitness-enforced), mapped via OwnsMoney or value converters (no schema change); the deliberate opposite of ADR-048's identifier aliases (identifiers cross boundaries, domain values carry invariants) |
g02 |
| 069 | Shared DataProtection key ring for scaled-out hosts: AddCommonDataProtection persists the key ring to one Azure blob under DefaultAzureCredential so cookies and antiforgery tokens minted by one replica decrypt on another; Key Vault at-rest encryption is a deliberately independent second gate, and absent config is a full no-op (adopted by ADC; Store still runs per-replica key rings) |
g16/g08 |
| 070 | Fail-fast configuration contract: every settings section binds through AddOptions().Bind().ValidateDataAnnotations().ValidateOnStart() so a misconfigured host refuses to boot instead of failing at first use; settings consumed above Infrastructure flow through read-only singleton facades (IApplicationSettings, ISmtpSettings, IJwtSettings) rather than IOptions<T> |
g12/g14 |
| 071 | Barcode scanning + QR display, split by what each depends on: QrCodeImage is a plain shared component (QRCoder PNG as a data URI, no device needed), while camera reads go through IBarcodeScannerService, an ADR-042 capability (never throws, null = cancelled/denied/unsupported) with a TryAdd null fallback and a ZXing.Net.MAUI implementation, opt-in per head |
g26/g15 |
| 072 | QR badge check-in + points gamification (ADC): the badge QR carries an opaque server-verified Guid (revocable by one Regenerate()), one CheckIn aggregate with Event/Session/Sponsor scopes behind filtered unique indexes, and an append-only points ledger whose unique (UserId, ActivityType, SubjectKey) index is both the redelivery-idempotency guard and the anti-farming rule |
g22/g17 |
| 073 | Multi-tenancy (shared-schema + DB-per-tenant): a second named EF query filter "Tenant" composes by AND with "SoftDelete"; ITenantContext resolves claim-then-header behind TenantResolutionMiddleware, a dedicated interceptor stamps writes and refuses cross-tenant ones, and DB-per-tenant is a per-tenant connection-string override (Helpdesk is the reference adopter) |
g07/g12 |
| 074 | Recurring job scheduler: durable multi-replica-safe cron built on the outbox claim-lease idiom (not Hangfire/Quartz); IScheduledJob resolved scoped per execution, a ScheduledJobEntry store on the Default source, Cronos parsing, smart-wait runner via TimeProvider; missed schedules run once then advance |
g04 |
| 075 | Audit trail: a third SaveChangesInterceptor (registered last, so it diffs freshly stamped values) writes per-property AuditTrailEntries in the same transaction as the data; opt-in twice (AddAuditTrail + IAuditedEntity per entity), [Pii] values captured redacted, retention via an ADR-074 scheduled purge |
g07 |
| 076 | Data-subject export (DSAR) contract: ExportUserDataHandlerBase mirrors the delete handler's ownership gate, fans out to registered IUserDataExportSections, and assembles a versioned JSON export; per-section failure degrades to Available = false rather than failing the request, because a DSAR is a legal deadline |
g24/g12 |
| 077 | HybridCache substrate (amends 026): AddCommonHybridCache swaps ICacheService to L1 in-process + L2 distributed under a disjoint {prefix}hc:{key} keyspace, making the two-serialization-formats-in-one-keyspace failure impossible rather than unlikely; IncrementAsync bypasses L1 to keep counter semantics |
g09 |
| 078 | CSV export as a dedicated [HttpGet("export")] endpoint on EntityControllerBase, not content negotiation (the output cache does not vary by Accept); page-loops the capped query pipeline and streams up to MaxExportRows, truncating with an X-Export-Truncated header; RFC 4180 writer in-house |
g12/g03 |
| 079 | Shared HTTP middleware pipeline: UseCommonMiddlewarePipeline fixes one middleware order for every REST/gRPC host (exception handler through controllers), with the load-bearing adjacencies commented in code; conditional middleware registers unconditionally and stays inert by config (the ADR-014 decorator-order sibling, for the HTTP side) |
g12 |
| 080 | Rollout + automatic revision rollback: both consumer deploys end in a post-deploy smoke gate asserting expected status codes; on failure every container app walks back to its previous revision (az containerapp revision copy); rollback is revision-only by construction, schema is never reverted (ADR-030/057) |
devops-cicd |
| 081 | Cost baseline as a deploy gate: a read-only cost-guard workflow in deploy.needs asserts the production footprint still matches its baseline (replica caps + accepted SQL tiers), so an un-reverted manual surge blocks the next deploy; it never mutates anything |
devops-cicd/devops-iac |
| 082 | Two-tier cross-origin posture: service hosts get named allow-listed CORS policies from one AddCommonCors (origins from config, empty by default), selected inside the shared pipeline; the gateways get a default policy restricting only origins, because a reverse proxy must forward arbitrary client headers |
g12/g16 |
| 083 | CRUD lifecycle event taxonomy: one EntityChangedEvent<TId> base (a DomainEntityState discriminator + the entity id) replaces per-entity Created/Updated/Deleted triples; business state-machine transitions deliberately keep their own event types, and the discriminator rides integration events as a frozen wire field |
g04/g02 |
| 084 | Stripe webhook ingress contract (Store Sales): an anonymous raw-body POST verified by Stripe-Signature whose status code encodes ACCEPTED-vs-PROCESSED, not success/failure; 400 only when the event cannot be accepted at all, because rejections make Stripe retry and eventually disable the endpoint; post-acceptance failures log and return 200 with ADR-054's reconciliation as backstop |
g04/g12 |
| 085 | Identifier type aliases revisited (revisits 048): the wrapper-struct alternative is re-evaluated, priced (43 aliases, 42 of them int; 3,641 occurrences across 1,016 files to migrate) and deferred again, now behind three named revisit triggers instead of an open-ended "not now" |
g02/g14 |
| 086 | Process manager deferred (relates to 054): a documented deferral shipping no code; records the shape a durable coordinator would take (MassTransit v8 saga state machine, per-instance state + deadlines) and the build trigger (3+ steps across 2+ services, state that fits no aggregate, a per-instance deadline); ADR-054's compensation + sweep suffice until then | g04 |
| 087 | Broker poison-message handling (amends 009): transport-aware second-level redelivery (opt-in on RabbitMQ, native on Azure Service Bus), an auto-registered FaultIntegrationEventConsumer<TEvent> that makes an exhausted message visible but never replays it (meter MMCA.Common.Broker), and a circuit breaker on the outbox broker publish only; a per-query DB breaker is rejected (does not compose with EF's execution strategy) |
g04/g07 |
| 088 | Gateway edge responsibilities (extends 019): the edge owns three cross-cutting behaviors via MMCA.Common.Aspire: GatewayCorrelationMiddleware ensures + forwards X-Correlation-ID, per-client-IP rate limiting that deliberately includes anonymous callers (inverting ADR-019's exemption), and downstream health probes on the Ready tag only; edge JWT pre-validation is declined with a trigger |
g16 |
| 089 | Gateway topology owned by configuration (amends 008): the route table moves out of MapForwarder code into YARP ReverseProxy configuration as the single route source, with RouteMapTests as a drift gate in both consumers and the per-destination HTTP version policy (ADR-012 profiles) in cluster config; the AppHost/bicep keep address books, not route tables |
g16 |
| 090 | Event upcaster registration extension point (completes 010): IEventUpcaster<TSource, TTarget> in the Application layer plus an EventUpcasterRegistry that chains V1 to V2 to V3 to the terminal contract and re-stamps MessageId/DateOccurred after every hop, so inbox dedup survives upcasting; both delivery paths consult it, and a duplicate/self-map/cycle throws at host start |
g04/g05 |
| 091 | Cache-backed password reset (extends 029/032): the reset credential is one ICacheService record (256-bit token, only its SHA-256 stored, 30-minute TTL, single live token per address, 5 validation attempts, 3 requests per 60 minutes) rather than three columns on the user row, and ForgotPasswordHandlerBase returns success on every path so the endpoint is not an account-enumeration oracle |
g08/g14 |
| 092 | Core Web Vitals budget as a shipped test contract and deploy gate: WebVitalsCollector installs PerformanceObserver hooks as a Playwright init script, WebVitalsBudget defaults to the good band (LCP 2500, FCP 1800, TTFB 800 ms, CLS 0.1, INP 500), a breach throws naming the page, and both apps' assertions ride the chromium e2e-gate |
g27/devops-cicd |
| 093 | Container image build posture: eleven four-stage Dockerfiles where the GitHub Packages token is a BuildKit secret (never an ARG/ENV), there is deliberately no separate dotnet build stage (publish re-restores; the RID split made every image compile twice, about 75 s), and PublishReadyToRun=true on the nine service/gateway images only; floating base tag and running as root are recorded as undecided |
devops-aspire/devops-cicd |
| 094 | Client-side entity data-access contract (the calling half of 034): hand-written typed bases in MMCA.Common.UI (AuthenticatedServiceBase + EntityServiceBase<TEntityDTO, TIdentifierType>), no generated client; the user-facing Polly retry lives in the client base rather than in ADR-009's resilience handler, and ADR-017's Idempotency-Key is minted client-side for creates only and held constant across the retry burst |
g15/g12 |
| 095 | Uniqueness under soft delete: SoftDeleteUniqueIndexConvention, registered once in ApplicationDbContext.ConfigureConventions, filters every unique index on a non-owned IAuditableEntity to live rows, so a deleted record stops occupying its unique slot forever; a hand-authored filter wins, HasSoftDeleteFilter is the manual extension point, and Cosmos is a no-op |
g07 |
| 096 | Best-effort side-effect contract: one BestEffort.ExecuteAsync(operation, logger, action, ct) helper awaits the side effect and turns any failure into exactly one Warning plus one besteffort.dispatch.failed increment on its own MMCA.Common.BestEffort meter; caller cancellation is rethrown rather than swallowed, and the operation name stays a low-cardinality constant |
g03/g22 |
| 097 | Multi-device refresh sessions (supersedes 050): refresh tokens become rows in a RefreshSessions table, one per signed-in device, stored as an unsalted SHA-256 hex digest (every lookup is by hash and the input is 64 bytes of CSPRNG output), chained on rotation via ReplacedByTokenHash; RefreshSession is a flat framework record (no audit stamps, no soft delete, no concurrency token, because a revoked row must stay findable for the reuse check), IRefreshSessionStore is the persistence contract and RefreshSessionCleanupService sweeps expired rows |
g08/g07 |
| 098 | Aspire for orchestration, not for testing or production dashboards: each AppHost composes the local stack and every host calls AddServiceDefaults/MapDefaultEndpoints, but integration testing stays WebApplicationFactory + Testcontainers (SqlServerIntegrationTestFixtureBase<TEntryPoint> per service, CrossServiceFixtureBase for the three-host tier) and DistributedApplicationTestingBuilder stays out; production observability is Application Insights, not the Aspire dashboard |
devops-testing/g27/devops-aspire |
| 099 | Generic write-side entity commands (the half 034 left at create and delete): the module writes one IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>.ApplyAsync that calls the aggregate's own guarded methods, so invariants and events keep exactly one home; UpdateEntityCommand carries id, request and RowVersion and reuses the existing concurrency/idempotency markers, MutateEntityHandlerCore hosts the shared load-apply-save sequence, and CrudEntityControllerBase exposes it over HTTP |
g05/g12 |
| 100 | Outbox resolved from the messaging mode (amends 003, on the rule 021 set for the inbox): MessageBus:EnableOutbox is bool?, IsOutboxEnabled resolves unset as Provider != InProcess, an explicit value wins in both directions; on the disabled path AddInfrastructure registers neither OutboxProcessor nor OutboxCleanupService and adds OutboxDisabledNoticeService instead, the schema is kept, and the one combination that cannot work (a broker with the outbox off) is refused at startup |
g04/g14 |
| 101 | MMCA.Common metapackage (the Core 6): one PackageReference in place of the six a standard host always takes (Shared, Domain, Application, Infrastructure, API, Aspire), ordered so the bundle reads as the architecture it installs; it ships no assembly (IncludeBuildOutput=false, NU5128 suppressed in that project alone) and is versioned by MinVer off the same tag, so it releases in lockstep to both registries |
devops-cicd |
| 102 | PBKDF2-only password hashing (supersedes 032): one IPasswordHasher, one PasswordHasher implementation registered with TryAddSingleton (stateless, three private constants), PBKDF2-HMAC-SHA512 with a 32-byte salt, 64-byte digest, 600,000 iterations and a FixedTimeEquals compare; VerifyPassword has no branch, so the legacy HMAC salt-length selection and every HMACSHA512 usage are gone from Source/ in all four repos |
g08 |
| 103 | bUnit component-test tier as a package: MMCA.Common.Testing.UI ships BunitComponentTestBase, which fixes once the choices every consumer UI test tree would otherwise re-derive (bUnit v2 on the xUnit v3 / Microsoft Testing Platform line, BunitContext and Render<T> isolated behind RenderUnderTest/RenderAs, the MudBlazor service set and the JS-interop stubs), so a move off that bUnit line changes one file |
g27 |
| 104 | Plain enums by default, Enumeration<T> opt-in: a bounded set is a plain C# enum unless a member must carry data or behavior; the shipped smart-enum base discovers public static readonly members once by DeclaredOnly reflection, freezes per-type value and case-insensitive name lookups, returns Result from FromValue/FromName (013), uses type-guarded equality that deliberately declines IEquatable<T>, and does not derive from ValueObject |
g02 |
| 105 | Data residency as a build gate: DataResidencyTestsBase (one [Fact], rubric section 30) parses the region where a repo actually provisions PII-bearing storage from that repo's own infrastructure source of truth (ADC from the SQL_LOCATION_OVERRIDE default in deploy.yml, Store from its DR runbook) and fails the build unless PRIVACY.md states it, with a per-repo denylist that blocks a stale or copied region claim |
g27/devops-cicd |
| 106 | Extension members as the public DI surface: the framework's entire Add* registration surface is written as C# extension(T) blocks inside static classes under LangVersion preview in all four repos (82 blocks in 65 files, 23 of them extension(IServiceCollection services), measured 2026-09-01); the call site is indistinguishable from a classic extension method and the compiler-emitted classic static method keeps the choice reversible, with the public-API baselines recording both shapes |
g14 |
| 107 | ExecuteInTransactionAsync is a re-entrant, retriable, commit-once unit: an inner call joins the ambient transaction instead of nesting, the whole delegate retries under EF's execution strategy with a change-tracker reset per attempt, a failed Result rolls back exactly like a throw, and a commit whose outcome cannot be known surfaces as TransactionCommitAmbiguousException naming what each physical source did |
g07/g05 |
| 108 | One cross-replica mutual-exclusion primitive, IDistributedLock (TryAcquireAsync(key, ttl, wait)): non-reentrant, TTL-bounded, explicitly best-effort, owner-scoped idempotent release; Redis-backed where a connection exists, a warn-once process-local fallback where not; it collapses duplicate work and is never the only guard on an invariant persistence can enforce |
g05/g14 |
| 109 | Feature-by-folder as an enforced convention: the aggregate names the first folder level in Domain/Application/Shared, a technical root then the aggregate in UI/API/Infrastructure, at most twelve direct code files per folder, namespaces follow folders, and a layout change ships as a breaking release with a migration map (FolderWidthTestsBase enforces the cap in every repo) |
g27 |
| 110 | Rubric v2 keeps 34 categories with stable numbering, replacing the two overlap-heavy ones in place: section 10 becomes Messaging & Integration Architecture and section 16 becomes AI-Native Application Architecture (N/A until a product feature calls a model), with criteria added to eleven others | 99-coverage-audit (rubric matrix; every chapter tag carries the v2 names since the 2026-09-05 retag), g04 (section 10), g19 (section 16) |
| 111 | AI session scoring governed as a production dependency: the model call sits behind IAiScoringService (declared in Application, carrying ModelId + PromptVersion, both persisted with every score), a golden-replay + prompt-contract evaluation suite is a deploy precondition, the untrusted half of the prompt is delimited, escaped and redacted, the response is schema-constrained, and token spend is metered and alerted against a budgeted ceiling |
g18/g19/devops-cicd |
The canonical index for the full set can be found at https://ivanball.github.io/docs/adr/.
3. The external stack (BCL / NuGet, "external Level 0")
These are not first-party and get no per-type sections. Versions are from
MMCA.Common/Directory.Packages.props and MMCA.ADC/Directory.Packages.props (Central Package
Management, see §4). What each is and why it's here:
Web / API
- ASP.NET Core 10 (minimal hosting, MVC controllers), the API surface.
- Asp.Versioning.Mvc 10: API versioning for controllers.
- Microsoft.AspNetCore.Authentication.JwtBearer 10: validates JWT bearer tokens.
- Yarp.ReverseProxy 2.3.0 (ADC), the gateway that fronts the extracted module services; with
Microsoft.Extensions.ServiceDiscovery.Yarpit routes to services by name.
Application / mapping / validation
- FluentValidation 12: request/command validators, run by a pipeline decorator.
- Riok.Mapperly 4.3.1: a source-generated, compile-time object mapper (no runtime reflection). Note ADR-001 chose manual DTO mapping over reflection-based AutoMapper; Mapperly is the compile-time, allocation-free way to keep mapping explicit and fast.
- Scrutor 7: assembly scanning and decorator registration (
TryDecorate) for DI; this is how the CQRS decorator pipeline is wired. - Microsoft.FeatureManagement 4.6: feature flags (e.g.
Notification.PushNotifications). - System.Linq.Dynamic.Core: dynamic
OrderBy/filtering for query endpoints.
Persistence
- EF Core 10 with providers SqlServer, Cosmos, and Sqlite: the ORM. Sqlite is used
for fast integration tests; Cosmos is a supported document source. EF concepts you must know:
DbContext(unit of work + change tracker), entity configurations (IEntityTypeConfiguration<T>), migrations (versioned schema deltas), global query filters (the soft-delete mechanism), and interceptors (SaveChangeshooks for audit + domain-event capture). - StackExchange.Redis / SignalR Redis backplane, distributed cache and SignalR scale-out.
Messaging
- MassTransit 8.5.5 (RabbitMQ + Azure Service Bus transports), the broker abstraction behind
IMessageBus's broker implementation. Pinned to v8 by policy: v9 requires a commercial license and crashes broker-enabled hosts at startup; a build-time test fails if the major reaches 9 (MMCA.Common/Directory.Packages.props:49-56carries the pin and the warning comment, and see §4).
Transport (service extraction)
- Grpc.AspNetCore / Grpc.Net.ClientFactory / Grpc.Tools / Google.Protobuf: gRPC server + client
.protocompilation, for synchronous inter-service calls between extracted modules (ADR-007).
UI
- MudBlazor 9.7.0: the Blazor component library and design system (grids, dialogs, forms,
theme). Used by both
MMCA.Common.UIand the ADC UIs. - Microsoft.AspNetCore.Components.*: Blazor (Server + WebAssembly) runtime and authorization.
- Polly 8 (via
Microsoft.Extensions.Http.Resilience), retry/timeout/circuit-breaker resilience on outbound HTTP/gRPC clients.
Hosting / observability (.NET Aspire)
- Aspire.Hosting 13.4.6 (+ RabbitMQ, Azure CosmosDB integrations), local orchestration: the AppHost spins up every service, database, broker, and a dashboard with one command.
- OpenTelemetry (Api/Exporter/Instrumentation) + Azure.Monitor.OpenTelemetry.AspNetCore, structured logs, distributed traces, and metrics, exported to Azure Application Insights.
- Microsoft.Extensions.ServiceDiscovery: resolves service names to endpoints (local and cloud).
- AspNetCore.HealthChecks.*: Redis/RabbitMQ health probes.
Auth / crypto
- System.IdentityModel.Tokens.Jwt 8: JWT creation/validation; JWKS key publishing for cross-service token validation (ADR-004 "authentication dual-fetch").
Versioning / build
- MinVer 7: derives the package version from the git tag (
vX.Y.Z), so releases are tag-driven.
Analyzers (all at error severity, see §4)
- Meziantou.Analyzer, SonarAnalyzer.CSharp, StyleCop.Analyzers, Roslynator.Analyzers, Microsoft.VisualStudio.Threading.Analyzers.
Testing
- xunit.v3 3.2: the test framework (xUnit v3, not v2).
- Microsoft Testing Platform (MTP): the test runner (
global.jsonsets"runner": "Microsoft.Testing.Platform"), not VSTest. This changes how you run a single test (see §6). - bUnit 2: Blazor component testing (the v2 line is the one compatible with xUnit v3 / MTP).
- Microsoft.Playwright 1.61 + Deque.AxeCore.Playwright 4.12: browser E2E and axe-core accessibility (WCAG 2.1 AA) scanning.
- NetArchTest.eNhancedEdition: architecture fitness tests (assert layer/purity rules against compiled assemblies).
- Moq 4 (mocking), AwesomeAssertions 9 (fluent assertions, a FluentAssertions-compatible fork), coverlet (coverage).
4. C#, build, and code-style conventions
- .NET 10.0,
LangVersion: preview: required because the codebase uses C# extension types (extension(T)syntax, see below). - Central Package Management (CPM). All NuGet versions live in each repo's
Directory.Packages.props(ManagePackageVersionsCentrally = true); individual.csprojfiles reference packages by name only. To change a version, edit the props file.[Rubric §15, §32] - NuGet lock files + pinned, audited sources.
MMCA.Commoncommits lock files and pinspackageSourceMappingto nuget.org, so it builds/tests with no GitHub token. CI runsdotnet list package --vulnerableand fails on any vulnerable package. The MassTransit v8 pin is enforced by a build-time test (DependencyVersionTests), a blanket "update all packages" that reintroduces v9 will fail the build by design.[Rubric §32, Dependency & Supply-Chain] TreatWarningsAsErrorsglobally, and five analyzers at error severity. The code must be warning-free to compile.[Rubric §15, Best Practices & Code Quality].editorconfigenforces style at error severity (MMCA.Common/.editorconfig): file-scoped namespaces (csharp_style_namespace_declarations = file_scoped:error, line 102), braces always required (csharp_prefer_braces = true:error, line 138),varonly when the type is apparent (lines 105-107), expression-bodied members preferred (lines 110-117), all accessibility modifiers required (line 73), nothis.qualification (lines 57-60),readonlywhere possible (line 94), interfaces begin withI(error, line 212). The naming rules below that (private fields_camelCase, constantsPascalCase) are declared atwarning, whichTreatWarningsAsErrorspromotes to a build break anyway. Test files relax method-naming and complexity rules via the[Tests/**/*.cs]section (line 737).
C# extension(T) types, read this once
C# 14 (preview) extension members let a static class add members to a type via an extension
block:
public static class DomainHelper
{
extension(string? id) // receiver: the "this" value
{
public TIdentifier Parse<TIdentifier>() { ... } // usable as someString.Parse<int>()
}
}
The codebase uses this heavily for DI registration, every DependencyInjection.cs adds methods
like AddApplication() directly onto IServiceCollection through an extension(IServiceCollection)
block. You'll first meet the syntax in group-02
(DomainHelper, EntityTypeExtensions). (A practical note for the leveling: references written inside an extension block belong
to the enclosing static class, that's how this guide attributes their dependencies.)
Architecture enforcement is doubled (fitness functions) [Rubric §34, §3]
The layer rules are not just convention, they are enforced twice:
- Compile-time,
Source/Build/MMCA.Common.LayerEnforcement.targets, imported for everyMMCA.Common.*project underSource/(MMCA.Common/Directory.Build.props:99-100), inspectsProjectReferences before build and fails with a descriptive error if a layer references a forbidden upstream layer. - Runtime (test),
Tests/Architecture/MMCA.Common.Architecture.Tests(NetArchTest) asserts the same rules against compiled assemblies: layer flow, domain purity, and microservice extraction rules (e.g. Application/Domain/Shared must never reference MassTransit directly, depend onIMessageBusinstead). The rule bodies themselves now live once in the shippedMMCA.Common.Testing.Architecturepackage (one of the lockstep-released packages, seeFACTS.md): a reusable rule library + abstract*TestsBaseclasses that each repo's arch-test project subclasses, supplying only a repo-specificIArchitectureMap, soMMCA.CommonandMMCA.ADC(and, outside this guide's scope, MMCA.Store and MMCA.Helpdesk) all enforce one rule set (ADR-015).
When you move a type between packages, expect both gates to react. This is the codebase's "executable governance", covered fully in the architecture-tests chapter.
5. The solution / test layout
*.slnx: the human solution (XML format).*.slnf, a solution filter used in CI to build a subset fast (MMCA.Store.CI.slnf,MMCA.ADC.CI.slnf).- Microsoft Testing Platform, not VSTest. To run one test class/method you target the test
project and pass an MTP filter after
--:Alwaysdotnet test --project Tests/<path>/<Name>.Tests.csproj -- --filter-method "*Pattern*" # -- --filter-class "*FooTests*"--project <csproj>, never a bare path, and get the flag right: a wrong filter flag exits 5 having run zero tests instead of erroring out loudly. Every test project must contain at least one test or MTP exits 8, so CI passes--minimum-expected-testson every run:1on the ADC legs (MMCA.ADC/.github/workflows/deploy.yml:219) and2000on MMCA.Common's full solution run, where the point is to fail a discovery regression that silently drops thousands of tests (MMCA.Common/.github/workflows/ci.yml:144). - Some UI test projects (
MMCA.Common.UI.Gallery,MMCA.Common.UI.E2E.Tests) are deliberately excluded from the.slnxso the unit-test run stays fast; they run in a dedicated CI job and are built by csproj path.
6. The 34-category architecture-evaluation lens
This codebase is also scored against a 34-category rubric
(Website/docs-src/governance/ArchitectureEvaluationCriteria.md, published at
https://ivanball.github.io/docs/governance/).
This guide weaves the rubric in so you learn the system and the lens it's judged by at the same
time. Each type section tags the categories it genuinely touches as [Rubric §N, Name], with a
one-line "what §N assesses" and "how this code embodies (or under-uses) it". The first occurrence of a
category teaches it; later ones cross-reference back. The guide explains categories; it does not
score them, the filled scorecards live beside the rubric as one repo-prefixed file per repo
(Website/docs-src/governance/common-ArchitectureScorecard.md, adc-ArchitectureScorecard.md,
store-ArchitectureScorecard.md), each paired with a *-RemediationBacklog.md.
Two axes (so a tag can say "mature but mediocre" or vice-versa)
- Maturity (0 to 4), process: how consistently/automatically the pattern is governed (ad-hoc → enforced by CI).
- Implementation (0 to 10), substance: how good the implementation is right now, against the category's criteria and red flags.
The categories, in three parts (quick index, full criteria in the rubric file)
Part A, Application / Backend (§1 to §17): §1 SOLID · §2 Design Patterns · §3 Clean Architecture · §4 Domain-Driven Design · §5 Vertical Slice · §6 CQRS & Event-Driven · §7 Microservices Readiness · §8 Data Architecture · §9 API & Contract Design · §10 Messaging & Integration Architecture · §11 Security · §12 Performance & Scalability · §13 Observability & Operability · §14 Testability & Test Strategy · §15 Best Practices & Code Quality · §16 AI-Native Application Architecture · §17 DevOps & Deployment.
Part B, Front-End / UI (§18 to §28): §18 UI Architecture & Component Design · §19 State Management & Data Flow · §20 Design System, Theming & UI Consistency · §21 Accessibility (a11y) · §22 Responsive Design & Cross-Browser/Device · §23 Front-End Performance & Rendering · §24 Forms, Validation & UX Safety · §25 Navigation, Routing & Information Architecture · §26 Front-End Security · §27 Internationalization & Localization · §28 Front-End Testing & Quality.
Part C, Operational, Governance & Cross-Cutting (§29 to §34): §29 Resilience, Reliability & Business Continuity · §30 Compliance, Privacy & Data Governance · §31 Cost Efficiency / FinOps · §32 Dependency & Supply-Chain Management · §33 Developer Experience & Inner Loop · §34 Architecture Governance & Documentation.
Some categories live most naturally in the DevOps/test chapters (§13 to §14, §17, §28, §29 to §34) and are explained there. The coverage audit will include a matrix proving every one of the 34 is explained at least once against real code or a real artifact.
You're ready for group-01, Result & Error Handling.