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) | |
| 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.