Architecture Decision Record

Architecture Decision Records

Accepted ADRs explaining why the core cross-cutting patterns exist. Read these before changing a pattern they describe — they capture context and trade-offs that aren't obvious from the code.

# Decision Summary
001 Manual DTO mapping Per-entity Mapperly source-generated mappers chosen over AutoMapper-style runtime reflection.
002 Navigation populators INavigationPopulator<T> for cross-container/cross-source eager loading.
003 Outbox dual dispatch Outbox + in-process dispatch + background processor; at-least-once delivery. Revised 2026-07-19: integration events route through the outbox to IMessageBus (never local dispatch), lease columns (LockedUntil/LockToken) make replica scale-out safe by construction, and retry exhaustion is dead-lettered loudly (metric + Error log + DeadLetterRetentionDays).
004 Cross-service token validation (JWKS) Extracted services validate Identity's RS256 tokens via JWKS / OIDC discovery (no shared key); HS256 shared-secret stays the monolith default; discovery is gateway-routed with a direct-Identity fallback.
005 Soft-delete vs. erasure Soft-delete stays for lifecycle; IAnonymizable + outbox purge for GDPR/CCPA erasure.
006 Database per service Each service owns its DB + outbox; one sealed context class per engine over the abstract base, one instance per DB. Removed the shared-outbox race (2026-06-07).
007 gRPC cross-service calls *.Contracts + typed clients + Result-over-the-wire for synchronous inter-service calls.
008 Monolith → services + Gateway One service host per module (the monolith with one module enabled), fronted by a YARP Gateway; transport at the edge keeps it reversible.
009 Resilience & recovery objectives Standard resilience handler on every outbound client (fitness-enforced); consumers must declare RTO/RPO + drilled restore + single-region acceptance.
010 Integration-event schema versioning Every integration event carries a SchemaVersion (default 1, fitness-enforced); breaking changes use a new event type + upcaster, never a silent reshape.
011 Single-locale by design (no i18n) en-US only is a deliberate, revisitable non-goal Superseded by ADR-027.
012 gRPC-host transport convention Two coherent profiles; the Kestrel choice forces the gateway-forward mode + JWKS routing. Both consumers default to Profile A (Http2-only h2c + gateway-routed JWKS) after Store converged on 2026-06-22; Profile B (Http1AndHttp2 + ALPN) is retained only for the SignalR/WebSocket hosts (ADC's Notification service and Store's Sales service). Since 2026-07-09 ADC's Notification runs a mixed-endpoint profile: Http1AndHttp2 default endpoint plus a dedicated Http2-only named grpc endpoint for the ADR-039 live-channel push ingress.
013 Result pattern over exceptions Expected failures are Result/Result<T> values with a transport-agnostic ErrorType; only the edge maps to HTTP/gRPC. Exceptions stay for the genuinely exceptional, and the exceptional path has its own edge contract: an ordered IExceptionHandler chain (OperationCanceled, Domain, DbUpdate, Validation, Global; registration order load-bearing) emitting RFC 9457 ProblemDetails (revised 2026-07-21).
014 CQRS decorator pipeline Thin ICommandHandler/IQueryHandler use cases behind a Scrutor decorator chain (FeatureGate → Logging → Caching → Validating → Transactional → Handler); the order is load-bearing. Revised 2026-07-19: business failures (Result.Failure) now roll the transaction back like exceptions, and in-process event dispatch is deferred until after commit.
015 Architecture fitness functions Invariants gate the build twice: a compile-time layer guard (MSBuild) + a shared NetArchTest rule library parameterized by IArchitectureMap, run identically across all four repos (Common / Store / ADC / Helpdesk).
016 Lockstep versioning + MassTransit-v8 pin All packages release at one version (count owned by FACTS.md); consumers swept in one pass (no phased rollout). MassTransit is pinned to v8 (v9 needs a license) and the pin is a fitness-function build gate.
017 HTTP request idempotency [Idempotent] action filter dedups client retries via an Idempotency-Key header + cached replay (24h, X-Idempotent-Replay); distinct from ADR-003's handler idempotency.
018 Polyglot persistence (per-engine sources) Three storage engines (SQL Server / Cosmos / SQLite) behind one model; engine is a [UseDataSource] attribute on the entity config (the orthogonal Engine axis to ADR-006's Name axis). Plumbing shipped + tested; first non-SQL entity not yet in production.
019 Layered rate limiting (authenticated-only global limiter) An always-on global limiter caps authenticated callers per-user (default 300/min) and exempts infra (/health, /alive, /.well-known, gRPC) and anonymous traffic; anonymous abuse is covered by output caching + the login-protection service instead; named policies stay for opt-in per-endpoint tightening.
020 Permission-based authorization over roles A capability layer over RBAC: [HasPermission("…")] resolves to on-demand perm:* policies backed by a central role→permission IPermissionRegistry; modules declare grants additively via AddPermissions. Opt-in and backward-compatible (named role policies untouched; inert until a host grants). Adopted by ADC (Conference/Identity/Engagement), not yet by Store.
021 Consumer-side inbox idempotency Opt-in inbox (IInboxStore / EfInboxStore, MessageBus:EnableInbox) dedups broker redeliveries by MessageId: IntegrationEventConsumer checks before handlers, records after success, in the consumer's own DB (unique index as the race guard). At-least-once-with-dedup (handlers stay idempotent for the crash window). The broker-consume sibling of ADR-003 / ADR-017.
022 Browser session-cookie auth (Blazor SSR) HttpOnly mmca_auth_access / mmca_auth_refresh cookies carry the session; SessionCookieAuthenticationHandler reads claims during SSR prerender (no signature check — the API stays the boundary, ADR-004) so [Authorize] passes on fresh GETs; refresh token stays server-side, hydrated via /auth/session/token. BFF-style, SameSite=Lax with a Sec-Fetch-Site check on the refresh endpoint.
023 Security-response headers + pluggable CSP Centralized hardened security-headers middleware (AddCommonSecurityHeaders) with an ICspPolicyProvider CSP extension point; the baseline CSP omits script-src/style-src so it cannot break Blazor, and HTML hosts register their own policy; both apps' UI hosts use the single shared BlazorCspPolicyProvider (in MMCA.Common.UI.Web, via AddCommonBlazorCsp). Adopted at both apps' Gateway + UI edges.
024 Two-channel user notifications One application use case writes a durable per-user UserNotification inbox (read/unread) and fires a transient SignalR push; transport is behind IPushNotificationSender (no-op Null default, swapped by AddPushNotifications with an optional Redis backplane) and audience behind INotificationRecipientProvider. Push failure is non-fatal (the inbox is the source of truth). ADR-044 adds an optional third OS-level native-push leg after these two, and the hub also carries ADR-039's ephemeral live-channel events. ADC runs a dedicated Notification service on these abstractions.
025 Startup warm-up + readiness gating WarmupHostedService runs IWarmupTasks once in parallel at startup and a ready-tagged WarmupReadinessGate holds /health/ready unhealthy so probes keep traffic off a warming replica; the gate opens even if a task fails (availability over warmth, lazy retry under ADR-009). Built-in task pre-fetches OIDC discovery to kill ACA cold-start. Wired into AddServiceDefaults, so every host gets it.
026 Two-tier caching (swappable substrate + output-cache edge) One ICacheService abstraction whose backing store is chosen at startup (AddCaching picks DistributedCacheService when Aspire registered Redis, else MemoryCacheService) with prefix invalidation and a 30s default TTL; a second HTTP output-cache tier (UseOutputCache, per-host policies) serves public reads at the edge (since ADR-040, authenticated requests too on registered public endpoints). Distinct from ADR-014's caching decorators (which consume this substrate). Every service now registers AddRedisClient alongside the distributed cache, so SCAN-based prefix invalidation is live whenever Redis is configured; the TTL backstops only the no-Redis case (amended 2026-07-23).
027 Multi-locale i18n (supersedes 011) en-US + Spanish via co-located .resx / IStringLocalizer<T>; backend errors localized server-side at the edge keyed by the existing Error.Code (English Message as fallback, title left as a machine marker); one culture cookie is the source of truth across SSR/Server/WASM, forwarded to services as Accept-Language, and persisted to User.PreferredCulture. Culture-less date/number formatting is guarded only by an advisory analyzer suggestion (MA0076); a fitness-rule gate is follow-up.
028 Day/Dark theme mode Connects the already-defined MMCATheme.PaletteDark via MudThemeProvider @bind-IsDarkMode, owned by the shared MmcaThemeProviders component that the shared MainLayout renders; a ThemeService persists the choice to cookie + localStorage + User.PreferredTheme, defaulting to the OS prefers-color-scheme; reuses ADR-027's cookie/profile persistence and ships the toggle in the shared MainLayout beside the culture switcher. The no-flash SSR bootstrap is not yet wired for theme (a first-paint flash is possible).
029 Auth brute-force protection Always-available ILoginProtectionService throttles the anonymous auth surface ADR-019 exempts: email-keyed exponential-backoff login lockout + per-IP registration cap, cache-backed (ADR-026), returning Result. The check/increment/reset sequence is centralized in AuthenticationServiceBase<TUser>; both apps' Identity flows inherit it by subclassing.
030 Startup sole-migrator Each service self-applies its EF migrations at boot (DatabaseInitStrategy=Migrate, minReplicas:1) and is the sole migrator — no deploy-step sqlcmd backstop — deliberately overriding the framework's None-for-prod default after a startup-race incident.
031 Feature-flag management Microsoft.FeatureManagement (config section + Percentage/TimeWindow/Targeting filters) enforced on two surfaces for one flag name: [FeatureGate] on controllers (404 via DisabledFeatureHandler) and IFeatureGated on CQRS handlers (the outermost decorator, NotFound). Disabled = 404, not 403.
032 Password hashing (PBKDF2 + legacy migration) One framework IPasswordHasher: new passwords use PBKDF2-HMAC-SHA512 (32-byte salt, 600k iterations, FixedTimeEquals compare); VerifyPassword picks the algorithm by salt length so pre-existing HMAC-SHA512 records (128-byte salt) still verify and migrate to the new format on the owner's next password set. The legacy branch is load-bearing: dropping it silently breaks every old login.
033 Resource-ownership authorization A row/resource-level ownership axis beside ADR-020's RBAC (the question ADR-020 explicitly scopes out): an OwnerOrAdminFilter action filter 403s a request whose owner parameter (route value or model-bound argument) mismatches the caller's owner claim, with the vocabulary host-configurable via OwnerOrAdminFilterOptions (claim, parameter name, bypass role; defaults customer_id/id/Admin); an OwnershipHelper builds an ownership Specification that row-scopes collection queries. Opt-in per controller/handler, claim-trusting (ADR-004), not full ABAC. Adopted by MMCA.Store and (with user_id/Organizer vocabulary) MMCA.ADC's Engagement module.
034 Generic entity controllers + dynamic query contract Every entity inherits a generic REST surface (EntityControllerBase / AggregateRootEntityControllerBase: list/paged/lookup/by-id + create/delete) plus an OData-lite query contract: sparse fieldsets (fields), per-type IFilterStrategy filtering via QueryFilterModelBinder, sort, pagination + X-Pagination, a MaxUnboundedResultLimit ceiling, and the two-path include strategy. Write-once over bespoke endpoints; the wire contract tracks the entity model (DTO-mediated). Composes with ADR-001 / ADR-002 / ADR-013 / ADR-017.
035 Optimistic concurrency via RowVersion round-trip Every auditable entity carries a RowVersion concurrency token (SQL Server rowversion, IsConcurrencyToken elsewhere) that round-trips through the client on IConcurrencyAware DTOs/update requests; update handlers stamp it back via IWriteRepository.SetOriginalRowVersion so a stale write surfaces as a concurrency conflict that DbUpdateExceptionHandler maps to HTTP 409. Build-gated by the UpdateRequestsAreConcurrencyAware fitness rule (subclassed in ADC and Store); adopted in both via AddRowVersionToAllEntities migrations. Distinct from request idempotency (ADR-017) and inbox dedup (ADR-021).
036 External OAuth login (Google/GitHub) AddExternalAuthProviders federates third-party sign-in behind a short-lived ExternalLogin cookie; OAuthControllerBase completes the handshake and swaps a single-use, 2-minute cached code for the app's local JWT pair (tokens never ride the redirect URL). The local User links by provider+key, by email (guarded since 2026-07-19: ADC rejects the link with ExternalEmailNotVerified when the provider did not assert the email verified), or is created externally (CreateExternal, LoginProvider/ProviderKey fields). Config-gated per provider (OAuth:<Provider>:ClientId), inert until configured; adopted by MMCA.ADC only (MMCA.Store does not wire it).
037 Field-level encryption at rest (AES-256-GCM EF converter) An EncryptedStringConverter transparently encrypts string columns with authenticated AES-256-GCM (random 12-byte nonce, 128-bit tag, Base64 nonce+ct+tag layout; consumer supplies the 32-byte key). Shipped and unit-tested but unadopted: no entity configuration wires it yet (the shipped-but-latent posture ADR-018 also records).
038 Supply-chain provenance (SBOM gate + lock files + vuln audit) Four build-gating controls for a published framework: a CycloneDX SBOM as a hard release gate, committed NuGet lock files, a CI --vulnerable --include-transitive audit that fails on any row except NuGetAuditSuppress-accepted advisories (single source of truth, re-applied in CI; zero suppressions active since the 2026-07-20/21 SQLite direct-pin fix), and packageSourceMapping pinning every package to nuget.org. Extends ADR-016 from versioning/licensing into provenance.
039 Live channel push (ephemeral events over the notification hub) NotificationHub gains JoinChannel/LeaveChannel group membership (keys validated against PushNotificationSettings.ChannelKeyPattern) and a ReceiveChannelEvent client method; a new ILiveChannelPublisher Application abstraction (Null default, SignalR group-send impl swapped by AddPushNotifications, ADR-024 pattern) publishes ephemeral (channelKey, eventName, payloadJson) events. One WebSocket carries durable notifications and lossy live events; the durable-vs-ephemeral split lives at the publisher boundary. Client side: multicast OnChannelEvent subscriptions + automatic channel re-join on reconnect.
040 Authenticated output caching for public reads PublicEndpointOutputCachePolicy (+ AddPublicEndpointPolicy extension) replaces the built-in default policy on [AllowAnonymous], user-independent GET endpoints so an Authorization header no longer bypasses the output cache. The UI attaches a Bearer token to every request, so the default policy served 0% cache hits to logged-in users and every read landed on the database; this policy keeps the GET/HEAD-only, no-Set-Cookie, 200-only guards and takes expiration + eviction tags per named policy. Strict contract: never apply to identity-dependent payloads.
041 Observability and telemetry strategy Shared Aspire OpenTelemetry baseline (AddServiceDefaults) plus framework-specific instrumentation where auto-instrumentation is blind: RED duration histograms from the CQRS logging decorators (meter MMCA.Common.Cqrs, tagged by outcome) and an outbox dead-letter counter (MMCA.Common.Outbox), with X-Correlation-ID middleware (W3C trace-id fallback, echoed on the response) tying logs to traces. Fail-safe cost knobs: ParentBased head sampling via Telemetry:TracesSampleRatio (sample-all unless a valid ratio is set), OutboxPollFilterProcessor dropping idle poll spans from export, and Telemetry:DisableHttpClientMetrics / Telemetry:DisableRuntimeMetrics gating those two meter families (on by default). Dual exporters: OTLP and Azure Monitor, either or both.
042 Device capability abstraction (MAUI Blazor Hybrid) Per-capability contracts in MMCA.Common.UI (Services/Capabilities, 18 at introduction, 22 today after ADR-044/045 and geocoding additions) with TryAdd null/browser fallbacks registered by AddUIShared; heads override AFTER it with plain Add (AddBrowserDeviceCapabilities / the new MMCA.Common.UI.Maui package's UseMauiDeviceCapabilities). UI.Maui is the fifteenth package and the one MAUI-TFM exception: outside MMCA.Common.slnx, built/packed by windows CI jobs, layer rule UI + Shared only. Deep links funnel through the singleton IDeepLinkDispatcher + DeepLinkListener; ExternalLink replaces raw target="_blank" (dead inside BlazorWebView).
043 Mobile deep links, app association, native OAuth callback OAuth:AllowedReturnUrlSchemes lets OAuthControllerBase.CompleteAsync redirect the single-use completion code (never tokens) to an allow-listed custom scheme (atldevcon://oauth-complete) so MAUI's WebAuthenticator can capture it; http(s) never matches (no open redirect), empty list = prior behavior, OriginalString echoed (native callback matching is exact). App association (assetlinks.json with the Play App Signing fingerprint + apple-app-site-association) is served by each app's UI.Web host via MapAppAssociationEndpoints, not the gateway; incoming URIs reuse the ADR-042 dispatcher because all heads share one route table. Framework leg fully shipped (allowlist + association endpoints + MauiExternalAuthBroker); adopted by ADC, not yet by Store.
044 Native push delivery (third notification channel) Amends ADR-024: INativePushSender + IPushDeviceRegistrar (Null defaults, Azure Notification Hubs impls swapped by AddNativePushNotifications only when the NativePush section is enabled and complete) give the send pipeline an OS-level FCM v1/APNs leg that reaches backgrounded and killed apps. Installations carry user:{id} tags (sends target users, OR-chunked at the hub 20-tag cap); SendPushNotificationHandler third leg is non-fatal (inbox stays the source of truth); DevicesController (PUT/DELETE, any authenticated user) manages installations; client side is IPushRegistrationService/IPushDeviceTokenProvider (ADR-042 pattern) - the Null token provider keeps credential-less builds wired but inert.
045 Managed file storage + user avatars (BR-116a) IFileStorageService (Null default; Azure Blob impl via AddAzureBlobFileStorage when the FileStorage section is complete - ServiceUri = DefaultAzureCredential, ConnectionString = Azurite) + IImageProcessor/ImageSharpImageProcessor (decode, auto-orient, exact-square crop, strip ALL metadata, re-encode JPEG - only pixels survive untrusted uploads) + IMediaPickerService UI capability (native pick/capture; web heads render InputFile instead). Avatar contract: 2 MB in, 256x256 JPEG out, avatars/{userId}-{random8}.jpg in a public-read container, [Pii] URL nulled + blob deleted on anonymize.
046 HTTP API versioning strategy One AddCommonApiVersioning call wires header-based versioning (api-version reader, default 1.0 assumed when unspecified, supported/deprecated versions reported on every response); ServiceInfoControllerBase ships a live v1.0-deprecated + v2.0 exemplar and the shared ServiceInfoVersioningContractTestsBase fitness contract asserts the headers per repo. Adopted by every extracted ADC/Store service host plus Helpdesk. The HTTP-contract axis, distinct from ADR-010's integration-event schema versioning.
047 Soft-deleted-user session revocation SoftDeletedUserMiddleware (BR-133, after authentication, before authorization) returns 401 for authenticated callers whose User.IsDeleted, via ISoftDeletedUserValidator behind a 30-second cached check; the validator is lazily resolved so hosts that do not register one (non-Identity services, Helpdesk) no-op. Bounds the stateless-JWT revocation window (ADR-004) to roughly the cache duration instead of the token lifetime.
048 Primitive identifier type aliases Entity IDs are primitives behind per-module global using {Entity}IdentifierType = ... aliases, linked solution-wide via Directory.Build.props, chosen over DDD strongly-typed ID structs: readable signatures with zero EF/serializer/OpenAPI friction, at the cost of no compile-time protection against swapping two same-typed IDs. Wrapper structs were considered and deferred (empty StronglyTypedIds placeholder folders survive in five module .Shared projects).
049 Library-scoped ConfigureAwait(false) policy Packaged non-UI framework code awaits with ConfigureAwait(false), enforced as a build gate (CA2007 = warning for Source/** in MMCA.Common's .editorconfig repo-delta, UI component packages excluded); the application repos keep ConfigureAwait analyzers off. Protects the MAUI head (ADR-042) and any non-ASP.NET consumer from library context-capture deadlocks.
050 JWT + single rotating refresh token One issuance workflow in AuthenticationServiceBase<TUser>: a short-lived stateless JWT access token (default 15 min) plus one server-stored, opaque refresh token per user that rotates on every use; a presented token that mismatches the stored one (or is expired) triggers RevokeRefreshToken and 401. Each rotation re-stamps the expiry (Jwt:RefreshTokenExpirationDays, default 7 days, honored since 2026-07-21), so the lifetime is a sliding inactivity window, not an absolute session cap; refresh is bound to the same principal via GetPrincipalFromExpiredToken. Single-token-per-user means a new login signs out other devices' refresh chains.
051 Client-side auth token lifecycle across render modes One ITokenRefresher abstraction with two head-specific strategies: browser heads (Server/WASM) refresh through the same-origin proxy (SameOriginProxyTokenRefresher, HttpOnly cookie via /auth/session/token, ADR-022) while MAUI refreshes directly against the API (DirectApiTokenRefresher) persisting the rotated pair in OS SecureStorage; AuthDelegatingHandler stamps the bearer on the APIClient pipeline, render-mode-aware ITokenStorageService implementations (WASM in-memory / Server SSR-vs-interactive, single-flight with 30s skew) hold the tokens, and JwtAuthenticationStateProvider drives Blazor auth state. Client half of ADR-022/ADR-050.

Writing a new ADR

Copy the structure of an existing record: Status (Proposed / Accepted / Superseded — date and link when superseding), Context (the forces and the problem), Decision (what we chose, in enough detail to implement), Rationale (why this over the alternatives), Trade-offs (what it costs). Number sequentially (NNN-kebab-title.md) and add a row above. Keep ADRs short and decision-focused; deep mechanics belong in the workspace-level Docs/Architecture/ArchitecturalAnalysis.md (outside this repo) or the per-project CLAUDE.md.