Onboarding guide
15. Common UI Framework (MudBlazor components, theme, base pages)
What this chapter covers. MMCA.Common.UI is the Blazor presentation package, and it is one of
the two layers (with Grpc) allowed to reference Shared only: its single ProjectReference is
MMCA.Common.Shared (MMCA.Common.UI/MMCA.Common.UI.csproj:42), and every other dependency is a
NuGet package (MudBlazor, Polly, the SignalR client, Scrutor, QRCoder, FeatureManagement,
System.IdentityModel.Tokens.Jwt, MMCA.Common.UI.csproj:19-37). It touches no Application, Domain
or Infrastructure type, which is exactly what lets it compile into a Blazor WebAssembly bundle and
into a .NET MAUI hybrid head (see primer §1). What it ships is the
set of reusable parts every consumer UI assembles pages from: a server-paged data-grid list-page
base class, the brand MudBlazor theme, a Result-returning typed HTTP service base for
talking to the WebAPI, a client-side read cache, the authentication and token-refresh
boundary, list-page state preservation across navigation, vendor-neutral toast and dialog
facades, a pluggable UI-module contract, an end-to-end localization pipeline, and a turnkey
notification inbox / push / live-channel feature. A second, thinner package MMCA.Common.UI.Web
sits above it and holds the pieces that need an ASP.NET pipeline (server-side token storage, the
Blazor Content-Security-Policy provider). The per-app and per-module Razor pages in the consumer apps
(chapter 21) derive from and consume these primitives, and the same
components render across Blazor Server, WebAssembly and MAUI with no per-platform reimplementation.
[Rubric §18, UI Architecture & Component Design] assesses component reuse, separation of
presentation from data access, and whether there is a coherent composition model; nearly every type
in this group exists so a consumer page is composed rather than hand-rolled, which is the shape
ADR-067 records.
The data-access boundary: a Result contract over one named HttpClient. A page never touches
HttpClient, and it never catches an exception to learn what the server said. It depends on
IEntityService<TEntityDTO, TIdentifierType>
(MMCA.Common.UI/Common/Interfaces/IEntityService.cs:20), whose seven CRUD members every one return
a Result or Result<T>
(IEntityService.cs:25-66), and it gets its behavior from the abstract
EntityServiceBase<TEntityDTO, TIdentifierType>
(MMCA.Common.UI/Services/Api/EntityServiceBase.cs:43), which derives in turn from
AuthenticatedServiceBase
(MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:15). That base owns the cross-cutting concerns
of an outbound call. First, a Polly retry policy: 3 retries with exponential backoff (2s, 4s, 8s)
plus up to one second of random jitter so a fleet of clients does not re-converge on the same instant
(AuthenticatedServiceBase.cs:25, :114-116), over a deliberate retryable set rather than "any
5xx", since 501 and 505 are permanent verdicts and are excluded while 408 and 429 are explicit
invitations to come back (AuthenticatedServiceBase.cs:100-109); the policy's onRetry disposes each
superseded response, because Polly hands the caller only the final outcome and an undisposed 5xx
leaks its content buffer and holds its connection out of the pool under exactly the sustained failure
the retries exist to survive (AuthenticatedServiceBase.cs:131-134). Second, a helper that creates a
"APIClient" HttpClient from IHttpClientFactory and stamps the JWT Bearer token onto it from
ITokenStorageService, swallowing the InvalidOperationException that JS
interop throws during SSR prerender (AuthenticatedServiceBase.cs:51-70); a sibling
CreateClientWithToken builds a client around an explicitly supplied token so a request the API
answered 401 can be replayed with one acquired straight from ITokenRefresher
rather than resending the token the server just rejected (AuthenticatedServiceBase.cs:80-87).
Retry and idempotency are coupled on purpose: NewIdempotencyKey()
(AuthenticatedServiceBase.cs:43) is generated once per logical write and set as a default header
on the single client that serves every attempt (EntityServiceBase.cs:159-163, :376-397), so a
retried create dedupes on the server instead of producing a duplicate row (the server half is
IdempotencyHeaders and
IdempotentAttribute,
ADR-017). Creates are the only
verb that carries a key: updates are full PUTs and deletes are naturally idempotent
(EntityServiceBase.cs:156-158). Updates instead carry a precondition: ConcurrencyTagOf renders a
DTO's RowVersion as a weak entity tag when the DTO implements
IConcurrencyAware, and that tag travels as the
If-Match header on the same per-operation client, so every retry states the same precondition
instead of a later attempt succeeding against a version the user never saw
(EntityServiceBase.cs:197-200, :389-395,
ADR-035). Responses come back
in the same PagedCollectionResult<T> /
CollectionResult<T> envelopes the API
returns, and both SendRequestAsync overloads read the response through
ProblemDetailsResultReader
(EntityServiceBase.cs:324-342, :354-370), so a 404 arrives as an
ErrorType.NotFound failure, a rejection as
Validation, a 500 as Unexpected, each carrying the server's own text. What the reader cannot
convert is the absence of a response, and that is
HttpResultExecutor's job
(MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:31): it wraps each call, turns a refused connection,
a broken stream or an unreadable body into Http.TransportFailure and a client-side timeout into
Http.Timeout (HttpResultExecutor.cs:34, :37, :121-122), and rethrows an
OperationCanceledException only when the caller's own token asked for it, because a page owns
its cancellation and must not have a disposed component reported back as an error to render
(HttpResultExecutor.cs:65-72). Many-to-many join endpoints, which have POST and DELETE but no
standalone reads, get their own thinner base, ChildEntityServiceBase
(MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:19), whose DeleteByIdAsync reports a missing
join row as a NotFound failure so "nothing to remove" stays distinguishable from "the remove
failed" (ChildEntityServiceBase.cs:62-79). The page-side half of the same transport is
ResultUiExtensions
(MMCA.Common.UI/Common/ResultUiExtensions.cs:63): TryGetValue unwraps inside a conditional the way
Dictionary.TryGetValue does, branching on IsFailure rather than on a null so a failed
(Items, TotalItems) tuple is not read as a success (ResultUiExtensions.cs:82-97), and the
rendering helpers look every message up as a resource key with pass-through, de-duplicate ordinally,
and order most severe first so a real 403 leads and an incidental validation line never buries it
(ResultUiExtensions.cs:18-29). [Rubric §3, Clean Architecture] and [Rubric §9, API & Contract Design]: the UI binds to a DTO contract and an interface, never to server internals, and the wire
envelope is uniform across every entity. [Rubric §29, Resilience] is the retry, jitter, idempotency
and precondition set. The contract itself is recorded in
ADR-094, and the retirement
of the old exception-throwing UI deviation in
ADR-013.
A third caching tier, on the client. IUiReadCache
(MMCA.Common.UI/Services/Caching/IUiReadCache.cs:32) is a read-through cache sitting in front of the
API client, so a list re-read twice within a few seconds (a grid re-mounted by navigation, a lookup
rendered in two components) costs one round trip. Its four members are TryGetFresh, Set,
InvalidatePrefix and Clear (IUiReadCache.cs:42, :51, :59, :66), and the key is
deliberately the relative URL, path plus the full query string, which is the same key shape the
server's authenticated output cache uses, so a filter, page or sort change misses on both tiers
rather than being served stale by one of them (IUiReadCache.cs:9-15,
ADR-040).
UiReadCache (MMCA.Common.UI/Services/Caching/UiReadCache.cs:18) implements it as a
lock-guarded ordinal dictionary (UiReadCache.cs:20, :25) with lazy expiry, dropping a stale
entry when it is next read instead of running a sweep timer over the few dozen entries a circuit ever
holds (UiReadCache.cs:11-14, :50-52), and TTL resolution picks the longest matching route
prefix so a child route can state a different budget than the endpoint it sits under
(UiReadCache.cs:120-135). Staleness is configuration, not accident:
UiReadCacheOptions
(MMCA.Common.UI/Common/Settings/UiReadCacheOptions.cs:13) binds the UiReadCache section with an
Enabled escape hatch, a 60-second DefaultTtl and the per-prefix override map
(UiReadCacheOptions.cs:16, :24, :32, :41). EntityServiceBase takes the cache as an
optional constructor argument, so a service registered without one behaves exactly like a plain
GET (EntityServiceBase.cs:47, :58, :241-268); only successes are stored, because caching a
failure would pin a transient outage in front of the user for the whole TTL and let a 404 survive the
create that fixed it (EntityServiceBase.cs:260-265); and every successful write invalidates its own
endpoint prefix (EntityServiceBase.cs:281-287). The one cross-cutting hazard is scope: the cache is
scoped, which is per-circuit on Blazor Server but per app lifetime on WebAssembly and MAUI, so
AuthUIService.LogoutAsync clears it explicitly rather than trusting the scope to
end with the session (MMCA.Common.UI/Services/Auth/AuthUIService.cs:127-130, and again on an
unrefreshable session at :156). [Rubric §12, Performance & Scalability] and [Rubric §19, State Management] both land here, and the tier is recorded in
ADR-026.
The list page: DataGridListPageBase<TDto>. This is the most concept-dense type in the group and
the centerpiece of the compose-do-not-repeat thesis. Every list screen in every consumer app derives
from DataGridListPageBase<TDto>
(MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:22), a ComponentBase that encapsulates what
would otherwise be copy-pasted onto each page: server-side paging against MudDataGrid<T>,
CancellationTokenSource lifecycle, loading state, filter and sort extraction from MudBlazor's
GridState<T>, error surfacing through the toast facade, a LoadFailed flag so a failed fetch
renders an inline retry instead of a misleading "no records" empty state
(DataGridListPageBase.cs:42, set at :549 and :571), viewport-driven mobile versus desktop
rendering (it implements IBrowserViewportObserver and flips IsMobile through
BreakpointConstants at the 960 px sidebar-collapse boundary,
DataGridListPageBase.cs:46, :308, and
MMCA.Common.UI/Common/BreakpointConstants.cs:16-17), a persisted dense-density toggle
(DataGridListPageBase.cs:78), and a careful IAsyncDisposable/IDisposable teardown. The fetch
path is Result-shaped end to end: a failed page toasts through
NotifyOnFailure(Toast, Localizer) and returns an empty grid rather than throwing
(DataGridListPageBase.cs:546-551). It also solves a Blazor render-mode problem: grid data captured
during SSR prerender is persisted through PersistentComponentState as a private
PersistedGridState record (DataGridListPageBase.cs:1034), restored in
OnInitialized (:171-175) and re-registered for persisting with an explicit
RenderMode.InteractiveAuto, because a page that inherits its render mode from <Routes> gives the
framework nothing to associate the callback with (DataGridListPageBase.cs:177-194,
ADR-056). A
PrerenderFetchTimeoutMs of 5000 caps how long prerender may block on a cold backend before falling
back to an empty grid the first interactive fetch refills (DataGridListPageBase.cs:84, applied at
:717). [Rubric §23, Front-End Performance & Rendering] assesses render efficiency and avoided
round-trips; this persist-and-restore dance is that concern made concrete. Around the base sit three
smaller helpers that are deliberately not members of it, so a page composing its own layout can
still use them: ListPageActions
(MMCA.Common.UI/Pages/Common/ListPageActions.cs:15) holds the mobile-versus-desktop reload dispatch
and the confirm-delete-reload flow every organizer list page repeats (:25-38, :56);
LatestLoadGuard (MMCA.Common.UI/Common/LatestLoadGuard.cs:38) gives each load
a generation and a token and cancels the previous one, which is what keeps a routed detail component
(reused by Blazor across route-parameter changes) from rendering entity 100's late answer after the
user has navigated to 101 (LatestLoadGuard.cs:50-59, :67); and
OfflineFirstPageSnapshot<TItem>
(MMCA.Common.UI/Pages/Common/OfflineFirstPageSnapshot.cs:22) records the first page of a list into
ILocalCacheStore as a private
CachedPage record (:26) and serves it back only when the device reports itself
offline and only for page 1, so the live path is untouched
(OfflineFirstPageSnapshot.cs:30, :36-46, :54-65). [Rubric §22, Responsive & Cross-Browser] is named by BreakpointConstants and exercised by
MobileInfiniteScrollList<TItem>
(MMCA.Common.UI/Components/Lists/MobileInfiniteScrollList.razor.cs:21), the mobile card list whose
IntersectionObserver sentinel, 500-item rendered cap (:53, :213) and generation-guarded
supersession of in-flight fetches keep a long list bounded; a page that wants infinite scroll without
giving up its own card markup renders InfiniteScrollSentinel alone
(MMCA.Common.UI/Components/Lists/InfiniteScrollSentinel.razor.cs:21), which owns just the observer and is
disposed by the renderer the moment the host stops rendering it (:6-20).
State preservation across navigation. Paging, sort, filters and density live in the URL query
string as the source of truth, encoded and decoded by
ListPageQueryStateService under deliberately short reserved keys (p,
ps, mp, s, sd, d, q, f:<name>) with defaults omitted so a pristine list page has a clean
URL (MMCA.Common.UI/Services/ListPageQueryStateService.cs:15-40), so deep links and browser
back/forward replay correctly. The noisier scroll offset lives in
ListPageStateService
(MMCA.Common.UI/Services/ListPageStateService.cs:63), a per-circuit scoped service whose
synchronous dictionary is the fast path and whose HydrateFromSessionAsync
(ListPageStateService.cs:103) / PersistToSessionAsync (:138) mirror entries through
sessionStorage via a nav-interop.js module (:65) so state survives circuit teardown, forceLoad
navigation and the SSR to WASM transition. Every JS path there is defensively caught (prerender,
disconnected circuit, Safari private mode) so storage can never break the page. The immutable
ListPageState record (ListPageStateService.cs:9) carries page, page size, mobile
page, scroll, sort, density and a page-specific filter dictionary, and is updated with with
expressions. NavigationHistoryService
(MMCA.Common.UI/Services/Navigation/NavigationHistoryService.cs:12) bridges Blazor's
NavigationManager to the browser history API so a detail page can perform a real history.back()
when a previous entry exists and fall back to a fixed path otherwise. [Rubric §19, State Management & Data Flow] assesses a deliberate, scoped state model rather than ambient globals: these are
registered Scoped, so each circuit gets its own instance
(MMCA.Common.UI/DependencyInjection.cs:114-116). [Rubric §25, Navigation & Information Architecture] covers the route catalogue (RoutePaths
(MMCA.Common.UI/Common/RoutePaths.cs:7), NavItem with its role, claim, section and
group facets plus resource-key titles resolved per circuit
(MMCA.Common.UI/Common/NavItem.cs:16), and the NavSection enum whose declaration
order is the sidebar order, MMCA.Common.UI/Common/NavSection.cs:7-17) and the open-redirect guard
ReturnUrlProtector, which accepts only same-origin relative paths beginning
with a single forward slash and rejects protocol-relative forms, backslashes, control characters and
anything that does not parse as a relative URI, replacing each with a fallback
(MMCA.Common.UI/Services/Navigation/ReturnUrlProtector.cs:18-60).
Authentication, the host-polymorphic token refresh, and the devices page. Client-side auth is
contracted by IAuthUIService
(MMCA.Common.UI/Services/Auth/IAuthUIService.cs:18), whose members are Result-returning like the
entity services, and implemented by AuthUIService
(MMCA.Common.UI/Services/Auth/AuthUIService.cs:37), which calls the WebAPI auth/* endpoints,
persists tokens through ITokenStorageService, pushes auth-state changes
through JwtAuthenticationStateProvider so AuthorizeView reacts
immediately, and coordinates push-registration through the device-capability contract
IPushRegistrationService
(AuthUIService.cs:34-40). Two named codes make its local-only failures legible rather than null:
Auth.TokenStorageUnavailable when the sign-in succeeded but JS interop could not persist the tokens,
and Auth.MissingAccessToken when a 2xx carried no usable token, which means the response shape
drifted (AuthUIService.cs:46, :52). Alongside login, register, OAuth exchange, logout, refresh and
change-password, it carries the self-service reset pair (IAuthUIService.cs:55, :62,
ADR-091) and the
multi-device session pair: GetSessionsAsync lists the caller's live refresh sessions newest first
and RevokeSessionAsync ends one of them (IAuthUIService.cs:70, :79,
ADR-097). Those two are
rendered by the framework-owned Sessions page
(MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:26), reachable at /profile/sessions
(RoutePaths.cs:16), which deliberately offers two revoke paths: a per-row sign-out that ends one
other device's session, and a page-level sign-out-everywhere that also ends the caller's own and is
therefore followed by the local logout and a redirect. The current device's row carries no button at
all, since revoking it from here would leave the app signed in on a dead session until the access
token expired (Sessions.razor.cs:16-24, :33, :55). Each row is labelled from
UserAgentSummary (MMCA.Common.UI/Services/Auth/UserAgentSummary.cs:18), which
extracts a browser and a platform from the raw header with the most specific token winning (every
Chromium browser also says "Chrome", and Chrome and Edge both say "Safari",
UserAgentSummary.cs:20-38) and returns the two parts separately, because composing "Chrome on
Windows" in code would hard-code English word order (UserAgentSummary.cs:13-16).
The refresh is the interesting part: one ITokenRefresher abstraction
(MMCA.Common.UI/Services/Auth/Tokens/ITokenRefresher.cs:13) has two implementations picked per host,
SameOriginProxyTokenRefresher for the browser (the refresh token
lives in an HttpOnly cookie and rotation happens server-side behind a same-origin
/auth/session/token proxy, so JS never sees it) and
DirectApiTokenRefresher for MAUI (the refresh token sits in OS
SecureStorage and is exchanged directly against auth/refresh). Storage is host-polymorphic in the
same way: WasmTokenStorageService holds the access token in memory only
and single-flights its re-acquisition behind a lock, since an unguarded ??= lets two callers each
start a hydrate and the later one overwrite the other's token
(MMCA.Common.UI/Services/Auth/Tokens/WasmTokenStorageService.cs:11, :22-38), while
ServerTokenStorageService reads the HttpOnly cookie through
CookieTokenReader whenever a live HttpContext exists (SSR
prerender) and switches to the in-memory token on the interactive circuit
(MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:18, :30-43,
ADR-022). A third
contract, ISecureTokenStore
(MMCA.Common.UI/Services/Auth/Tokens/ISecureTokenStore.cs:16), is raw persistence with no freshness
semantics, and it exists to keep the MAUI graph acyclic: storage depends on the refresher, the
refresher depends on the raw store, and there is no loop (ISecureTokenStore.cs:5-14). The
ISessionCookieSync / JsFetchSessionCookieSync
pair mirrors the in-memory access token into the cookie by firing the fetch from the browser, so
the Set-Cookie lands in the user's own jar under both render modes. All three storage services agree
on one 30-second expiry skew read through JwtTokenInfo.IsFresh
(MMCA.Common.UI/Services/Auth/Tokens/JwtTokenInfo.cs:16-37, used at WasmTokenStorageService.cs:15,24 and
ServerTokenStorageService.cs:23,40), which parses the token client-side without validating its
signature, because the API validates every request. Every outbound call also passes
AuthDelegatingHandler, which attaches the stored bearer token to requests
that do not go through CreateAuthenticatedClientAsync
(MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:10). The lifecycle across render modes is
ADR-051; the cross-service
JWKS validation these tokens flow into is
ADR-004.
Front-end security beyond tokens. [Rubric §26, Front-End Security] assesses token handling, XSS
exposure and secret storage, and this group answers it in four places: keeping the refresh token out
of JS-reachable storage (above); BlazorCspPolicyProvider, which pins
connect-src to 'self' plus the configured API/Gateway origin and its wss form for the SignalR
hub (MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:24, :41-60) and, when the endpoint
cannot be parsed, fails closed: connect-src narrows to 'self' and the policy stays enforced,
so a misconfiguration surfaces immediately as blocked calls in the console rather than as a
Report-Only header that protects nothing and nobody notices (BlazorCspPolicyProvider.cs:16-20,
:52-54), feeding the shared
SecurityHeadersMiddleware through
ICspPolicyProvider;
WebApplicationExtensions.UseAuthenticatedNoStore, which emits
Cache-Control: no-store on authenticated HTML so a logged-out user pressing Back never sees the
previous user's page out of the bfcache while anonymous pages stay bfcache-eligible
(MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:24-44); and the returnUrl sanitizer already
covered.
Forms declare their rules once. The shared auth forms (LoginModel,
RegisterModel, ForgotPasswordModel,
ResetPasswordModel) are plain data-annotation EditForm models, with
PasswordComplexityAttribute mirroring the server's rule (at least 8
characters with upper, lower, digit and a non-alphanumeric character) so the form gives the verdict
the API would, and deferring empty input to [Required] so a blank field shows one message rather
than two (MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:12, :21-30).
AbsoluteUrlAttribute
(MMCA.Common.UI/Validation/AbsoluteUrlAttribute.cs:26) is the same parity argument with sharper
stakes: it requires an absolute http/https URL (:39-52) because these values are rendered
straight into an image source or a link target, so accepting javascript: or data: on the client
and rejecting it on the server would leave a round trip as the only thing between a pasted script URL
and the page (AbsoluteUrlAttribute.cs:5-11). Beyond the auth pages, MudBlazor fields bind their
Validation parameter to a delegate from ModelValidation.For
(MMCA.Common.UI/Validation/ModelValidation.cs:26, :43-49), which hands the model and the member
path MudBlazor supplies to an IModelValidator
(MMCA.Common.UI/Validation/IModelValidator.cs:13). That indirection is the extension point: the
in-box DataAnnotationsModelValidator
(MMCA.Common.UI/Validation/DataAnnotationsModelValidator.cs:21) runs the attributes on the model and
resolves every produced message as a resource key with pass-through, so a model can declare
ErrorMessage = "Validation.AbsoluteUrl" and a plain-English message still renders verbatim
(DataAnnotationsModelValidator.cs:13-19, :36-41); a consumer that keeps its rules in
FluentValidation supplies its own implementation and MMCA.Common.UI never references a validation
library. That client-side parity is the point of [Rubric §24, Forms, Validation & UX Safety]: the
client predicts, the server decides.
The component library is behind two facades. IToastService
(MMCA.Common.UI/Common/Interfaces/IToastService.cs:37) and
IAppDialogService
(MMCA.Common.UI/Common/Interfaces/IAppDialogService.cs:14) are the only way page code raises a
transient notification or asks a yes/no question. The toast contract carries the four named severities
plus a runtime-severity Show, a two-line ShowPersistent that stays until dismissed (the
push-notification shape: a message that arrived unprompted must not expire before the user looks at
the screen, IToastService.cs:64-72) and a ShowAction that renders a button for the undo/view/retry
case, with the explicit warning that the callback runs outside any render callback so a caller whose
work can fail must guard it (IToastService.cs:74-101); severity itself is the framework's own
ToastSeverity enum (IToastService.cs:8).
MudToastService (MMCA.Common.UI/Services/MudToastService.cs:12) and
MudAppDialogService (MMCA.Common.UI/Services/MudAppDialogService.cs:11)
are the only two types in the framework that name MudBlazor's ISnackbar and IDialogService,
and even the severity projection is written out as a switch rather than cast, because the two enums
agreeing numerically today is not a dependency worth taking silently
(MudToastService.cs:80-93). The dialog facade collapses a dismissal (backdrop click, escape) onto
false, so a caller only ever branches on true (MudAppDialogService.cs:14-26). Both are
registered by their own AddCommonUiFacades() (MMCA.Common.UI/DependencyInjection.cs:162-167),
separate from AddUIShared so a bUnit harness can resolve exactly these two without the rest of the
shared-UI surface. [Rubric §1, SOLID] (dependency inversion) and [Rubric §14, Testability]: a
component test records toasts instead of driving a rendered snackbar host.
Design system and theming. Visual consistency is centralized in one static
MMCATheme MudTheme instance (MMCA.Common.UI/Theme/MMCATheme.cs:9, :11) holding a
light palette (:13-47), a full dark palette (:48-84), an Inter-first typography scale (:85-163)
and a 6 px default border radius (:164-167). It is applied through the shared MmcaThemeProviders
component, which renders the four Mud providers every root layout needs exactly once and takes the
theme as a parameter defaulting to MMCATheme.Instance, so an app with its own brand passes a derived
MudTheme instead of duplicating the provider block
(MMCA.Common.UI/Components/MmcaThemeProviders.razor:12-15, :23). The palette itself comes from a
single C# source of truth, BrandColors (MMCA.Common.UI/Theme/BrandColors.cs:10),
whose doc comment states the duplication contract plainly: the CSS custom properties in
wwwroot/app.css must mirror these constants because C# cannot read CSS at build time, and
BrandColorTokenTests asserts the two stay in sync (BrandColors.cs:3-9). Color choices carry
explicit WCAG reasoning: Secondary is Teal 700 #00796B for about 5.3:1 on light surfaces because the
Teal 600 it replaced sat at about 4.0:1, under the AA 4.5:1 floor (BrandColors.cs:21-26), and
WarningContrastText is overridden to #212121 because MudBlazor's default white on #F57F17
measures about 2.65:1 and failed an axe scan on a "Pending Payment" chip (MMCATheme.cs:29-33).
[Rubric §20, Design System, Theming & Consistency] is the home category (one token source, dark
mode, consistent typography) and [Rubric §21, Accessibility] is woven into the palette itself and
into the chrome, down to the skip-to-content link the shared layout renders first
(MMCA.Common.UI/Layout/MainLayout.razor:17).
Dark mode is a service, not a flag. ThemeService
(MMCA.Common.UI/Theme/ThemeService.cs:17, registered Scoped at DependencyInjection.cs:119)
owns the preference: InitializeAsync reads the stored value through a theme.js module and falls
back to the OS prefers-color-scheme only when nothing is stored (ThemeService.cs:18, :34),
SetDarkModeAsync persists through the same module and raises OnChange (ThemeService.cs:28,
:53), and the JS module handle is held by LazyJsModule
(MMCA.Common.UI/Services/LazyJsModule.cs:20), a single-flight importer that caches the in-flight
import under a lock so two concurrent callers cannot leak a second module reference, and that drops a
failed task so an import attempted during prerender does not poison the module for the rest of the
circuit (LazyJsModule.cs:5-19, :22-25). MmcaThemeProviders subscribes to OnChange and
re-renders (MmcaThemeProviders.razor:28, :30-41). Honest caveat: unlike locale, the no-flash
SSR bootstrap is not wired for theme. InitializeAsync is called from OnAfterRenderAsync(firstRender)
because JS interop is unavailable during prerender (MmcaThemeProviders.razor:30-37), so the bound
mode is corrected just after hydration and a brief wrong-theme first paint is possible
(ADR-028).
Internationalization: one culture decision, carried everywhere. The framework serves en-US and
Spanish (es) plus a development-only pseudo locale, and the hard part is not the translations, it is
making one culture decision agree across the InteractiveAuto split (SSR prerender, then an
InteractiveServer circuit, then an InteractiveWebAssembly client) and across the cross-origin REST
services behind the Gateway, with no language flash and no hydration mismatch
(ADR-027, which supersedes the
single-locale stance of ADR-011). A
single non-HttpOnly culture cookie is the source of truth. The WASM client reads it at startup through
MmcaCultureBootstrap.SetBrowserCultureAsync, which assigns
CultureInfo.DefaultThreadCurrent[UI]Culture before RunAsync() and falls back to
SupportedCultures.Default
(MMCA.Common.UI/Services/Culture/MmcaCultureBootstrap.cs:22-34). Outbound API calls forward the active
culture as an Accept-Language header through
CultureDelegatingHandler
(MMCA.Common.UI/Services/Culture/CultureDelegatingHandler.cs:13, :20-25), wired into the "APIClient"
pipeline at DependencyInjection.cs:82,106, because the cross-origin Gateway does not carry the
cookie through to the services and that header is what makes a backend failure come back localized.
View strings are externalized to co-located .resx resolved by IStringLocalizer<T>
(AddLocalization() at DependencyInjection.cs:64), anchored by two marker types:
SharedResource for cross-cutting chrome
(MMCA.Common.UI/Resources/SharedResource.cs:9) and MudTranslations for
MudBlazor's own component text (pager, filter menus, pickers,
MMCA.Common.UI/Resources/MudTranslations.cs:10), served through
ResxMudLocalizer, which AddUIShared TryAdds because AddMudServices
registers no MudLocalizer of its own (DependencyInjection.cs:73-77) and whose values degrade to
MudBlazor's built-in English when a key reports ResourceNotFound
(MMCA.Common.UI/Globalization/ResxMudLocalizer.cs:7-19). Applying a switch is host-specific and sits
behind ICultureApplier: the web default
EndpointCultureApplier force-loads the server /culture/set endpoint so
the server re-renders SSR under the new cookie and the WASM runtime re-reads it on startup
(MMCA.Common.UI/Services/Culture/EndpointCultureApplier.cs:18-32), while a MAUI hybrid head, having no
ASP.NET pipeline, replaces it after AddUIShared with an in-process applier
(MauiCultureApplier, chapter 26). The
development-only pseudo locale is the group's own i18n test harness:
PseudoStringLocalizerFactory decorates IStringLocalizerFactory
unconditionally (DependencyInjection.cs:71,
MMCA.Common.UI/Globalization/PseudoStringLocalizerFactory.cs:11) so every IStringLocalizer in the
host is wrapped in a PseudoStringLocalizer at once, and
PseudoLocalizer accents every letter, pads the text and wraps the result in a
bracket sentinel while leaving { } placeholders verbatim
(MMCA.Common.UI/Globalization/PseudoLocalizer.cs:20-30), which makes hard-coded strings,
fixed-width layouts and concatenated fragments all visible in one pass (PseudoLocalizer.cs:12-19).
Even the snackbar text is localized: ErrorMessages keeps its static call sites but
resolves each message from SharedResource once the root layout hands it a localizer, falling back to
the English format string until then (MMCA.Common.UI/Pages/Common/ErrorMessages.cs:24, :33), and
it never renders an exception's own Message, because raw exception text is neither localizable nor
safe to surface (ErrorMessages.cs:14-22). [Rubric §27, Internationalization] is the home category
here, and adding a locale is a .es.resx sibling plus one allowlist entry, not new infrastructure.
Per-user preference persistence. A signed-in user's culture and theme follow them across devices
via the Identity profile. IUserPreferenceWriter /
ApiUserPreferenceWriter PUT to auth/preferences over the shared
"APIClient" (MMCA.Common.UI/Services/Preferences/ApiUserPreferenceWriter.cs:22, :62-66) using the private
UserPreferencesRequest record (:29), and
IUserPreferenceReader /
ApiUserPreferenceReader GET the same endpoint at login and return the
immutable UserPreferences record, whose null fields mean "leave unchanged"
(MMCA.Common.UI/Services/Preferences/UserPreferences.cs:9,
MMCA.Common.UI/Services/Preferences/ApiUserPreferenceReader.cs:14, :21-31). The write is strictly
best-effort: the cookie is the device-local runtime channel and a failed persist never breaks the
in-page switch. Best-effort has a cost, though, and both sides guard it, first by refusing to send
when the token is missing, unreadable or within 30 seconds of expiry via JwtTokenInfo.IsFresh
(ApiUserPreferenceWriter.cs:27, :47; ApiUserPreferenceReader.cs:21, :31), and second by
remembering the exact token the API last answered 401 to, so a revoked session costs one failed
request rather than one per toggle (ApiUserPreferenceWriter.cs:31-37, :55-58, :68-71).
Comparing the token rather than setting a latch is what lets a fresh sign-in resume writing with no
reset step. That is a [Rubric §13, Observability & Operability] detail as much as a [Rubric §19, State Management] one: at low traffic, one 401 per theme toggle is enough on its own to trip a
failed-request alert rule.
Pluggable UI modules. The module system that organizes the back end
(IModule, chapter 14) has a front-end counterpart in
IUIModule (MMCA.Common.UI/Common/Interfaces/IUIModule.cs:10). A module descriptor
exposes its navigation entries as NavItem values, the Assembly holding its Razor pages
so the host can add it to AdditionalAssemblies for route discovery, and two defaulted collections of
component types to render in the app bar and at the root layout (IUIModule.cs:12-22). The
registration prologue is shared too: AddUIModule<TModule>() runs one Scrutor scan that picks up
every IEntityService<,> implementation in the module's assembly as scoped, then registers the
descriptor as a singleton (MMCA.Common.UI/DependencyInjection.cs:207-217), so a module's own
Add{Module}UI() no longer carries its own copy of that scan and can still register services that
must win afterwards. UIModuleConfiguration lets a host switch a module off
through Modules:{name}:Enabled, defaulting to enabled when the section is absent
(MMCA.Common.UI/Common/Settings/UIModuleConfiguration.cs:18-22), and
IHomePageContent is the per-app landing-page hook behind the shared / route,
naming the component type and the page title
(MMCA.Common.UI/Common/Interfaces/IHomePageContent.cs:8-15). Adding a feature module therefore wires
its pages, its services and its menu entries into the shell with no edit to the shell.
[Rubric §18, UI Architecture] and [Rubric §1, SOLID] (open/closed).
A complete vertical slice shipped inside the framework: notifications. Unlike the rest of the
package, which is base classes consumers extend, the Notifications area is a finished feature an app
switches on with one call. NotificationUIModule
(MMCA.Common.UI/Notifications/NotificationUIModule.cs:15) contributes a user-facing inbox nav entry
plus an Organizer-gated push-notification entry (:17-21), the app-bar
NotificationBell (:23) and a root-layout listener component (:25);
NotificationInbox, NotificationList and
NotificationSend (with its NotificationSendModel
form model) render it; NotificationInboxService and
PushNotificationService (behind
INotificationInboxUIService and
IPushNotificationUIService) call the API; and
NotificationHubService
(MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:26) holds the SignalR
connection to the API's NotificationHub, retrying an
initial connect up to 3 times with doubling backoff and discarding a connection that never started so
a later join is not blocked forever (NotificationHubService.cs:28, :146-176). The same connection
carries ephemeral live channel events
(ADR-039): components join through
JoinChannelAsync (NotificationHubService.cs:192), membership is reference-counted per key by
ChannelReferenceCounter
(MMCA.Common.UI/Services/Notifications/ChannelReferenceCounter.cs:16) so one subscriber leaving does
not cut the channel off for the others, handlers are multicast through disposable
ChannelSubscription handles (NotificationHubService.cs:412), and every held
channel is re-joined on Reconnected because SignalR group membership does not survive a new
connection (NotificationHubService.cs:143). Which notifications a user sees can be narrowed by
INotificationScopeProvider, an app-supplied scope key such as
"event:2" that both HTTP services consume so a send and the reads that follow agree, defaulting to
the unscoped NullNotificationScopeProvider and contractually
forbidden from throwing, with the further instruction to fail closed (return the last known key)
rather than degrade to null, since a null silently widens the view to every notification
(MMCA.Common.UI/Services/Notifications/INotificationScopeProvider.cs:9-22). Shared unread state
lives in NotificationState
(MMCA.Common.UI/Services/Notifications/NotificationState.cs:18), which stamps when the count was
last established so a subscriber can ask IsStale instead of re-fetching on every trigger (:7-12)
and arbitrates a single active-poller slot by owner reference rather than a counter, so a teardown
that never unregisters cannot strand the slot for the life of the circuit (:23-29). Both of the
badge's timings are configuration rather than compiled-in constants:
NotificationBellOptions
(MMCA.Common.UI/Common/Settings/NotificationBellOptions.cs:12) binds a 30-second default
PollInterval and a 30-second NavigationRefreshMaxAge, so a deployment paying per API call widens
the poll and a page change within the window keeps the count it has (:22, :29), read by the bell
through IOptions and a TimeProvider
(MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:30, :36-37). The bell also
registers strictly symmetrically, because hosts render it twice inside <AuthorizeView> and a routine
token refresh tears both instances down and rebuilds them (NotificationBell.razor.cs:22-29). The
whole feature is wired by its own AddNotificationUI()
(MMCA.Common.UI/Notifications/DependencyInjection.cs:12, :20-42), kept separate so an app that does
not want real-time notifications never pays for the SignalR plumbing.
How it wires up at startup. A host's Program.cs calls AddUIShared(configuration) once, a C#
extension(IServiceCollection) member (see
primer §4) on
DependencyInjection (MMCA.Common.UI/DependencyInjection.cs:26, :30-142).
In order it binds and validates on start ApiSettings, so a missing endpoint
fails the host rather than the first request (:33-36; the read-only face of those options is
IApiSettings, whose WasmApiEndpoint lets the server call an internal URL while
the browser is handed an external one, MMCA.Common.UI/Common/Settings/IApiSettings.cs:11-17); binds
LayoutSettings, UiReadCacheOptions and NotificationBellOptions without
validation, deliberately optional so a host that configures none of them keeps the compiled-in
defaults (:38-48); TryAdds TimeProvider.System as the clock those staleness policies are
measured against and the read cache itself (:52, :57); sets up localization and the pseudo/Mud
localizer decorators (:60-73); registers the auth and culture delegating handlers and the named
"APIClient" whose base address comes from ApiSettings and whose timeout is pinned to
HttpResilienceDefaults.TotalRequestTimeout
rather than the BCL's arbitrary 100s, so the transport never pre-empts the resilience budget
(:77-102); calls AddCommonUiFacades() for the toast and dialog pair (:106); then TryAdds
AuthUIService, the two list-page state services,
NavigationHistoryService, ThemeService,
EndpointCultureApplier,
NavigationPublicLinkBuilder behind
IPublicLinkBuilder (share-sheet and QR links resolved against the browser
origin, which a MAUI head replaces because its WebView origin is a virtual host nobody else can open,
MMCA.Common.UI/Services/Navigation/IPublicLinkBuilder.cs:9,
MMCA.Common.UI/Services/Navigation/NavigationPublicLinkBuilder.cs:11), the preference reader and writer, and a
default IOAuthUISettings (DefaultOAuthUISettings)
that downstream apps override with
ConfigurationOAuthUISettings, which reads provider availability
from the OAuth section for a server host and from pre-computed Enabled flags for a WASM client
(:109-135, MMCA.Common.UI/Services/Auth/OAuth/ConfigurationOAuthUISettings.cs:13, :24-30); and finally
calls AddDeviceCapabilityDefaults() so every capability contract resolves on every head (:139,
ADR-042, chapter 26).
The TryAdd* discipline is what lets a consumer pre-register its own implementation and win. Browser
hosts add AddClientAuthSessionCookieSync() (:170-174) and AddWasmFormFactor() (:182-183); a
Blazor Server head adds AddCommonServerTokenStorage(), AddCommonBlazorCsp() (before
AddCommonSecurityHeaders, so it beats the TryAdded static provider) and
AddCommonWebFormFactor() from MMCA.Common.UI.Web
(MMCA.Common.UI.Web/DependencyInjection.cs:14, :26-48) plus the UseAuthenticatedNoStore()
middleware. UISharedAssemblyReference
(MMCA.Common.UI/DependencyInjection.cs:222) is the marker other assemblies scan against.
The small Level-0 supporting cast fills in the rest: NotificationRoutePaths
(MMCA.Common.UI/Common/NotificationRoutePaths.cs:8), whose deep-link builder formats invariantly
because the route's :int constraint is the validation boundary (:14-20);
QrErrorCorrectionLevel, the framework's own enum for QrCodeImage so the
component's public API does not pin consumers to QRCoder's ECCLevel
(MMCA.Common.UI/Components/Sharing/QrErrorCorrectionLevel.cs:9);
ApiFileDownloadButton
(MMCA.Common.UI/Components/Forms/ApiFileDownloadButton.razor.cs:14), which gives browsers a plain download
link and native heads a fetch-stage-share flow, stripping directory segments from the supplied file
name so a value built from entity data cannot steer the write out of the temp directory (:21-30);
and MauiBackNavigationBridge with its
BackNavigationResult for MAUI hardware-back handling, which reports both
whether history.back() fired and whether the WebView is at the root of its stack so a host can decide
to exit (MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:19, :28). Form-factor
detection has graduated into its own device-capability layer
(IFormFactor and friends, chapter 26). The
presentational helper MoneyExtensions formats
Money for display, grouping a mixed collection by
currency so unrelated amounts never collapse under whichever symbol came first
(MMCA.Common.UI/Extensions/MoneyExtensions.cs:14, :23-32), keeping a display concern out of the
domain value object, exactly where Clean Architecture wants it.
Read the per-type sections that follow for the mechanics. The consumer-side module UIs live in the ADC
module-UI chapter (chapter 21), and the bUnit component tests plus the
Playwright/axe-core E2E suite that exercise this package are covered in the testing chapter
(chapter 27), which is where [Rubric §28, Front-End Testing]
lives.
BreakpointConstants
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/BreakpointConstants.cs:9· Level 0 · class (static)
- What it is: a one-method static helper that answers "is this viewport a mobile viewport?", so C# viewport detection and the CSS media-query boundary agree on one number.
- Depends on:
MudBlazor.Breakpoint(NuGet, imported atBreakpointConstants.cs:1). Nothing first-party. - Concept introduced, the single authoritative breakpoint.
[Rubric §22, Responsive & Cross-Browser]assesses whether a codebase has one definition of "small screen" rather than a magic number re-chosen per component; this class embodies it by making the mobile/desktop split a named predicate. The doc comment (BreakpointConstants.cs:11-15) pins the threshold as "below the sidebar-collapse threshold (MudBlazor Xs or Sm, i.e. < 960 px)", which is the same boundary the shared stylesheet collapses the sidebar at.[Rubric §20, Design System & Theming]applies for the same reason: one threshold keeps the layout, the nav drawer and the list pages switching modes together instead of at three slightly different widths. - Walkthrough: the type has exactly one member.
IsMobileBreakpoint(Breakpoint breakpoint)(BreakpointConstants.cs:16-17) is expression-bodied and returnsbreakpoint is Breakpoint.Xs or Breakpoint.Sm. That pattern is the whole rule:Mdand wider is desktop, and there is no third state. - Why it's built this way: static and dependency-free, so a component can call it without injecting anything, and moving the mobile threshold is a one-line edit paired with one CSS rule rather than a hunt through component code.
- Where it's used: exactly one production call site, DataGridListPageBase<TDto>'s viewport-change handler
NotifyBrowserViewportChangeAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:303), which setsIsMobilefrom it at line 308 and, on a desktop-to-mobile transition, resetsMobileCurrentPageto 1 and re-requests the mobile data (DataGridListPageBase.cs:310-314). That single assignment is what swaps a desktopMudDataGridfor the MobileInfiniteScrollList<TItem> card layout on every list page in both consumer apps.
IAppDialogService
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IAppDialogService.cs:14· Level 0 · interface
- What it is: the framework's modal-confirmation contract, one method that asks a yes/no question and resolves once the user answers. It exists so page code never names the component library that draws the dialog.
- Depends on: nothing first-party (the file carries no
usingdirectives at all). Implemented by MudAppDialogService over MudBlazor'sIDialogService. - Concept introduced, the vendor-neutral UI facade.
[Rubric §32, Dependency & Supply-Chain]assesses whether a third-party dependency is contained behind your own contract or spread across call sites;[Rubric §14, Testability]assesses whether a unit of behavior can be exercised without its infrastructure;[Rubric §1, SOLID]covers the dependency-inversion half of the same idea. The framework applies all three the same way twice: this interface and its sibling IToastService are the only shapes pages depend on, and their two implementations are the only types in the framework that name MudBlazor'sIDialogService/ISnackbar(MudAppDialogService atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/MudAppDialogService.cs:11, doc comment at:6-10). The payoff is concrete: a bUnit test answers a confirmation prompt with a stub instead of rendering, driving and dismissing a real dialog. The doc comment (IAppDialogService.cs:3-13) also states the deliberate scope limit: only the yes/no shape is abstracted, and richer entity-specific dialogs (DeleteConfirmation) stay component-side rather than growing this contract. - Walkthrough: one member.
ConfirmAsync(string title, string message, string confirmText, string cancelText)returnsTask<bool>(IAppDialogService.cs:26). Two contract details are stated in the XML doc and honored by the implementation. First, every string parameter is documented as "already-localized" (:21-24): the facade never touchesIStringLocalizer, the caller resolves its own copy, which is what keeps the resource key next to the page that owns it (ADR-027). Second, dismissing the dialog without choosing counts as declining (:17-19), so a caller only ever has to branch ontrue. MudAppDialogService implements exactly that by collapsing MudBlazor's tri-state answer withreturn confirmed is true;(MudAppDialogService.cs:25), becauseShowMessageBoxAsyncanswersnullfor a backdrop click or an escape key press (MudAppDialogService.cs:16-18). - Why it's built this way: a four-string method with a
boolanswer is the smallest contract that covers every destructive-action prompt in the framework, and keeping it that small is what makes the vendor genuinely swappable: the whole surface an alternative renderer must satisfy is one method. It is registered byAddCommonUiFacades()alongside the toast facade (DependencyInjection,MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:165), scoped to match the MudBlazor services it wraps. See ADR-067, whose 2026-08-29 revision records the vendor choice and these two facades, and whose 2026-08-31 revision records their move into their own registration call. - Where it's used: injected by the shared framework surfaces that ask before doing something lossy: DataGridListPageBase<TDto>, the notification list, send and inbox pages,
ListPageActions, the signed-in-devices page (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs), and theUnsavedChangesGuardcomponent. Registered for component tests by the shipped bUnit base'sServices.AddCommonUiFacades()call (MMCA.Common/Source/Hosting/MMCA.Common.Testing.UI/Infrastructure/BunitComponentTestBase.cs:53), which usesTryAddsemantics so a test that wants a recording double registers one afterwards (BunitComponentTestBase.cs:50-52).
IHomePageContent
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IHomePageContent.cs:8· Level 0 · interface
- What it is: the hook that lets each consuming application supply its own landing page at the
/route without forking the shared routing or layout. - Depends on:
System.Type(BCL) and, at the consuming end,Microsoft.AspNetCore.Components.DynamicComponent, which the doc comment names as the rendering mechanism (IHomePageContent.cs:3-7). - Concept introduced, late-bound content injection into a packaged shell.
[Rubric §18, UI Architecture & Component Design]assesses whether shared UI infrastructure adapts to per-app content without duplication. The shared package owns the route:Home.razordeclares@page "/"once (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Home.razor:1), injectsIEnumerable<IHomePageContent>(Home.razor:3), and renders<DynamicComponent Type="_contentType" />when a provider resolved (Home.razor:8-11). Because the component arrives as a runtimeTyperather than a compile-time reference, the framework package renders an app's landing page without referencing the app. When no implementation is registered the page falls back to a localized welcome panel (Home.razor:12-21), so a brand-new host still renders something coherent. - Walkthrough: two read-only members.
ComponentType(IHomePageContent.cs:11) is theSystem.Typeof the Razor component to render as the home-page body.PageTitle(IHomePageContent.cs:14) is the browser-tab title, bound byHome.razor:6. - Why it's built this way: an inverted dependency (the app registers into the framework, never the reverse) is what lets the whole shell ship as a NuGet package. Compare the sibling mechanism in IUIModule: both hand the framework a
Typeor anAssemblyand let reflection do the binding, and both exist for the same reason (ADR-067). - Where it's used: implemented once per app and registered once per head. ADC registers
ADCHomePageContentas a singleton in all three heads (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:63,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:51,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:124), with two separate implementations, one for the web heads (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Pages/ADCHomePageContent.cs:11) and one for MAUI (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHomePageContent.cs:8). Store does the same withStoreHomePageContent(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:101,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:41,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:76). - Caveats / not-in-source: the injection point is
IEnumerable<IHomePageContent>, so several registrations do not fail; which one wins is decided byHome.razor's selection code (Home.razor:23onward), not by this interface. Every current host registers exactly one.
LatestLoadGuard
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/LatestLoadGuard.cs:38· Level 0 · class (sealed,IDisposable)
- What it is: a small per-page helper that keeps a routed component showing the load it asked for last, by giving every load a generation number and a cancellation token and cancelling the previous one as the next begins.
- Depends on:
System.Threading.CancellationTokenSourceandObjectDisposedException.ThrowIf(BCL). Nothing first-party. - Concept introduced, generation-guarded supersession.
[Rubric §19, State Management & Data Flow]assesses the correctness of in-flight asynchronous state, and this is the canonical bug it looks for. The doc comment states it precisely (LatestLoadGuard.cs:5-10): Blazor reuses a routed component instance across route-parameter changes, so a page that opens entity 100 (slow response) and then navigates to entity 101 (fast response) receives 100's late answer after 101 has already rendered. Assigning it unconditionally leaves the URL on 101 while the page holds 100, and any action bound to the loaded entity then fires against the wrong record. Cancellation alone does not fix this, because a fetch that ignores its token still completes; the integer generation is the authoritative check. The same pattern appears at component scope inside MobileInfiniteScrollList<TItem>, which snapshots a generation before awaiting and drops the result if the world moved;LatestLoadGuardis that idea extracted into a reusable object so a detail page gets it in three lines.[Rubric §15, Best Practices & Code Quality]applies: the alternative is every page hand-rolling a token source, a counter and a dispose path. - Walkthrough: three private fields (
LatestLoadGuard.cs:40-42): the currentCancellationTokenSource?, theint _generation, and a_disposedflag.Begin()(:50-59) is the entry point. It throws if the guard is disposed (:52), cancels and disposes the previous load through the privateCancelAndDisposeCurrent()(:54), publishes a fresh token source (:55), increments the generation (:56), and returns the pair(CancellationToken Token, int Generation)(:58). Returning a tuple rather than exposing two properties is what makes the generation a snapshot: the caller holds the value it started with, so a laterBegin()cannot retroactively change what it compares against.IsCurrent(int generation)(:67) is the check after the await:!_disposed && generation == _generation. Disposal counts as not-current, so a component torn down mid-fetch also drops its answer instead of assigning into a dead render tree.Dispose()(:70-79) is idempotent via the_disposedearly return (:72-75) and cancels the in-flight load on the way out.CancelAndDisposeCurrent()(:81-91) null-guards, then cancels, disposes and nulls the source, so the guard never double-disposes a token source and never leaks one.- The usage shape is spelled out as a
<code>block in the doc comment (:15-31): aprivate readonly LatestLoadGuard _load = new();field,var (token, generation) = _load.Begin();at the top ofOnParametersSetAsync, anif (!_load.IsCurrent(generation)) { return; }immediately after the await, andpublic void Dispose() => _load.Dispose();.
- Why it's built this way: deliberately not thread-safe, and the doc comment says so in bold (
:32-36). It is built for the renderer's synchronization context, where component lifecycle methods and event callbacks are already serialized, so the fields need no interlocking and the type stays allocation-cheap. That is a contract, not an oversight: sharing one instance across threads is documented as unsupported. - Where it's used: no production call site yet. The type is public and exercised by LatestLoadGuardTests (
MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Common/LatestLoadGuardTests.cs:11, six test methods coveringBegincancelling the prior token, generation advance,IsCurrentafter supersession, and behavior after disposal)[Rubric §28, Front-End Testing], and it is still listed inPublicAPI.Unshipped.txt, meaning it has been added to the public surface but not yet baselined into a shipped release. - Caveats / not-in-source: the doc comment presents the guard as the answer for detail pages, but as of this source no page in the framework or in either consumer app calls
Begin()/IsCurrent()(the only references are the type's own file and its test class). Treat it as shipped-and-tested infrastructure awaiting adoption, not as the pattern currently in force on the detail pages.
NavSection
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavSection.cs:7· Level 0 · enum
- What it is: classifies a sidebar entry into one of three audience groups: everyone, signed-in users, or administrators.
- Depends on: nothing. Consumed by NavItem and by the shared nav menu.
- Concept introduced, audience as a first-class navigation axis.
[Rubric §25, Navigation & Information Architecture]assesses whether the menu structure is declarative and audience-aware rather than a hand-maintained pile of conditionals.[Rubric §11, Security]touches it too, but with an important distinction worth internalizing early: the section is a grouping hint, not an authorization check. What actually hides a link isRequiredRole/RequiredClaimon NavItem, evaluated by the menu (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/NavMenu.razor:198-199), and what actually protects the destination is page-level and API-level authorization. - Walkthrough: three values in declaration order, and the order is itself a contract because the doc comment states sections render in enum declaration order (
NavSection.cs:5).General(:10) is for items visible to everyone, anonymous and authenticated alike.User(:13) is for signed-in non-admin items.Admin(:16) is for administrator and organizer items. - Why it's built this way: an enum rather than a string gives the renderer exhaustive, typo-proof matching, which is exactly what
NavMenu.razorrelies on when it partitions the flattened item list into three collections withi.Section is NavSection.General/User/Admin(NavMenu.razor:202-204). It is a plain C# enum rather than a smart enumeration because no member needs to carry data or behavior, which is the default this codebase commits to (ADR-104). - Where it's used: the
Sectionparameter of NavItem (NavItem.cs:16, defaulting toGeneral), and the three-way partition atNavMenu.razor:202-204. NotificationUIModule shows both non-default values in one file: its inbox item isSection: NavSection.User(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:19) and its push-management item isSection: NavSection.Admin(:20).
RoutePaths
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/RoutePaths.cs:7· Level 0 · class (static)
- What it is: the route-path constants owned by the shared UI package itself. Module-specific routes live in their own
*RoutePathsclasses. - Depends on: nothing.
- Concept introduced, one source of truth for route strings.
[Rubric §25, Navigation & Information Architecture]also covers URL hygiene: a literal"/profile/sessions"typed into four components is four places to get a rename wrong. The class comment (RoutePaths.cs:3-6) states both halves of the convention: centralized paths shared across all UI modules and hosts live here, and module-specific paths live in their own class. That convention is followed consistently across the workspace, by NotificationRoutePaths in this package and byConferenceRoutePaths,EngagementRoutePathsandIdentityRoutePathsin the consumer modules. - Walkthrough: two
public static readonly stringmembers.Home = "/"(RoutePaths.cs:9).Sessions = "/profile/sessions"(:16), the signed-in-devices page. Its doc comment (:11-15) records why it belongs to the framework rather than to an app: the page is framework-owned (MMCA.Common.UI.Pages.Auth.Sessions), lists the user's live refresh sessions with per-device and account-wide sign-out, and is reachable from the shared nav menu's authenticated section, so a consuming app gets it without doing any routing work.
- Why it's built this way:
static readonlyrather thanconstis sufficient because these strings are consumed in navigation andHrefexpressions, not in attribute arguments. That has one consequence worth knowing: a@pagedirective still needs its own literal, soHome.razor:1writes@page "/"directly and this constant covers only the linking and navigating side.Sessionsshows the cost of the convention: the route literal appears in the page's own@pagedirective and again here, and only the tests hold the two together. - Where it's used:
RoutePaths.Homebacks the navbar brand link (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/NavMenu.razor:18), the Home nav link (NavMenu.razor:58), and the first breadcrumb of the sessions page (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:65).RoutePaths.Sessionsbacks the authenticated-section nav link (NavMenu.razor:142) and is asserted by name in both repos' component tests:MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Layout/NavMenuTests.cs:70pins that the link renders inside.nav-auth-section,:75pins its position,:88pins that it is absent for an anonymous user, andMMCA.Store/Tests/Modules/Identity/MMCA.Store.Identity.UI.Tests/Pages/Profile/ProfileTests.cs:67pins that the profile page links to it. - Caveats / not-in-source:
Sessionsis still listed inPublicAPI.Unshipped.txt(MMCA.Common/Source/Presentation/MMCA.Common.UI/PublicAPI.Unshipped.txt:339) whileHomeis baselined inPublicAPI.Shipped.txt:916, so the two members sit at different points in the public-API baseline cycle.
ToastSeverity
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IToastService.cs:8· Level 0 · enum
- What it is: the framework's own five-level scale for how prominent a toast is, and therefore which color and icon the host renders it with.
- Depends on: nothing. Declared above IToastService in the same file.
- Concept reinforced, keeping the vendor enum out of call sites. The facade idea is taught at IAppDialogService; this enum is the part of it that is easy to skip. A contract that abstracts the snackbar but takes
MudBlazor.Severityas a parameter has not abstracted anything, because every caller still references the vendor assembly. The doc comment says exactly that (IToastService.cs:3-7): the five levels mirror what every component library exposes, so a host maps them one-to-one without losing anything, and "naming them here is what keeps the vendor's own severity enum out of page code".[Rubric §32, Dependency & Supply-Chain]and[Rubric §9, API & Contract Design]both apply: the mapping toMudBlazor.Severitylives in one private method inside MudToastService, so swapping renderers is a change to oneswitch. - Walkthrough: five explicitly numbered members, each documented by what it means for the user rather than by color.
Normal = 0(IToastService.cs:11), neutral with no color emphasis.Info = 1(:14), something happened that the user did not ask for.Success = 2(:17), the action the user asked for completed.Warning = 3(:20), completed partially or with something worth knowing.Error = 4(:23), the action failed. The explicit values keep the enum stable if members are ever reordered. - Why it's built this way: five levels rather than four, because
Normal(an uncolored toast) is a distinct affordance fromInfo, and not more, because there is no shape beyond these that the framework raises.Infois the default parameter value on bothShowPersistentandShowAction(:72,:100), which is the neutral choice for an unprompted message. - Where it's used: the
severityparameter ofIToastService.Show,ShowPersistentandShowAction; theseverityparameter of ResultUiExtensions.NotifyOnFailure, where it defaults toToastSeverity.Error(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:269); and MudToastService, the one type that maps it toMudBlazor.Severity(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/MudToastService.cs:27).
UISharedAssemblyReference
MMCA.Common.UI ·
MMCA.Common.UI·MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:222· Level 0 · class
- What it is: an empty marker class whose only job is to give code a compile-checked
typeof(...).Assemblyhandle on the shared UI assembly. - Depends on: nothing.
- Concept introduced, the assembly-marker type. Reflection over an assembly needs an
Assemblyinstance, and there are two ways to get one: a string (Assembly.Load("MMCA.Common.UI"), which fails at run time when someone renames the project) or a type reference (typeof(UISharedAssemblyReference).Assembly, which fails at compile time and is carried along by a rename refactoring). Every layer of the framework ships an equivalent marker; this is the UI layer's.[Rubric §15, Best Practices & Code Quality]assesses exactly this kind of refactor-safety over stringly-typed lookups. - Walkthrough: a single declaration using the semicolon type body,
public class UISharedAssemblyReference;(DependencyInjection.cs:222), with its doc comment on line 217. It shares a file with DependencyInjection but is declared at namespace scope beneath it, outside that static class, because a type nested inside a static class could not serve as a public marker the same way. It carries no members, so nothing can accidentally depend on state it does not have. - Why it's built this way: type-only, public and empty is the whole point. It is the assembly's identity expressed as a symbol the compiler tracks.
- Where it's used: the architecture fitness suite is the real consumer.
CommonArchitectureMapregisters the assembly as the framework's UI layer withFramework(Layer.Ui, typeof(Common.UI.UISharedAssemblyReference).Assembly)(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:27), which is what lets the shared layer-dependency rules know which assembly is the UI layer;AnonymousEndpointTestsincludes it in the assemblies it scans (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Api/AnonymousEndpointTests.cs:19); andNavigationContractTestsenumerates its types withtypeof(UI.UISharedAssemblyReference).Assembly.GetTypes()(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Ui/NavigationContractTests.cs:105).[Rubric §34, Architecture Governance & Documentation]applies: this one-line type is what makes a layer boundary machine-checkable. - Caveats / not-in-source: the doc comment (
DependencyInjection.cs:221) offers "e.g., for Scrutor scanning" as the motivating case, but no Scrutor registration in this repo takes its scan root from this marker.AddUIModule<TModule>()scansFromAssemblyOf<TModule>()(DependencyInjection.cs:211), taking the root from the module descriptor's own assembly instead. Trust the call sites: the current consumers are the architecture tests.
IToastService
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IToastService.cs:37· Level 1 · interface
- What it is: the framework's contract for transient user notifications ("toasts"), covering four named severities, a runtime-chosen severity, a persistent two-line notification, and a toast that carries a clickable action.
- Depends on: ToastSeverity (same file). Implemented by MudToastService over MudBlazor's
ISnackbar. - Concept introduced, fire-and-forget by design. The facade rationale is taught at IAppDialogService; what is specific here is that every method returns
void. The doc comment explains why (IToastService.cs:31-35): during server-side prerender there is no toast host at all, so the call is a silent no-op, and a contract that reported whether the message rendered would force every call site to handle a condition it can do nothing about.[Rubric §24, Forms, Validation & UX Safety]assesses whether outcomes surface to the user in a recoverable way;[Rubric §27, Internationalization]applies because every parameter is documented as already localized, which pushes resource resolution out to the page that owns the key. That last rule is enforced, not merely documented: an architecture fitness regex fails the build on a literal first argument to any toast method (Toast\.(?:Success|Info|Warning|Error|Show|ShowPersistent|ShowAction)\(\s*\$?", atMMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Ui/ArchitectureRules.LocalizedText.cs:14, with the comment at:12-13recording that it is the same guard that previously watched directISnackbaruse at:9). - Walkthrough: seven members, in three tiers.
- The four named severities,
Success(IToastService.cs:41),Info(:45),Warning(:49) andError(:53), each taking one already-localized message. MudToastService implements each as a one-linesnackbar.Add(message, Severity.X)(MudToastService.cs:15-24). Show(string message, ToastSeverity severity)(:62) is the same thing with the level as a parameter, and the doc comment names its motivating caller: ResultUiExtensions.NotifyOnFailure, which carries the severity through as an argument rather than picking one of the four (:55-58).ShowPersistent(string title, string body, ToastSeverity severity = ToastSeverity.Info)(:72) is the push-notification shape: an emphasized title above a body, staying on screen until dismissed. The reasoning (:64-67) is that a message arriving unprompted must not expire before the user has looked at the screen. MudToastService builds it as a render fragment, a<strong>title, a<br>, then the body (MudToastService.cs:30-40).ShowAction(string message, string actionText, Func<Task> onAction, ToastSeverity severity = ToastSeverity.Info, bool requireInteraction = false)(:96-101) is the undo / view-it / retry shape a bare message cannot express. Two contract details are documented rather than enforced. First, the callback runs outside any render callback, so nothing catches what it throws: a caller whose work can fail must guard it and raise its own failure toast (:78-81, restated at:86-88). Second,requireInteraction: truepins the toast open until the user dismisses it or takes the action, and the MudBlazor implementation additionally renders it filled, following the same emphasis conventionShowPersistentuses, "because a toast that waits for the user has to look like it is waiting" (:90-95).
- The four named severities,
- Why it's built this way: a small,
void-returning, already-localized contract is what allows the vendor to appear in exactly one class. It is registered scoped byAddCommonUiFacades()(DependencyInjection,DependencyInjection.cs:164) to match the lifetime of the MudBlazorISnackbarit wraps. See ADR-067. - Where it's used: essentially everywhere a page reports an outcome. Inside the framework package: DataGridListPageBase<TDto>, MobileInfiniteScrollList<TItem>,
ListPageActions, the three notification pages, the sessions page, and theUnsavedChangesGuard,SharePageButton,ApiFileDownloadButtonandNotificationListenercomponents. Outside it, every consumer page reaches it indirectly through ResultUiExtensions.NotifyOnFailure. Component tests resolve it from the shipped bUnit base (BunitComponentTestBase.cs:53, whose comment at:50-51records that without it a consumer's component test fails to resolveIToastServiceand each repo ends up re-registering the same pair).
NavItem
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavItem.cs:16· Level 1 · record
- What it is: the immutable description of one sidebar entry a UI module contributes: title, href, icon, the resource type its title resolves against, optional role and claim gates, its NavSection, and an optional collapsible group.
- Depends on: NavSection;
System.Type(BCL). - Concept introduced, navigation as data contributed by modules.
[Rubric §25, Navigation & Information Architecture]assesses modular, role-aware navigation. The shared menu never knows which modules exist: it injectsIEnumerable<IUIModule>, flattens every module'sNavItems, filters, partitions and renders (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/NavMenu.razor:197-204). That mirrors the server-side IModule contract one layer up (ADR-059 for the server, ADR-067 for this one).[Rubric §27, Internationalization]applies throughTitleResource(ADR-027). - Walkthrough: a positional record declared on one line (
NavItem.cs:16) with eight parameters, four of them optional:Title,Href,Icon: required, positional.Type TitleResource: required, and the fourth positional parameter. This is the localization contract, and the doc comment states it precisely (NavItem.cs:9-14):TitleandGroupare resource keys, resolved againstTitleResourceat render time, per-circuit, so the menu follows the active culture. A key the resource type does not declare renders as the raw string, which is what makes a not-yet-translated entry legible instead of blank.NavMenu.razorimplements exactly that, callingLocalizerFactory.Create(item.TitleResource)[item.Title]for an item (NavMenu.razor:166-170) andLocalizerFactory.Create(group.First().TitleResource)[group.Key]for a group heading (NavMenu.razor:172-180), with the ADR-027 rule restated in a code comment at:163-165.string? RequiredRole = nullandstring? RequiredClaim = null: render gates. The menu applies them asitem.RequiredRole is null || _user?.IsInRole(item.RequiredRole) == trueand the equivalent claim-type test (NavMenu.razor:198-199).NavSection Section = NavSection.General: which sidebar group the item lands in (NavMenu.razor:202-204).string? Group = null: nests the item inside a collapsibleMudNavGroup; the menu groups by it withGroupBy(i => i.Group)in each of the three sections (NavMenu.razor:60,:83,:110).
- Why it's built this way: a positional record gives value semantics and a one-line construction per entry, which is what makes a module's
NavItemsread as a small declarative list. MakingTitleResourcea required positional parameter rather than an optional nullable one is the load-bearing design choice: there is no way to register a nav item that bypasses localization, so "all visible text follows the selected language" holds for the menu by construction rather than by review. ADR-067 records the shape at this exact line (NavItem.cs:16). - Where it's used: returned from the
NavItemsproperty of every IUIModule implementation and rendered byNavMenu.razor. NotificationUIModule is the framework's own example and shows both the minimal and the maximal form:new("Nav.NotificationInbox", NotificationRoutePaths.NotificationInbox, Icons.Material.Filled.Inbox, typeof(SharedResource), Section: NavSection.User)(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:19) andnew("Nav.PushNotifications", NotificationRoutePaths.Notifications, Icons.Material.Filled.NotificationsActive, typeof(SharedResource), RoleNames.Organizer, Section: NavSection.Admin, Group: "Notifications")(:20). Covered by NavMenuTests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Layout/NavMenuTests.cs), which drives the menu through aStubUiModule(IReadOnlyList<NavItem> navItems)(:198). - Caveats / not-in-source:
RequiredRoleandRequiredClaimcontrol rendering only. They hide a link; they do not authorize the destination. The menu keeps a section-level authentication check alongside the per-item one deliberately (NavMenu.razor:104-105), but page-level and API-level authorization remain the enforcing gates.
IUIModule
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IUIModule.cs:10· Level 2 · interface
- What it is: the UI-side counterpart to the server's IModule. A pluggable UI module declares its navigation entries, its Razor assembly for route discovery, and optionally components it injects into the top app bar and the root layout.
- Depends on: NavItem;
System.Reflection.Assembly(IUIModule.cs:1). - Concept introduced, plugging into a packaged application shell.
[Rubric §18, UI Architecture & Component Design]assesses whether there is a coherent composition model. ADR-067 states the problem this solves: before it, every Blazor head owned its ownApp/Routes/ layout / nav markup, so adding a module meant editing the host (a new nav link, a new assembly in the router, a new drawer in the layout), and two apps built on the same framework drifted apart in shell behavior even where they agreed. The framework already shipped the shell; what was missing was a contract letting a module contribute into it. Resolution isIEnumerable<IUIModule>from DI, so the shell composes whatever is registered without naming any module.[Rubric §25, Navigation & Information Architecture]applies because nav is contributed rather than hard-coded, and[Rubric §7, Microservices Readiness]applies in the same spirit as the server contract: a module that can be added or removed by one registration line is a module that can move. - Walkthrough: four members, two of them defaulted.
IReadOnlyList<NavItem> NavItems(IUIModule.cs:13): the module's contribution to the shared sidebar. Flattened byNavMenu.razor:197with.SelectMany(m => m.NavItems).Assembly Assembly(:16): the assembly containing the module's Razor pages. The router consumes it asAdditionalAssemblies="UIModules.Select(m => m.Assembly)"(MMCA.Common/Source/Presentation/MMCA.Common.UI/Routes.razor:8), which is what makes a module's@pageroutes discoverable at run time with no central route table to edit.IReadOnlyList<Type> AppBarComponentTypes => [](:19): a default interface member returning an empty collection expression. Components listed here render inside the top app bar (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:98, also gathered byNavMenu.razor:194).IReadOnlyList<Type> LayoutComponentTypes => [](:22): the same idea at the root-layout level, for drawers, overlays and headless listeners (MainLayout.razor:99).
- Why it's built this way: the two default interface members are what keep the simple case simple. A module that only contributes navigation implements two properties, not four, and can gain app-bar or layout contributions later without a breaking change to anything already written. Passing an
Assemblyrather than a list of page types keeps route discovery reflective, so adding a page is never a framework edit. - Where it's used: implemented by module descriptors across the workspace: the framework's own NotificationUIModule (
MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:15), ADC'sConferenceUIModule(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceUIModule.cs:14),EngagementUIModule(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementUIModule.cs:17),IdentityUIModule(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/IdentityUIModule.cs:13) and the MAUI-head-onlyDeviceUIModule(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/DeviceUIModule.cs:19), plus Store'sCatalogUIModule(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/CatalogUIModule.cs:13),SalesUIModule(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/SalesUIModule.cs:16),IdentityUIModule(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.UI/IdentityUIModule.cs:13) andMauiUIModule(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiUIModule.cs:14). The optional members earn their keep in practice:NotificationUIModulecontributesNotificationBellto the app bar andNotificationListenerto the layout (NotificationUIModule.cs:23-25), Store'sSalesUIModulecontributesCartButtonplusCartDrawerandOrphanOrderRecovery(SalesUIModule.cs:30-32), ADC'sEngagementUIModulecontributesLiveEventListener(EngagementUIModule.cs:31), and ADC'sDeviceUIModulecontributes five headless native listeners at once (DeviceUIModule.cs:33). Registration goes throughAddUIModule<TModule>()(see DependencyInjection), which each module's own one-lineAdd{Module}UI()delegates to, for exampleMMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/DependencyInjection.cs:19. A stub implementation drives the menu tests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Layout/NavMenuTests.cs:198) and another drives the backend-less component gallery (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Gallery/Stubs/GalleryUIModule.cs:14).
IEntityService<TEntityDTO, TIdentifierType>
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IEntityService.cs:20· Level 3 · interface
- What it is: the generic CRUD contract every module page injects to talk to its API endpoints. Seven asynchronous members, every one of them returning a Result.
- Depends on: Result and ErrorType from
MMCA.Common.Shared.Abstractions(IEntityService.cs:1); IBaseDTO<TIdentifierType> as theTEntityDTOconstraint (:21) and BaseLookup<TIdentifierType> as a return type (:41), both fromMMCA.Common.Shared.DTOs(:2). Implemented by EntityServiceBase<TEntityDTO, TIdentifierType>. - Concept introduced, the Result railway crossing the HTTP boundary intact.
[Rubric §18, UI Architecture & Component Design]assesses separation of components from their data access, and[Rubric §9, API & Contract Design]assesses whether the client contract mirrors the server surface. But the teaching point is the second paragraph of the doc comment (IEntityService.cs:10-16): every member returns "the same railway type the server produced, read back from its Problem Details response with the originalErrorTypeintact". A page therefore branches on the outcome instead of catching an exception. That is what makes a 404 renderable as an empty state and a 401 as a redirect without pattern-matching message text, and it is why the sibling helper ResultUiExtensions exists. ADR-013's 2026-08-27 revision records this as the retirement of the UI-layer deviation, and ADR-094 records the surrounding data-access contract. Note also the layering rule this respects:MMCA.Common.UImay referenceMMCA.Common.Sharedonly, which is why both constraints come fromSharedand never from Application or Domain[Rubric §3, Clean Architecture]. - Walkthrough: two generic constraints (
:21-22) bindTEntityDTOto IBaseDTO<TIdentifierType> and requireTIdentifierType : notnull. Then seven members, every one ending in a defaultedCancellationToken:GetAllAsync(bool includeFKs, bool includeChildren, CancellationToken)(:25-28) returnsResult<IReadOnlyList<TEntityDTO>>; the two flags map to API query options.GetPagedAsync(Dictionary<string, (string Operator, string Value)> filters, int pageNumber, int pageSize, string? sortColumn, string? sortDirection, bool includeChildren, CancellationToken)(:31-38) returnsResult<(IReadOnlyList<TEntityDTO> Items, int TotalItems)>. TheTotalItemshalf of that tuple is what makes server-side paging work at all: a grid needs the total to size its pager without fetching the rest of the table. The filter dictionary is the client half of the dynamic query contract (ADR-034).GetAllForLookupAsync(string nameProperty, CancellationToken)(:41-43) returns lightweightId + NameBaseLookup<TIdentifierType> items for dropdowns and autocompletes, so a picker never pulls whole entities.GetByIdAsync(TIdentifierType id, bool includeChildren, CancellationToken)(:50-53) returnsResult<TEntityDTO>, and the doc comment (:45-49) pins the contract that used to be ambiguous: a missing entity is anErrorType.NotFoundfailure, never a success carrying null. That is what lets a detail page writeif (result.IsNotFound())instead of a null check that cannot distinguish "absent" from "the call failed".AddAsync(TEntityDTO entity, CancellationToken)(:56-58) returns the server-assigned DTO including its generated id.UpdateAsync(TEntityDTO entity, CancellationToken)(:61-63) andDeleteAsync(TIdentifierType id, CancellationToken)(:66-68) return the non-genericResult, because there is no value to carry back, only a verdict.
- Why it's built this way: an interface keeps Blazor components testable (mock the contract, no HTTP) and hides the API URL structure behind a typed surface. The generic-over-DTO shape is the client mirror of the server's generic controller layer (ADR-034), which is why one base class implements it for every entity in the system. The uniform
Resultreturn is deliberate rather than convenient: mixing nullable returns for reads withboolfor writes forces each call site to invent its own error story, whereas returningResulteverywhere means one small set of helpers covers all seven members. - Where it's used: implemented for every entity by EntityServiceBase<TEntityDTO, TIdentifierType> (
MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:43-47, which also composes an optional IUiReadCache) and consumed by every module CRUD page, most of them through DataGridListPageBase<TDto>. Registration is automatic:AddUIModule<TModule>()runs a Scrutor scan over the module's assembly that picks up every class assignable toIEntityService<,>and registers it scoped as its implemented interfaces (DependencyInjection,DependencyInjection.cs:210-214). The(Items, TotalItems)shape ofGetPagedAsyncis also the shape MobileInfiniteScrollList<TItem>'s page-fetch delegate expects.
ResultUiExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:63· Level 3 · class (static, extension methods)
What it is: the page-side half of the Result transport. It is the four things a Blazor page ever does with a failed Result, written once so no page hand-rolls them again: unwrap the value, push the message into an inline alert, raise it as a toast, or branch on why it failed.
Depends on: Result, Error, ErrorType and ErrorTypeSeverity from
MMCA.Common.Shared.Abstractions(ResultUiExtensions.cs:3); IToastService and ToastSeverity (:4);Microsoft.Extensions.Localization.IStringLocalizer(:2);System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(:1).Concept introduced, localize-with-pass-through, and severity-ordered deduplication.
[Rubric §24, Forms, Validation & UX Safety]assesses whether a failure reaches the user as one clear, actionable sentence;[Rubric §27, Internationalization]assesses whether that sentence follows the active culture;[Rubric §15, Best Practices & Code Quality]assesses whether the same five lines get re-typed per page. Two mechanisms carry all three.- Pass-through localization (
:17-23): every message is looked up as a resource key, and one the localizer does not declare renders verbatim. That is what lets a single call site handle both an API error whose text the server already localized and a client-side error whoseMessageis a resource key. - Severity-ordered deduplication (
:24-29): messages are made distinct withStringComparer.Ordinaland ordered most-severe-first via ErrorTypeSeverity, so a real 403 or 500 leads and an incidental validation message never buries it. The shape this guards against is common onceResult.Combineaggregates invariants: the same sentence arriving under several codes now reads as one sentence. This is the client mirror of the server-side status selection recorded in ADR-013, using the very same ranking type hoisted intoShared(MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ErrorTypeSeverity.cs:57) so both edges classify one aggregate identically.
The class doc even ships a before/after pair (
:31-62) contrasting the oldtry/catch (Exception ex)shape withif (result.TryGetValue(out var dto)) { ... } else { result.NotifyOnFailure(Toast, L); }.- Pass-through localization (
Walkthrough: eleven public members plus one private helper, in four tiers.
- Unwrapping.
TryGetValue<T>(this Result<T>, [NotNullWhen(true)] out T? value)(:82-97) reads likeDictionary.TryGetValueso the success and failure branches sit side by side. The implementation carries a subtle correctness note in its comment (:86-88): the failure branch is decided byresult.IsFailure, not by whether the value is null, because for a value type (a(Items, TotalItems)tuple, anintcount)defaultis never null and a null test alone would report every failure as a success. The three-argument overload (:116-133) hands the errors back on the failing branch and documents one edge honestly (:102-109): a success carrying a null value also takes the failing branch, and a success has no errors, soerrorscomes back empty. The framework's own services never produce that shape (a 2xx with no value fails withHttp.EmptyResponse), but a caller switching on the error list should not assume it is non-empty. - Composing the message.
LocalizedErrorMessages(this Result, IStringLocalizer?)(:145-159) returns an empty list for a success, so a caller can bind it without a null or success check, and otherwise orders byErrorTypeSeverity.Rankdescending, localizes, drops blanks, and takes ordinal-distinct values (:154-158).LocalizedErrorMessage(:169-173) joins that list with a space and returnsnullfor a success.LocalizeDistinct(IEnumerable<string>?, IStringLocalizer?)(:185-197) gives the same treatment to a plain message list, specifically theMudForm.Errorsshape whose entries are resource keys produced by the model's DataAnnotations; it preserves original order rather than re-ranking, since those entries carry noErrorType. - Rendering.
OnFailureSetError(this Result, Action<string?> setError, IStringLocalizer?)(:226-233) hands the composed message to the page's own error field, the one an inlineMudAlertor thePageErrorStatecomponent renders (its doc comment names it by its current home,MMCA.Common.UI.Components.PageState.PageErrorState, at:200-202, matching the file atMMCA.Common/Source/Presentation/MMCA.Common.UI/Components/PageState/PageErrorState.razor:1), and clears it on success by passingnull(:231), which is why a retry that succeeds does not leave a stale alert on screen.NotifyOnFailure(this Result, IToastService, IStringLocalizer?, ToastSeverity = Error)(:265-281) raises the composed message as one toast, never one per error, callingtoast.Show(message, severity)only when the composed message is non-null (:274-278). Both return the same result instance so the call can sit inline, and both have aResult<T>overload that delegates to the non-generic one and returns the typed result (:237-241,:285-293), which is what keeps(await Service.AddAsync(dto, token)).NotifyOnFailure(Toast, L)chainable. - Branching on why.
HasErrorType(this Result, ErrorType)(:303-307) is the general predicate; the doc comment (:295-299) notes the category survives the HTTP round trip through ProblemDetailsResultReader, which is what makes this meaningful client-side at all.IsNotFound()(:315) andIsUnauthorized()(:323) are the two named cases, and their doc comments state the intended UI reaction: a 404 becomes a "not found" state rather than an error alert, a 401 becomes a redirect to the login route. - The private helper.
Localize(string message, IStringLocalizer?)(:325-334) is where pass-through actually happens: a null localizer or a blank message returns the input unchanged, otherwise it indexes the localizer and returnslocalized.ResourceNotFound ? message : localized.Value(:333).
- Unwrapping.
Why it's built this way: extension methods on
Resultrather than an injectable service, because there is no state and nothing to resolve, so a page uses them without a constructor parameter and a unit test calls them directly. Every rendering helper returns the result it was given, which is what allows the fluent one-liner style the class doc advertises. The nullableIStringLocalizer?parameter everywhere means the helpers work from a context that has no localizer (they then render verbatim) rather than forcing one in.Where it's used: by every page and component that calls an IEntityService<TEntityDTO, TIdentifierType> member, across the framework package and both consumer apps, plus the shared deduplicating error-summary component. Covered by ResultUiExtensionsTests (
MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Common/ResultUiExtensionsTests.cs:17)[Rubric §28, Front-End Testing].Caveats / not-in-source: the class doc opens with "the Result transport (ADR-030)" (
ResultUiExtensions.cs:9), but ADR-030 is030-startup-sole-migrator.md. The Result-pattern record, including the 2026-08-27 revision that namesResultUiExtensionsand its exact member list, is ADR-013; the client data-access half is ADR-094. Trust the ADR index over the comment.
NotificationRoutePaths
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NotificationRoutePaths.cs:8· Level 5 · class (static)
- What it is: the route constants for the framework's own notification feature, three literal paths plus one typed deep-link builder for a single inbox item.
- Depends on:
System.Globalization.CultureInfo(NotificationRoutePaths.cs:1) and theUserNotificationIdentifierTypealias, which resolves toint(MMCA.Common/Source/Core/MMCA.Common.Shared/GlobalUsings.NotificationIdentifierType.cs:2). That alias dependency is why an otherwise Level 0-looking constants class sits at Level 5. - Concept introduced, formatting a URL segment invariantly. The "one source of truth for route strings" idea is taught at RoutePaths; what is new here is the builder method and why it pins a culture.
[Rubric §27, Internationalization]assesses whether culture-sensitive formatting is applied deliberately rather than by default, and this is the case where the correct answer is to opt out. The doc comment (:14-18) spells it out: the route's:intconstraint is the validation boundary, so a culture that renders digit groups (1,234) or non-ASCII digits would produce a URL the constraint rejects. A route segment is machine-readable data, not user-facing text. - Walkthrough: three
public static readonly stringmembers and one method.Notifications = "/notifications"(:10), the admin push-management list.NotificationSend = "/notifications/send"(:11), the admin send page.NotificationInbox = "/notifications/inbox"(:12), the per-user inbox.NotificationInboxItem(UserNotificationIdentifierType id)(:21-22) composes the deep link withstring.Create(CultureInfo.InvariantCulture, $"{NotificationInbox}/{id}"). The target route exists:NotificationInbox.razorcarries both@page "/notifications/inbox"and@page "/notifications/inbox/{Id:int}"(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationInbox.razor:1-2).
- Why it's built this way: kept separate from RoutePaths so an app that never enables the notification module carries no irrelevant constants, and so notification routes evolve on their own.
string.Createwith an explicit culture, rather than plain string interpolation, is the analyzer-friendly way to state "this formatting is deliberate" in a repo where every analyzer is an error. - Where it's used: the notification surfaces navigate by these constants rather than by literals:
NotificationSend.razor.cs:61(its breadcrumb back to the list),:117(post-send navigation) and:136(the cancel path),NotificationList.razor.cs:77(navigate to send), andNotificationBell.razor.cs:238(NavigateToInbox). NotificationUIModule builds its two NavItem entries fromNotificationInboxandNotifications(NotificationUIModule.cs:19-20). ADC'sAppActionRouteMapTestsasserts that a push action resolves toNotificationRoutePaths.NotificationInbox(MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.UI.Tests/Services/AppActionRouteMapTests.cs:39). - Caveats / not-in-source:
NotificationInboxItemhas no call site in any of the four repos as of this source; it is listed inPublicAPI.Unshipped.txt(MMCA.Common/Source/Presentation/MMCA.Common.UI/PublicAPI.Unshipped.txt:315, recorded there with its resolved signatureNotificationInboxItem(int id)), while the three string constants are baselined inPublicAPI.Shipped.txt:913-915. The{Id:int}route it targets is live; the typed builder for it is shipped but not yet adopted.
DependencyInjection
MMCA.Common.UI ·
MMCA.Common.UI·MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:26· Level 7 · class (static, with oneextension(IServiceCollection)block)
- What it is: the composition root of the UI layer. One
AddUIShared(configuration)call wires the shared UI infrastructure every head needs (Blazor Server, WebAssembly, MAUI), and four smaller methods cover the per-head and per-module registrations. - Depends on: nearly the whole group. Settings: ApiSettings, LayoutSettings, UiReadCacheOptions, NotificationBellOptions. Caching: IUiReadCache / UiReadCache. Localization: PseudoStringLocalizerFactory, ResxMudLocalizer. HTTP: AuthDelegatingHandler, CultureDelegatingHandler, HttpResilienceDefaults. Facades: IToastService / MudToastService, IAppDialogService / MudAppDialogService. Services: IAuthUIService, ListPageStateService, ListPageQueryStateService, NavigationHistoryService, ThemeService, ICultureApplier / EndpointCultureApplier, IPublicLinkBuilder / NavigationPublicLinkBuilder, IUserPreferenceWriter, IUserPreferenceReader, IOAuthUISettings / DefaultOAuthUISettings, ISessionCookieSync / JsFetchSessionCookieSync. Capabilities: IFormFactor / WasmFormFactor. Composition: IUIModule, IEntityService<TEntityDTO, TIdentifierType>. Externals: Scrutor (
Decorate,Scan), MudBlazor, andMicrosoft.Extensions.{Configuration, DependencyInjection, Localization, Options}(DependencyInjection.cs:1-16). - Concept introduced, the composition root written as an
extension(T)block. The whole registration surface lives insideextension(IServiceCollection services)(DependencyInjection.cs:28) rather than as classicthis-parameter extension methods; see primer, C# extension(T) types for the language mechanics, taught once.[Rubric §15, Best Practices & Code Quality]assesses one consistent idiom across layers, and this file matches the otherDependencyInjectionclasses in every layer of the workspace.[Rubric §33, Developer Experience]assesses fail-fast startup and a small number of calls per host.[Rubric §12, Performance & Scalability]assesses whether concerns are wired once, centrally: localization, culture forwarding, authentication, resilience and caching are all configured here rather than per page. - Walkthrough: five methods in the extension block.
AddUIShared(IConfiguration configuration)(:30-142), in order:- Options. ApiSettings binds with
.ValidateDataAnnotations().ValidateOnStart()(:33-36), so a missingApiEndpointfails the host at startup rather than at the first HTTP call. LayoutSettings binds without validation because empty defaults are acceptable (:39-40). UiReadCacheOptions (:44-45) and NotificationBellOptions (:47-48) bind the client-side staleness policy; the comment (:42-43) records that both sections are optional and an absent section leaves the compiled-in defaults, which is what a host gets without configuring anything. - Clock and read cache.
TryAddSingleton(TimeProvider.System)(:52), with a comment explaining both directions of theTryAdd(:50-51): a host that already registered one, asAddInfrastructuredoes, keeps it, and a test substitutes aFakeTimeProvider.TryAddScoped<IUiReadCache, UiReadCache>()(:57) is scoped so it is per-circuit on Blazor Server; the comment (:54-56) records the consequence on the other heads, where the scope is the app lifetime, which is why the sign-out path clears it explicitly, otherwise one account's reads would outlive its session[Rubric §26, Front-End Security]. - Localization (ADR-027).
AddLocalization()(:60) forIStringLocalizer<T>, thenDecorate<IStringLocalizerFactory, PseudoStringLocalizerFactory>()(:67), registered unconditionally because the pseudo-locale transform is inert under every other culture and the pseudo locale is only ever activatable in Development (:62-66).TryAddTransient<MudBlazor.MudLocalizer, ResxMudLocalizer>()(:73) localizes MudBlazor's own component text; the comment (:69-72) records whyTryAddis authoritative regardless of host registration order, namely thatAddMudServicesregisters noMudLocalizerof its own and a DI resolution test guards that assumption. - The one HTTP client. Both delegating handlers register transient (
:77-78), then the named"APIClient"(:81-102). Its factory resolvesIOptions<ApiSettings>and setsclient.BaseAddress = new Uri(apiSettings.ApiEndpoint!, UriKind.Absolute)(:88-91). There is deliberately no hand-written endpoint guard, and the comment says why (:83-87): resolving.Valueruns theValidateDataAnnotationsrules registered above, so a missing[Required]endpoint already fails as anOptionsValidationException, and a second check would only give the same failure a different, less informative exception.client.Timeoutis pinned to HttpResilienceDefaults.TotalRequestTimeout(:97) because the BCL's own 100-second default was chosen with no knowledge of the resilience budget and would cut a call off mid-policy at an arbitrary point (:93-96)[Rubric §29, Resilience & Business Continuity]. Default headers are cleared andAccept: application/jsonadded (:98-99), and the two handlers chain in order (:101-102) so every outgoing call carries both the bearer token and the active UI culture asAccept-Language. - Facades.
services.AddCommonUiFacades()(:106), factored out so a bUnit harness can register exactly these two without pulling in the whole shared-UI surface (:104-105). - Scoped services, all via
TryAddso several composing hosts cannot double-register: IAuthUIService (:109), ListPageStateService (:110), ListPageQueryStateService (:111), NavigationHistoryService (:112), ThemeService (:115, ADR-028), ICultureApplier defaulting to EndpointCultureApplier (:121), IPublicLinkBuilder defaulting to NavigationPublicLinkBuilder (:127), and the per-user preference writer and reader (:130-131), documented as best-effort and a no-op for an anonymous user (:129).TryAddSingleton<IOAuthUISettings, DefaultOAuthUISettings>()(:135) supplies a no-op default that a downstream app replaces. - Capabilities.
AddDeviceCapabilityDefaults()(:139, ADR-042) so every capability contract resolves on every head, registered here specifically so MAUI and browser hosts can override afterwards under last-registration-wins (:137-138).
- Options. ApiSettings binds with
AddCommonUiFacades()(:158-163): twoTryAddScopedcalls, IToastService to MudToastService (:160) and IAppDialogService to MudAppDialogService (:161). Its doc comment (:144-157) is the clearest statement of the facade rule anywhere in the codebase: these two implementations are the ONLY types in the framework that name MudBlazor'sISnackbar/IDialogService, they are scoped to match the MudBlazor services they wrap, and the method is called both byAddUISharedand by the shipped bUnit base so a component test resolves the facades without the rest of the shared-UI surface.AddClientAuthSessionCookieSync()(:170-174): oneTryAddScoped<ISessionCookieSync, JsFetchSessionCookieSync>()(:172), the bridge that mirrors the client's in-memory tokens into the HttpOnly cookie read during server-side SSR prerender. Called from both the Blazor Server host and the WebAssembly client (:165-169).AddWasmFormFactor()(:182-183): registers IFormFactor to WasmFormFactor as a singleton. The doc comment names the two alternatives (:176-181):AddCommonWebFormFactor()fromMMCA.Common.UI.Webon the Blazor Server head,AddMauiFormFactor()fromMMCA.Common.UI.Mauion the MAUI head.AddUIModule<TModule>()(:203-213), constrained toTModule : class, IUIModule(:204): a Scrutor scanFromAssemblyOf<TModule>()registering every IEntityService<TEntityDTO, TIdentifierType> implementation scoped as its implemented interfaces (:206-210), thenAddSingleton<IUIModule, TModule>()(:212). The doc comment (:192-197) records the deliberate boundary: this is the two-step prologue every module'sAdd{Module}UI()opens with, and module-specific services stay with the caller afterwards, so a module whose service must beat a shared default still controls its own registration order. The type-parameter doc (:199-202) states the constraint that follows from using the descriptor's assembly as the scan root: it must live alongside the module's entity services and Razor pages.
- Why it's built this way:
TryAddthroughout is both a safety property (several composing hosts callingAddUISharedcannot double-register) and the override mechanism (a host that registers its own implementation before the call wins). Two ordering choices push in the opposite direction and are called out in comments because they are load-bearing: ICultureApplier's default round-trips a server/culture/setendpoint that a MAUI hybrid head does not have, so hybrids override it afterAddUIShared(:117-120), and IPublicLinkBuilder's default resolves against the browser origin, which is wrong for a MAUI WebView whose origin is a virtual host nobody else can open, so that is overridden after as well (:123-126). Read together, the file encodes a rule worth carrying into any new registration: a contract whose correct implementation depends on the head is defaulted here and replaced later, while a contract that is the same everywhere isTryAdded and left alone. - Where it's used: called once at startup by all six consuming UI hosts (ADC's
MMCA.ADC.UI.Web,MMCA.ADC.UI.Web.Clientand MAUIMMCA.ADC.UI, plus the three Store equivalents), each followed by the per-moduleAdd{Module}UI()calls, which are usually one-liners overAddUIModule<TModule>(), for exampleMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.UI/DependencyInjection.cs:19andMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:23.AddCommonUiFacades()has a second caller outside any host, the shipped bUnit base (MMCA.Common/Source/Hosting/MMCA.Common.Testing.UI/Infrastructure/BunitComponentTestBase.cs:53). The"APIClient"configured here is the client every EntityServiceBase<TEntityDTO, TIdentifierType>-derived service resolves. The assembly this class lives in is also the one UISharedAssemblyReference (declared at:218, just below) names for the architecture fitness suite.
ApiFileDownloadButton
MMCA.Common.UI ·
MMCA.Common.UI.Components.Forms·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Forms/ApiFileDownloadButton.razor.cs:14· Level 0 · class (partial component)
- What it is: a payload-agnostic icon button that hands the user a file produced by an API endpoint. On a browser it is a plain download link to the endpoint; on a native (MAUI) head, where the WebView cannot download, it fetches the bytes over the API client, stages a temp file, and opens the OS share sheet. Callers supply the endpoint, the file name, the MIME type and the labels; the button knows nothing about what the payload is (
ApiFileDownloadButton.razor.cs:6-13). - Depends on:
IExternalLinkServiceandIShareService(the device-capability abstractions),IToastService,ApiSettingsthroughIOptions<ApiSettings>, andIStringLocalizer<ApiFileDownloadButton>over the component's own.resxpair, all injected in the markup file (Components/ApiFileDownloadButton.razor:4-9). Externals:IHttpClientFactory,System.IO.File/Path, and MudBlazor'sMudIconButton. - Concept introduced, one component, two heads, one contract.
[Rubric §18, UI Architecture](assesses whether components have a single clear responsibility and keep host differences out of pages) and[Rubric §26, Front-End Security](assesses whether the front end treats caller-supplied data as untrusted). The head split is decided by a capability query, not by a compilation symbol: the markup branches onExternalLink.InterceptsLinks(ApiFileDownloadButton.razor:18), and both branches render the sameMudIconButtonwith the same icon, size and accessible name, so the affordance looks identical while the mechanism differs (ADR-042). The security concept is path containment: a file name built from entity data must not be able to steer a filesystem write, which is whatResolveStagedFileNameexists to prevent. - Walkthrough:
- Parameters (lines 17-80). Three are
[EditorRequired]:RelativeApiPath(line 19),FileName(line 30) andShareTitle(line 35).ContentTypedefaults toapplication/octet-stream(line 43) and is what the share sheet uses to pick target apps.Icondefaults to the generic download glyph (line 47),SizetoSize.Small(line 51).AriaLabel,UnavailableMessageandFailureMessage(lines 59, 66, 73) are nullable overrides over localized defaults.HttpClientNamedefaults to"APIClient"(line 80), the framework's bearer-token plus culture-header client. AccessibleLabel(line 84) resolvesAriaLabel ?? L["Button.Download.Aria"].Value, and the markup binds it to BOTHaria-labelandtitleon either branch (ApiFileDownloadButton.razor:22-23,:31-32), so an icon-only control always carries a name. The default key lives in the component's own resource file (Components/ApiFileDownloadButton.resx:15).BrowserDownloadUrl(lines 89-98) is the browser branch'sHref. It prefersWasmApiEndpointoverApiEndpoint(line 93) because the browser needs the externally reachable gateway URL: on the Server headApiEndpointmay be a container-internal name, and on the WASM headApiEndpointis already the browser-reachable value fetched from/client-config(lines 86-88). With no base URL configured it falls back to the relative path (lines 94-95); otherwise it composes an absolute URI (line 96).ShareDownloadedFileAsync(lines 100-149) is the native branch. It re-entrancy-guards on_isExportingand an empty path (lines 102-105), sanitizes the file name before the fetch so an unusable name costs no download (lines 110-116), creates the named client and pulls the bytes (lines 118-119), stages the file (line 121), and callsShare.ShareFileAsync(line 123), toasting a warning when no share surface accepted it (line 125). Three catch blocks all surface a toast rather than throwing:HttpRequestException(line 128),OperationCanceledException(line 132, which here is theHttpClienttimeout, not a disposal, since no token is passed), and a bareException(line 138) covering the staging write and the share sheet, because this runs in anOnClickcallback where an unhandled exception is fatal to a native host (lines 140-143). Thefinallyclears the guard (line 147).ResolveStagedFileName(lines 162-171) reduces the caller's name to a bare file name withPath.GetFileNameand rejects.and..(lines 164-170), returning null when nothing usable remains. The remarks (lines 155-161) state the exact hazard:Path.Combinediscards its first argument outright when the second is rooted, and..segments walk out of the temp root, so an unsanitized name would decide where the delete and the write land.StageFileAsync(lines 180-192) writes intoPath.GetTempPath()under the already-sanitized name (line 182), deleting any leftover first (lines 184-187) so a truncated previous copy is never shared. It deliberately does not delete after sharing: on Android the share intent returns as soon as it launches, so deleting would race the receiving app (lines 174-178).
- Parameters (lines 17-80). Three are
- Why it's built this way: the download mechanics are the part every consumer would otherwise re-implement per file type, and they are exactly the part that differs per head, so they belong in the framework behind a capability query (ADR-042). Keeping the wording out of the component (labels are parameters with localized fallbacks) is what lets one button serve a calendar file, an export, or a receipt without the framework knowing any of those words.
- Where it's used: ADC wraps it in a thin calendar affordance,
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Components/AddToCalendarButton.razor:8-17, which suppliesContentType="text/calendar", the calendar glyph, and its own localized aria-label and failure messages while the download and share mechanics stay here. Covered byApiFileDownloadButtonTests. - Caveats / not-in-source: the doc comment on
AriaLabel(line 54) tags the icon-only accessible-name rule as "ADR-021", but ADR-021 in the current set is021-consumer-inbox-idempotency; the accessibility contract the rule belongs to is ADR-063. Which apps a native share sheet offers for a given MIME type is OS behavior and not determinable from this source.
IApiSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/IApiSettings.cs:6· Level 0 · interface
- What it is: a two-property read-only view of the API configuration a UI host needs: the base URL it calls, and the base URL it hands to a browser.
- Depends on: nothing. Two
string?getters and no using directives. - Concept introduced, split-horizon endpoints.
[Rubric §7, Microservices Readiness](assesses whether a component copes with the topology it is deployed into rather than assuming one address space). A Blazor Web host and the browser it serves do not see the gateway the same way.ApiEndpoint(line 9) is what the server calls, which in production is a container-internal or service-discovery name that resolves nowhere in a browser.WasmApiEndpoint(lines 11-17) is the externally reachable URL served to the WebAssembly client through the/client-configendpoint. Splitting them lets the server take the faster internal path while the browser gets a name it can resolve, from one configuration section. - Walkthrough:
string? ApiEndpoint { get; }(line 9) andstring? WasmApiEndpoint { get; }(line 17). Both are nullable, because the interface itself imposes no requirement; the[Required]rule lives on the implementation (ApiSettings). - Why it's built this way: a read-only interface over an options class is the shape that lets a consumer state "I only read configuration" instead of taking a mutable settings object. It also documents the contract in one place while
ApiSettingscarries the binding and validation attributes. - Where it's used: implemented by
ApiSettings(Common/Settings/ApiSettings.cs:9). The two endpoint values are read throughIOptions<ApiSettings>at the/client-configendpoints and in the API client factory, not through this interface. - Caveats / not-in-source: no injection site resolves
IApiSettingstoday: a repo-wide search finds the interface only at its declaration and on theApiSettingsclass. The doc comment (line 15) saysWasmApiEndpoint"falls back toApiEndpointwhen null", which is true of Store's endpoint (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:190) but not of ADC's, which throws anInvalidOperationExceptionnaming the missing key rather than handing the browser an unresolvable name (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:158-161). The fallback is host policy, not a property of the contract.
InfiniteScrollSentinel
MMCA.Common.UI ·
MMCA.Common.UI.Components.Lists·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Lists/InfiniteScrollSentinel.razor.cs:21· Level 0 · class (partial component)
- What it is: a bottom-of-list marker that raises
OnVisiblewhen it scrolls near the viewport, so the hosting page can fetch and append its next page. It owns the IntersectionObserver and nothing else: the item markup, the fetch, and the accumulated list stay with the page. - Depends on:
IJSRuntimeand the shared JS module_content/MMCA.Common.UI/infinite-scroll.js(wwwroot/infinite-scroll.js),DotNetObjectReference,ElementReference, and MudBlazor'sMudProgressCircularin the markup. Nothing first-party; it is a sibling ofMobileInfiniteScrollList<TItem>, which drives the same JS module. - Concept introduced, the observer as a child component so disposal is correct.
[Rubric §23, Front-End Performance](assesses render and network cost: paging on demand instead of loading everything, and detaching observers so they stop costing anything),[Rubric §21, Accessibility](assesses whether dynamic content changes are announced without hijacking focus) and[Rubric §18, UI Architecture]. The design point recorded in the doc comment (lines 14-19) is a lifecycle one: a page deriving fromDataGridListPageBase<TDto>cannot hook async disposal because itsDisposeAsyncis not virtual, whereas a child component is disposed by the renderer the moment the host stops rendering it. Rendering the sentinel only while more pages exist therefore makes "detach the observer" a rendering decision rather than a bookkeeping one, and a filter reset that refills the list gets a fresh instance with a fresh observer. - Walkthrough:
- Parameters:
OnVisible(line 26),IsLoading(line 29) which renders the inline progress row, andLoadingLabel(line 32), the localized accessible name for that row. - State (lines 34-39): a per-instance
_observerId(aGuid"N" string, line 34), the_sentinelRefelement reference, the imported_module, the_dotNetRefself-reference handed to JS, plus_observingand_disposedflags. OnSentinelVisible()(lines 45-47) is the[JSInvokable]callback. Its name is fixed by the shared JS module, which calls it by string (lines 41-44). It returns immediately when disposed, otherwise marshals onto the renderer withInvokeAsyncbefore invokingOnVisible.OnAfterRenderAsync(lines 50-58) attaches the observer once, on the first render only.AttachObserverAsync(lines 98-113) imports the module lazily (lines 102-103), creates theDotNetObjectReference(line 104), callsobservewith the reference, the element and the id (line 105), and sets_observing. AJSDisconnectedExceptionis swallowed (lines 108-112): during prerendering or circuit teardown there is no JS to talk to, and the list simply stops at the pages already loaded rather than failing the render.DisposeAsync(lines 61-96) suppresses finalization, guards re-entry with_disposed, callsunobservewhen it was observing (lines 76-79), disposes the module (line 81), tolerates bothJSDisconnectedExceptionandJSException(lines 84-91), and disposes theDotNetObjectReferencein afinally(line 94) so the .NET side is released even if the JS side already went away.- The markup (
Components/InfiniteScrollSentinel.razor:4-13) is a singledivcarrying the element reference, with the progress row rendered only whileIsLoading. That row isrole="status" aria-live="polite" aria-busy="true"(line 9), matchingPageLoadingState's politeness so a screen reader hears that more items are loading without the announcement interrupting reading. - The JS side is deliberately tiny:
observedisconnects any prior observer for the id, creates anIntersectionObserverwithrootMargin: '200px'and invokesOnSentinelVisibleon intersection (wwwroot/infinite-scroll.js:3-14);unobservedisconnects and forgets the id (lines 16-22). The 200px margin is what makes the next page start loading slightly before the sentinel is on screen.
- Parameters:
- Why it's built this way: extracting just the observer is what lets a page keep its own cards, empty state and error state and still get infinite scroll (lines 10-13). The alternative, folding the behavior into the list component, would force any page that wants infinite scroll to also adopt that component's layout.
- Where it's used: ADC's public speaker list renders it below the card grid while more pages exist, wiring
OnVisibleto its own loader and passing a localized loading label (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor:144-145, insidePublicSpeakerList). Covered byInfiniteScrollSentinelTests.
LayoutSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/LayoutSettings.cs:9· Level 0 · class (sealed)
- What it is: the three strings that make the shared shell look like a specific application: the navbar brand text, an optional brand logo URL, and the footer text.
- Depends on: nothing first-party.
System.Diagnostics.CodeAnalysis.SuppressMessage(BCL) for one analyzer waiver, and theMicrosoft.Extensions.Optionsbinder at registration time. - Concept introduced, a settings section as a bound options class.
[Rubric §17, DevOps & Deployment](assesses whether configuration is centralized and typed rather than read ad hoc) and[Rubric §20, Design System and Theming](assesses whether the look of the app is expressed once rather than repeated per page). The shape repeats across every settings class in this namespace: apublic static readonly string SectionNamenaming the configuration section (line 12),init-only properties with compiled-in defaults, and oneservices.AddOptions<T>().Bind(configuration.GetSection(T.SectionName))call inAddUIShared(DependencyInjection.cs:43-44). Because every property has a default, an absent section is not an error: the host simply gets the compiled-in values. Components then takeIOptions<LayoutSettings>and read.Value, so nothing in the shell parses configuration itself. - Walkthrough:
SectionName = "Layout"(line 12).BrandName(line 15) defaults to"MMCA".NavMenurenders it as the brand link's text and folds it into the link's localized accessible name (Layout/NavMenu.razor:18,:26).FooterText(line 18) defaults tostring.Empty, andMainLayoutrenders the footer block only when it is non-blank (Layout/MainLayout.razor:72-76). An empty default therefore means "no footer", not "an empty footer".BrandLogoUrl(line 30) defaults to empty, which renders the text-only brand. When set,NavMenuemits animgbeside the brand text withalt=""andaria-hidden="true"(Layout/NavMenu.razor:22-24): the image is decorative because the brand link already carries its own accessible name, so alt text here would only repeat it to a screen reader (lines 20-24). The property carries a[SuppressMessage]for CA1056 (lines 26-29) whose justification records why it is astringand not aUri: the value is usually a host-relative path such as/img/logo.svg, whichSystem.Uricannot represent withoutRelativeOrAbsoluteround-tripping.
- Why it's built this way: the shell ships in the framework package, so the only way a consuming app can brand it without forking is configuration. Keeping the branding in
appsettings.jsonalso means a deployment can rebrand without a rebuild. - Where it's used: injected as
IOptions<LayoutSettings>byLayout/NavMenu.razor:12andLayout/MainLayout.razor:11. Configured by every UI host, for exampleMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:15-18. Covered byNavMenuTests.
NotificationBellOptions
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/NotificationBellOptions.cs:12· Level 0 · class (sealed)
- What it is: the two numbers that define how stale the unread-notification badge is allowed to be: how often it re-reads the API, and how old its count may be before a navigation re-reads it.
- Depends on: nothing first-party.
TimeSpan(BCL). Bound with the same options shapeLayoutSettingsintroduces. - Concept introduced, staleness as a stated policy.
[Rubric §19, State Management](assesses whether client-side state has an explicit freshness contract) and[Rubric §31, Cost and FinOps](assesses whether the design lets an operator trade money against latency). Both numbers are host decisions rather than compiled constants: a deployment paying per API call widens the poll, one where an unread count must feel instant narrows it (lines 6-10). The framing in the doc comment is important for reading the code: the periodic read is the backstop behind the real-time push, not the primary path, soPollIntervalis the budget for "how long a missed push may go unnoticed" (lines 17-21). - Walkthrough:
SectionName = "NotificationBell"(line 15).PollInterval(line 22), default 30 seconds.NotificationBellbuilds itsPeriodicTimerfrom it (Components/Notifications/NotificationBell.razor.cs:92), against the injected clock rather than the ambient one.NavigationRefreshMaxAge(line 29), default 30 seconds. On a page change the bell accepts the count it already holds unless it is older than this window (NotificationBell.razor.cs:155, viaState.IsStale(...)). That is what keeps a user clicking through five pages in ten seconds from issuing five reads of a number that has not moved (lines 24-28).
- Why it's built this way: both values are pure policy with no correct universal answer, so they belong in configuration; and because both have defaults, a host that says nothing keeps the framework's chosen 30-second budgets.
- Where it's used: bound in
AddUIShared(DependencyInjection.cs:51-52) and injected asIOptions<NotificationBellOptions>byNotificationBell(Components/Notifications/NotificationBell.razor.cs:36). Covered indirectly byNotificationBellTests.
PseudoLocalizer
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoLocalizer.cs:20· Level 0 · class (static)
- What it is: a pure string transform that "pseudo-localizes" text. It accents every letter, pads the result by roughly 40% to simulate real-translation expansion, and wraps it in
[!! ... !!]bracket sentinels, while leaving composite-format placeholders ({0},{name}) byte-identical so the string can still be formatted with arguments (ADR-027 §8). - Depends on:
System.Text.StringBuilderandchar.IsLetter(BCL). Nothing first-party. It is consumed byPseudoStringLocalizer. - Concept introduced, pseudo-localization as an i18n fitness test.
[Rubric §27, Internationalization](assesses whether the app is genuinely translation-ready, not just wired for one extra language) and[Rubric §28, Front-End Testing](assesses whether i18n defects are caught automatically). Pseudo-localization is a development-time technique that surfaces three classes of bug in a single visual pass, without needing a real second translation, and the<remarks>block (PseudoLocalizer.cs:12-19) enumerates exactly those three: (1) any string that stays plain ASCII was hard-coded rather than pulled from a resource, and stands out beside the accented text; (2) any UI that truncates the padded text has a fixed-width layout that a real (longer) translation would break; (3) any label built by concatenating fragments shows one sentinel per fragment, exposing the joins that translate badly. - Walkthrough:
- Three constants (lines 22-24):
OpenSentinel = "[!! ",CloseSentinel = " !!]", andCombiningAcute(the combining acute accent code point) appended after each base glyph so the letter stays readable while visibly altered. Transform(string value)(lines 30-74): returns null/empty input unchanged (lines 32-35); pre-sizes aStringBuilderwith slack for the padding (line 37) and appends the open sentinel (line 38); then walks each character in aswitch(lines 42-66) tracking aninsidePlaceholderflag toggled by{and}(lines 46-53) so placeholder bodies are copied verbatim, and for every letter outside a placeholder appends the combining accent and increments aletterscounter (lines 54-64); finally computes the pad length asMath.Max(1, letters * 2 / 5)(about 40%, line 69), appends a separating space (line 70), that many~characters (line 71) and the close sentinel (line 72), and returns the string (line 73).
- Three constants (lines 22-24):
- Why it's built this way: keeping the transform pure and static (input string to output string, no culture check inside) makes it trivially unit-testable and lets the culture gating live one layer up in
PseudoStringLocalizer. Preserving{...}placeholders is essential: transforming them would corruptstring.Format, so pseudo-loc must accent the template and only then substitute arguments (see the two-step inPseudoStringLocalizer). - Where it's used: called by
PseudoStringLocalizeron every resolved string when the current UI culture is the pseudo locale (SupportedCultures.PseudoLocale, referenced in the doc comment at line 10); inert otherwise. Covered byPseudoLocalizationTests.
QrErrorCorrectionLevel
MMCA.Common.UI ·
MMCA.Common.UI.Components.Sharing·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Sharing/QrErrorCorrectionLevel.cs:9· Level 0 · enum
- What it is: the four QR error-correction strengths the framework's QR components expose. Higher levels survive more damage or occlusion but pack fewer characters into the same module count, so the code grows denser (lines 4-5).
- Depends on: nothing. It is a bare enum with no using directives.
- Concept introduced, a framework-owned enum instead of a re-exported vendor type.
[Rubric §9, API and Contract Design](assesses whether a public surface is expressed in types the owner controls) and[Rubric §32, Dependency and Supply-Chain](assesses whether third-party types leak into contracts consumers must compile against). The doc comment states the decision outright (lines 6-7): declaring this rather than exposing QRCoder's ownECCLevelkeeps the component's public API from pinning consumers to the encoder package. The mapping to the vendor type is a private detail of the component, a one-lineswitchinComponents/QrCodeImage.razor:77-82, so replacing the encoder would not be a breaking change for any page that names this enum. - Walkthrough: four members with explicit values and a stated recovery budget each:
Low = 0(line 12, about 7% recovery, densest code, short payloads on clean screens),Medium = 1(line 15, about 15%, the usual screen and print trade-off),Quartile = 2(line 18, about 25%, printed sheets that may get scuffed) andHigh = 3(line 21, about 30%, codes overlaid with a logo or scanned in poor light). The explicit values matter because the enum is bound as a component parameter and compared for change detection. - Why it's built this way: the recovery percentages are properties of the QR standard, not of the encoder, so documenting them on a framework enum keeps the decision (how much damage must this code survive?) at the call site where the physical context is known.
- Where it's used:
QrCodeImagetakes it as a parameter defaulting toMedium(Components/QrCodeImage.razor:36) and maps it toQRCodeGenerator.ECCLevelbefore encoding (:77-82);QrCodeButtondefaults toQuartile(Components/QrCodeButton.razor:65). ADC passesMediumexplicitly on the attendee badge (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIn/MyBadge.razor:36) and the speaker QR page (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerQr.razor:29). Covered byQrCodeImageTests.
UIModuleConfiguration
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/UIModuleConfiguration.cs:10· Level 0 · class (static)
- What it is: a one-method helper that answers "is this UI module enabled in this host?" by reading
Modules:{moduleName}:Enabledfrom configuration, defaulting to enabled when nothing is configured. - Depends on:
Microsoft.Extensions.Configuration.IConfiguration(GetSection,Exists,GetValue). Nothing first-party, which is what lets a host call it before any DI registration has happened. - Concept introduced, composing a UI host from configuration.
[Rubric §7, Microservices Readiness](assesses whether the same codebase can be deployed as different subsets) and[Rubric §17, DevOps & Deployment]. The server-side module system registersIModuleimplementations in topological order; the UI side has its own analogue,IUIModule, registered by each module'sAddXUI()extension (ADR-067). This helper is the gate in front of those calls: a host that switches a module off never callsAddXUI(), so noIUIModuledescriptor is registered, and the shell composes without that module's routes, nav entries or services. The default-on behavior (lines 7-8) is a compatibility choice: a host with noModulessection behaves exactly as it did before the section existed. - Walkthrough:
ModulesSectionName = "Modules"(line 12) andIsModuleEnabled(IConfiguration configuration, string moduleName)(lines 18-22). It walks two section levels,Modulesthen the module name (line 20), and returns!section.Exists() || section.GetValue("Enabled", true)(line 21). Read carefully, that is two independent defaults: an absent module entry is enabled, and a present entry missing theEnabledkey is also enabled. Only an explicitfalseturns a module off. - Why it's built this way: a static helper over
IConfiguration(rather than a bound options class) is what makes it usable at the exact point it is needed, insideProgram.cs/MauiProgram.csbefore the service provider exists. Keeping the check in the framework rather than hand-rollingbuilder.Configuration["Modules:X:Enabled"]per host is what keeps the default-on semantics identical across all six heads. - Where it's used: all six UI hosts gate their module registrations with it. ADC:
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:82-92,MMCA.ADC.UI.Web.Client/Program.cs:61-70,MMCA.ADC.UI/MauiProgram.cs:127-136(Identity, Conference, Engagement, Notification). Store:MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:120-126,MMCA.Store.UI.Web.Client/Program.cs:51-57,MMCA.Store.UI/MauiProgram.cs:83-89(Catalog, Sales, Identity). The corresponding configuration block isMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:9-14.
UiReadCacheOptions
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/UiReadCacheOptions.cs:13· Level 0 · class (sealed)
- What it is: the client-side staleness policy for
IUiReadCache: a master on/off switch, a default freshness budget, and per-route-prefix overrides. - Depends on: nothing first-party.
TimeSpanandDictionary<string, TimeSpan>(BCL), plus the options binder at registration. - Concept introduced, client-side freshness as a recorded decision.
[Rubric §19, State Management](assesses whether cached client state has an explicit lifetime),[Rubric §12, Performance and Scalability]and[Rubric §31, Cost and FinOps]. The doc comment states the intent precisely (lines 6-11): the point of writing staleness into configuration is that it becomes a decision a host records, rather than an accident of how often a component happens to re-render. This is the client-side analogue of the server caching strategy (ADR-026) applied to the entity data-access contract (ADR-094). - Walkthrough:
SectionName = "UiReadCache"(line 16).Enabled(line 24), defaulttrue. Setting it false turns every lookup into a miss and every store into a no-op (lines 19-22), so the services behave exactly as they would with no cache registered. The cache honors it on both paths (Services/Caching/UiReadCache.cs:36,:74), which is the framework's escape hatch for a host that wants no client-side staleness at all.DefaultTtl(line 32), default 60 seconds, applied to any read whose URL matches no configured prefix. The comment records the reasoning for the number (lines 27-31): short enough that a stale list corrects itself within one user's attention span, long enough to collapse the burst of identical reads a page issues while it mounts.RoutePrefixTtls(line 41), a getter-onlyDictionary<string, TimeSpan>keyed by the leading part of a relative URL (for examplecountries). Getter-only is deliberate: the configuration binder populates the instance the defaults created, which is how bindable collections are shaped across this namespace (lines 37-39). The longest matching prefix wins, so a specific child route can state a stricter budget than the endpoint above it whatever order configuration enumerates in; that resolution is implemented inUiReadCache.ResolveTtl(Services/Caching/UiReadCache.cs:120-135).
- Why it's built this way: a single global TTL would force one budget on reference data that changes hourly and on lists that change constantly, so the per-prefix table is what makes one cache usable for both. Longest-match rather than first-match removes any dependence on configuration ordering, which JSON does not guarantee.
- Where it's used: bound in
AddUIShared(DependencyInjection.cs:48-49) and injected intoUiReadCache(Services/Caching/UiReadCache.cs:18, snapshotted to a field at:27). Covered byUiReadCacheTests.
WebApplicationExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Extensions·MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:8· Level 0 · class (static)
- What it is: a one-method middleware extension for Blazor Server / WASM hybrid hosts.
UseAuthenticatedNoStore()emitsCache-Control: no-storeon HTML responses to authenticated users, so a logged-out user pressing Back never sees the previous logged-in HTML. - Depends on:
Microsoft.AspNetCore.Builder.IApplicationBuilder,HttpContext.User, andHttpResponse.OnStarting(ASP.NET Core). Nothing first-party. - Concept introduced, the browser back-forward cache (bfcache) as an auth-leak boundary.
[Rubric §26, Front-End Security](assesses whether the front end avoids leaking authenticated content and treats the browser as hostile storage) and[Rubric §23, Front-End Performance](assesses render and navigation cost; bfcache is a performance feature this deliberately gives up, but only where it is unsafe). A browser's bfcache restores a full DOM snapshot of a previous page on Back without issuing a request, so no server authorization check runs. Emittingno-storeon a response makes that page bfcache-ineligible: Back re-requests it and the server re-renders under the current (possibly signed-out) identity. The scoping is the interesting part: anonymous pages keep their bfcache eligibility because the guard iscontext.User.Identity?.IsAuthenticated is true(line 30), and non-HTML responses (JSON, static assets, the Blazor framework files) are skipped by thetext/htmlcontent-type check (lines 31-32), so nothing but authenticated pages pays the cost. - Walkthrough: a static class holding a single C#
extension(IApplicationBuilder app)block (line 10), the sameextension(T)preview syntax the framework uses for DI registration (see primer).UseAuthenticatedNoStore()(lines 24-44) registers an inlineapp.Use((context, next) => ...)middleware (line 26).- It does not inspect the response at request time: it hooks
context.Response.OnStarting(line 28), the callback the server invokes just before the first byte of the response is written. That is what makes readingcontext.Userandcontext.Response.ContentTypemeaningful, both are populated by then even though this middleware sits ahead of the authentication middleware in the pipeline. - When both conditions hold it sets
Cache-Control: no-store, no-cache, must-revalidate, max-age=0plus the HTTP/1.0-eraPragma: no-cache(lines 34-35), then returnsTask.CompletedTask(line 37). - The middleware returns
next()immediately (line 40), and the extension returnsapp(line 43) so it chains in the usualapp.UseX().UseY()shape.
- Why it's built this way: an
IApplicationBuilderextension is the idiomatic ASP.NET Core registration shape, and theOnStartinghook is what allows a single narrow registration to make an after-the-fact decision (was this response authenticated? was it HTML?) instead of duplicating the check at every page. The remarks (lines 19-23) state the one ordering constraint: register it beforeMapRazorComponentsso it wraps every page response. - Where it's used: both Blazor Web hosts call it once:
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:134andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:171. - Caveats / not-in-source: whether a given browser honors
no-storeas bfcache-ineligibility is browser behavior, not code, and cannot be verified from this source. This type is distinct from the same-namedWebApplicationExtensionsin the API layer; they share a name across assemblies, not an implementation.
ApiSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/ApiSettings.cs:9· Level 1 · class (sealed)
- What it is: the bound implementation of
IApiSettings: the"Api"configuration section, validated at startup so a host with no API endpoint fails immediately instead of at the first request. - Depends on:
IApiSettings(the read-only contract it implements) andSystem.ComponentModel.DataAnnotations.RequiredAttribute(BCL). - Concept introduced, fail-fast configuration.
[Rubric §29, Resilience, Reliability & Business Continuity]and[Rubric §15, Best Practices and Code Quality]. The class is three lines of data, but the behavior lives in how it is registered:AddOptions<ApiSettings>().Bind(...).ValidateDataAnnotations().ValidateOnStart()(DependencyInjection.cs:37-40).ValidateDataAnnotationsturns the[Required]attribute into an options validator, andValidateOnStartruns that validator during host startup rather than lazily on first resolution, so a missingApi:ApiEndpointsurfaces as anOptionsValidationExceptionnaming the key before the host accepts traffic (ADR-070). That is what licenses the null-forgivingapiSettings.ApiEndpoint!in the client factory (DependencyInjection.cs:95): the validator, not a local check, is the guarantee. - Walkthrough:
SectionName = "Api"(line 12), the same convention every settings class here uses.[Required] public string? ApiEndpoint { get; init; }(lines 15-16). Nullable so the binder can leave it unset,[Required]so leaving it unset fails validation.init-only, so a bound instance is immutable after construction.WasmApiEndpoint { get; init; }(line 19) carries<inheritdoc />and no[Required]: it is optional at the contract level, and each host decides whether an absent value is acceptable.
- Why it's built this way:
sealedplusinitgives an immutable snapshot of configuration that cannot drift while the app runs. Putting the validation attribute on the options class rather than writing a guard in theHttpClientfactory keeps one failure mode with one message: the comment atDependencyInjection.cs:87-91records that a second hand-written check would only give the same failure a different, less informative exception. - Where it's used: bound in
AddUIShared(DependencyInjection.cs:37-40); read by the named"APIClient"HttpClientfactory to setBaseAddress(DependencyInjection.cs:92-95, alongside the 90-second total-request timeout at:97); read byApiFileDownloadButtonfor its browser download URL (Components/ApiFileDownloadButton.razor.cs:93); and served to the WebAssembly client by each Server head's/client-configendpoint (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:148-161andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:185-191).
PseudoStringLocalizer
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizer.cs:13· Level 1 · class (sealed)
- What it is: an
IStringLocalizerdecorator that pseudo-localizes every resolved string, but only when the current UI culture is the pseudo locale; under every other culture it delegates unchanged to the wrapped localizer, so it is inert in production (ADR-027 §8). - Depends on:
PseudoLocalizer(the transform, Level 0),SupportedCultures(itsIsPseudoLocalefromMMCA.Common.Shared.Globalization), andIStringLocalizer/LocalizedString/CultureInfo(BCL and NuGet). Constructed with aninnerIStringLocalizervia a primary constructor (line 13). - Concept introduced, the decorator that gates on culture.
[Rubric §2, Design Patterns](assesses idiomatic use of patterns; this is a textbook Decorator, same interface in and out, wrapping behavior around a delegate) and[Rubric §27, Internationalization]. The key design move is that pseudo-localization is a cross-cutting transform applied to the localizer, not to any call site: because it implementsIStringLocalizerand forwards toinner, it can be slid underneath everyIStringLocalizer<T>in the app at once by decorating the factory (PseudoStringLocalizerFactory), with zero changes to consumers. - Walkthrough:
IsPseudoActive(lines 16-17), a private static bool that returnsSupportedCultures.IsPseudoLocale(CultureInfo.CurrentUICulture.Name), the single gate every member checks.this[string name](lines 20-29): resolvesinner[name](line 24), then, if pseudo is active, returns a newLocalizedStringwhose value isPseudoLocalizer.Transform(localized.Value)while preservingResourceNotFound/SearchedLocation(line 26); otherwise returns the inner value untouched (line 27).this[string name, params object[] arguments](lines 32-48): when pseudo is inactive, delegates straight toinner[name, arguments](lines 36-39); when active it does the two-step that makes placeholders survive, transform the raw template first (lines 43-44), thenstring.Formatthe accented template with the arguments (line 45), so the substituted values are never accented or padded.GetAllStrings(bool includeParentCultures)(lines 51-57): maps the transform over every string when active (line 55), passes them through otherwise (line 56).
- Why it's built this way: gating inside the decorator (rather than conditionally registering it) keeps DI wiring unconditional and simple, the decorator is always present and simply does nothing outside the pseudo locale, which per the doc comment (lines 10-11) is never an activatable request culture in production. Splitting the pure transform (
PseudoLocalizer) from the culture-aware decorator keeps each single-responsibility and independently testable ([Rubric §1, SOLID]). - Where it's used: produced by
PseudoStringLocalizerFactoryaround every localizer the inner factory creates, so it transparently wrapsIStringLocalizer<SharedResource>,IStringLocalizer<MudTranslations>, and every other localizer in the host.
ResxMudLocalizer
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/ResxMudLocalizer.cs:17· Level 1 · class (sealed, internal)
- What it is: MudBlazor's
MudLocalizerimplementation that resolves the library's built-in component text from theMudTranslationsresource pair, so MudBlazor chrome (pager, filter menus, pickers, close buttons) follows the active UI culture (ADR-027). - Depends on:
MudBlazor.MudLocalizer(the abstract base, NuGet),IStringLocalizer<MudTranslations>(injected via primary constructor, line 17), andMudTranslations(Level 0). Nothing else first-party. - Concept introduced, adapting a third-party localization hook.
[Rubric §2, Design Patterns](this is an Adapter, bridging MudBlazor'sMudLocalizercontract to the ASP.NET CoreIStringLocalizerworld) and[Rubric §27, Internationalization]. MudBlazor exposes exactly one extension point for translating its built-in strings: subclassMudLocalizerand override its indexer. This adapter routes that indexer straight toIStringLocalizer<MudTranslations>. MudBlazor's ownDefaultLocalizationInterceptorconsults this localizer only for non-English cultures and falls back to its built-in English whenever the returnedLocalizedString.ResourceNotFoundis true (per the doc comment,ResxMudLocalizer.cs:9-12), so any untranslated key degrades gracefully. - Walkthrough: a one-member class.
internal sealed class ResxMudLocalizer(IStringLocalizer<MudTranslations> localizer) : MudLocalizer(line 17) with a singlepublic override LocalizedString this[string key] => localizer[key];(line 19). The doc comment (lines 13-15) also notes that because resolution flows through the DIIStringLocalizerFactory, thePseudoStringLocalizerFactorydecorator applies here too, so under the development-onlyqps-Plocculture MudBlazor's chrome pseudo-localizes alongside the application text. - Why it's built this way:
internalbecause it is pure host wiring no consumer needs to name; delegating to the injectedIStringLocalizer<MudTranslations>reuses the exact same.resx/factory pipeline as app strings (one localization mechanism, not two), which is what lets pseudo-loc reach MudBlazor for free. - Where it's used: registered as MudBlazor's
MudLocalizerinAddUISharedviaservices.TryAddTransient<MudBlazor.MudLocalizer, ResxMudLocalizer>()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:77).TryAddis authoritative becauseAddMudServicesdoes not register aMudLocalizerof its own (guarded by a DI-resolution test, per the comment atDependencyInjection.cs:73-76), regardless of host registration order. Covered byResxMudLocalizerTests.
PseudoStringLocalizerFactory
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizerFactory.cs:11· Level 2 · class (sealed)
- What it is: an
IStringLocalizerFactorydecorator that wraps every localizer the inner factory produces in aPseudoStringLocalizer, so decorating this one factory pseudo-localizes everyIStringLocalizer<T>andIStringLocalizerin the host at once (ADR-027 §8). - Depends on:
PseudoStringLocalizer(Level 1) andIStringLocalizerFactory/IStringLocalizer(Microsoft.Extensions.Localization, NuGet). Constructed with theinnerfactory via a primary constructor (line 11). - Concept introduced, decorate the factory to reach every product.
[Rubric §2, Design Patterns](Decorator applied at the factory level) and[Rubric §6, CQRS & Event-Driven Design](assesses whether cross-cutting behavior is injected in one place rather than scattered). BecauseStringLocalizer<T>resolves its backing localizer through theIStringLocalizerFactory, wrapping the factory means every localizer the DI container ever hands out is already pseudo-aware: no per-type registration, no consumer change. This is the same "decorate the boundary, not the callers" idea the CQRS pipeline uses (see primer §2), applied to localization. - Walkthrough: two forwarding overrides, each wrapping the inner factory's product:
Create(Type resourceSource)(lines 14-15):new PseudoStringLocalizer(inner.Create(resourceSource)), the path used byIStringLocalizer<T>.Create(string baseName, string location)(lines 18-19):new PseudoStringLocalizer(inner.Create(baseName, location)), the path used by name-based localizers.
- Why it's built this way: registering the wrapper on the factory is the minimal, DI-idiomatic way to make pseudo-loc universal; combined with the culture gate inside
PseudoStringLocalizer, it can be registered unconditionally because it is inert under every non-pseudo culture, so production wiring is not conditional on environment (the registration comment,DependencyInjection.cs:66-70, says exactly that: the pseudo locale is only ever activatable in Development). - Where it's used: registered via
services.Decorate<IStringLocalizerFactory, PseudoStringLocalizerFactory>()(Scrutor) inAddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:71), afterservices.AddLocalization()(line 60). Its reach includes MudBlazor chrome throughResxMudLocalizer, which resolves itsIStringLocalizer<MudTranslations>through this same factory.
MobileInfiniteScrollList<TItem>
MMCA.Common.UI ·
MMCA.Common.UI.Components.Lists·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Lists/MobileInfiniteScrollList.razor.cs:21· Level 3 · class (generic partial component)
- What it is: the mobile card list every list page falls back to on a narrow viewport. It owns the whole loop: an IntersectionObserver sentinel that asks for the next page, an accumulated item list rendered through a caller-supplied card template, a rendered-item cap that bounds DOM growth, generation-guarded supersession of in-flight fetches, and localized load-failure handling with a Retry button.
- Depends on:
Resultand its generic form (the fetch delegate's return type),IToastService,SharedResourcethroughIStringLocalizer<SharedResource>,ResultUiExtensions.LocalizedErrorMessage, theEmptyStatecomponent, and the shared_content/MMCA.Common.UI/infinite-scroll.jsmodule it shares withInfiniteScrollSentinel. Externals:IJSRuntime,DotNetObjectReference,CancellationTokenSource, MudBlazor primitives. - Concept introduced, generation-guarded supersession.
[Rubric §19, State Management](assesses whether concurrent updates to client state have a defined winner) and[Rubric §23, Front-End Performance]. The hard problem in an infinite list is not fetching, it is what happens when the user changes the filter while a fetch is in flight. Cancellation alone is not enough: the fetch delegate is consumer-supplied and may ignore itsCancellationTokenentirely, so a superseded call can still complete successfully and try to append rows to a list that was cleared. The answer here is a monotonically increasing_generationcounter (line 63). A load snapshots it before awaiting and discards its results if the value moved while it waited (lines 174-176, 192). The token cancellation is still issued (it stops work that does honor it), but the generation, not the token, is authoritative (lines 189-191). The second half of the pattern is that the page counter is computed, not committed:targetPage = _currentPage + 1(line 183) and_currentPageonly advances on a successful, non-superseded completion (line 207), so a cancelled, failed or superseded fetch leaves nothing to compensate back and no page is ever re-requested (lines 180-182). - Walkthrough:
- Injected services (lines 22-24) and parameters (lines 26-53).
CardTemplateis an[EditorRequired]RenderFragment<TItem>(line 28).FetchPageResult(line 38) is the fetch delegate in the shape every Result-returning UI service already has,(page, pageSize, cancellationToken)returningResult<(IReadOnlyList<TItem> Items, int TotalItems)>, which isIEntityService<TEntityDTO, TIdentifierType>.GetPagedAsyncminus the filter and sort arguments.PageSizedefaults to 10 (line 40),MaxRenderedItemsto 500 (line 53). - State (lines 55-83):
_items,_totalCount,_currentPage,_generation, the four render flags (_isInitialLoad,_isLoadingMore,_hasMore,_loadError),_loadErrorMessage, and the interop handles (_sentinelRef,_jsModule,_dotNetRef,_observerId,_cts,_observerAttached,_disposed). OnInitializedAsync(lines 85-91) validates the delegate then loads page 1.ValidateFetchParameter(lines 98-105) throws anInvalidOperationExceptionwhenFetchPageResultis null, deliberately before the load, so a misconfigured call site fails loudly instead of rendering as a load failure with a Retry button that can never succeed (lines 93-97).OnAfterRenderAsync(lines 107-113) attaches the observer only once there are items to scroll past and the initial load is done (line 109).AttachObserverAsync(lines 115-129) andDetachObserverAsync(lines 131-146) import and call the sameobserve/unobservemodule functions the sentinel component uses, toleratingJSDisconnectedExceptionon both paths.OnSentinelVisible()(lines 148-161), the[JSInvokable]entry point, early-returns when already loading, exhausted or disposed (lines 151-154), then loads on the renderer's context and re-renders.LoadNextPageAsync(bool isInitial)(lines 163-245) is the core. It guards re-entry (lines 165-168), clears the error state (lines 171-172), snapshots the generation and publishes a freshCancellationTokenSource(lines 176-178), computestargetPage(line 183), and awaits the delegate (line 187). After the await it checks disposal and generation (line 192), unwraps theResultwithTryGetValueand routes a failure toSetLoadFailed(lines 197-203), and only then commits: advance the page, append the items, record the total (lines 207-209), and recompute_hasMoreas_items.Count < _totalCount && _items.Count < MaxRenderedItems(line 213), which is where the DOM cap stops the loop.OperationCanceledExceptionis swallowed as a normal supersession (lines 215-218); any other exception raises the generic failure, again only for the current generation (lines 219-227). Thefinally(lines 228-244) is careful about ownership: only the current generation may clear_isLoadingMore(a superseding reset already cleared it and may have set it again), and only the still-currentCancellationTokenSourceis disposed here (ReferenceEquals, line 237), because a resetter that took one over already cancelled and disposed it.SetLoadFailed(lines 258-267) sets the inline error state and, on the initial load only, also raises a toast, because an initial failure renders as an empty state and the toast is otherwise the only signal the user gets (lines 247-251). The message comes fromfailure?.LocalizedErrorMessage(L)(line 261); a raw exception passesnull, because exception text is neither translatable nor safe to surface (lines 253-257), and the generic resource string is used instead.ResetAsync()(lines 279-315) is the public API a page calls when filters change. Order matters and is commented: bump the generation first so any in-flight fetch is already superseded (line 283), then cancel and dispose the stale token source (lines 285-292), then clear_isLoadingMoreexplicitly (lines 294-297, because the superseded load will not clear it), then reset the list and every flag (lines 299-305), detach the observer (lines 307-308), and reload from page 1 (lines 312-314).DisposeAsync(lines 317-349) guards re-entry, cancels and disposes the token source, detaches the observer, disposes the JS module toleratingJSDisconnectedException, and disposes theDotNetObjectReference.- The markup (
Components/MobileInfiniteScrollList.razor:1-43) renders one of three shapes: an indeterminate progress bar on the initial load (lines 3-6),EmptyStatewhen the list came back empty (lines 7-10), or the keyedMudCardstack with the caller's template (lines 13-22). Below the cards it renders the sentineldivonly while_hasMore(lines 24-34) and the inline error plus Retry button when a later page failed (lines 36-42).
- Injected services (lines 22-24) and parameters (lines 26-53).
- Why it's built this way: the component encapsulates the part of infinite scroll that is genuinely hard to get right (supersession, cancellation ownership, disposal, the DOM cap) and leaves the part that is app-specific (what a card looks like, where the data comes from) to parameters. The
Result-returning delegate rather than a rawTask<List<T>>is what makes a localized failure message reachable without the component knowing any error catalogue. - Where it's used: the mobile branch of nearly every list page. ADC:
SessionList.razor:56,SpeakerList.razor:41,SponsorList.razor:41,RoomList.razor:41,EventList.razor:31,ActivityList.razor:41,QuestionList.razor:27,ConferenceCategoryList.razor:27, the public viewsPublicSessionListView.razor:6andPublicEventList.razor:23, the check-inAttendeeSearchPanel.razor:27, andMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/User/UserList.razor:27. Store:MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Order/OrderList.razor:26andPages/ShoppingCart/ShoppingCartList.razor:19. It is also exercised in the component gallery (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Gallery/Pages/ComponentsGallery.razor:57) and covered byMobileInfiniteScrollListTests. - Caveats / not-in-source:
MaxRenderedItemsbounds the DOM but there is no virtualization, so 500 rendered cards remain in the DOM; whether that is acceptable on a given device is not determinable from source. A consumer fetch delegate that ignores itsCancellationTokenstill runs to completion after a reset: the generation guard discards its results, but the request itself is not stopped.
MoneyExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Extensions·MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/MoneyExtensions.cs:14· Level 5 · class (static)
- What it is: the presentation-layer formatter for money, turning a
Moneyvalue object into$12.50 USDand a collection of them into a per-currency range such as$10.00 - $25.00 USD. - Depends on:
Moneyand itsCurrency(bothMMCA.Common.Shared.ValueObjects), plusCultureInfo.InvariantCultureandStringComparer.Ordinal(BCL). - Concept introduced, formatting lives in the UI layer, not the value object.
[Rubric §3, Clean Architecture](assesses whether presentation concerns stay out of the inner layers:Moneyknows amounts and currencies, it does not know what a price looks like) and[Rubric §20, Design System and Theming](assesses consistent presentation of a recurring data shape; one formatter means every price on every page reads the same). It also shows the C#extension(T)preview syntax used for something other than DI: two blocks, one onMoneyand one onIReadOnlyCollection<Money>, sit in a single static class so both spellings (price.ToDisplayString()andprices.ToDisplayRange()) are available from oneusing. - Walkthrough:
- The class carries a file-level
[SuppressMessage("Naming", "CA1708")](lines 10-13): with two or moreextension(T)blocks in one static class, CA1708 flags the compiler-generated grouping members as case-colliding. The justification records that no user-visible identifier differs only by case, a known analyzer trap of the preview syntax. extension(Money price)(lines 16-21) exposesToDisplayString()(lines 19-20), which delegates toFormatGroup(price.Amount, price.Amount, price.Currency.Code): passing the same value as both bounds is what makes the shared helper render a single price rather than a degenerate range.extension(IReadOnlyCollection<Money> prices)(lines 23-47) exposesToDisplayRange()(lines 32-46): returnsstring.Emptyfor an empty collection (lines 34-37), then groups byCurrency.CodewithStringComparer.Ordinal(line 42) and formats each group from its own min and max (line 43), joining the groups with", "(line 45). Grouping is the load-bearing detail: a mixed-currency collection renders one range per currency, each with its own symbol, instead of collapsing unrelated amounts under whichever currency appeared first. The inline comment (lines 39-40) notesGroupBypreserves first-appearance order, so the single-currency case (every collection in practice today) is unchanged.Symbol(string code)(lines 54-59), a private switch mapping"USD"to$and"EUR"to the escaped euro sign (line 57, escaped to keep the source file ASCII-only). Every other code, including the empty code of theCurrency.Nonesentinel behindMoney.Zero()(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Financial/Currency.cs:23,Money.cs:142), renders with no symbol rather than falsely claiming dollars.FormatGroup(decimal min, decimal max, string code)(lines 65-73), the single formatting path:"N2"withCultureInfo.InvariantCulture(lines 69-70) so two decimals and a thousands separator render identically regardless of server locale, a single price whenmin == maxand a hyphen-separated range otherwise (line 68), and the trailing code appended only when it is non-empty (line 72).
- The class carries a file-level
- Why it's built this way: presentational formatting belongs above the domain, so
Moneystays display-agnostic and the same value can be rendered differently by a different head.InvariantCultureis a deliberate choice overCurrentCulture: prices are shown with an explicit ISO code (USD), so a locale-dependent decimal separator would produce$12,50 USDand read as an error. The empty-symbol fallback and the per-currency grouping are both "render the truth" decisions: never imply a currency the data does not carry. - Where it's used: Store's Sales and Catalog UIs.
ToDisplayString()renders order totals and line amounts (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Order/OrderLinesPanel.razor:34,:39,:51;Pages/Order/OrderSummaryPanel.razor:54;Pages/Order/OrderList.razor:36,:102) and the cart's order-created snackbar (Pages/ShoppingCart/ShoppingCartDetail.razor.cs:354);ToDisplayRange()renders the price span across a product's variants in catalog browse (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Catalog/CatalogBrowse.CardFormatting.razor.cs:38, with the single-price helper alongside it at:41) and on the catalog product detail page (Pages/Catalog/CatalogProductDetail.razor.cs:266,:269). Covered byMoneyExtensionsTests. - Caveats / not-in-source: only
USDandEURhave symbols; adding a currency means editingSymbol, there is no configuration-driven table. The"N2"format assumes a two-minor-unit currency, so a zero-decimal currency (JPY) would render two spurious decimals; no code guards that today.
CachedPage
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/OfflineFirstPageSnapshot.cs:27· Level 0 · record (sealed, private, nested)
- What it is: the on-disk shape of an offline list snapshot, a two-field record
(List<TItem> Items, int TotalItems)nested privately insideOfflineFirstPageSnapshot<TItem>. It is what actually gets serialized when a list page remembers its first page for a dead network. - Depends on: nothing first-party. It is round-tripped through
ILocalCacheStore, whoseSetAsync/GetAsync<T>do the JSON work (OfflineFirstPageSnapshot.cs:44-45,:63). - Concept introduced, the private nested cache payload.
[Rubric §19, State Management & Data Flow]assesses whether client-held state has an explicit, owned shape rather than being smeared across ad-hoc dictionaries;[Rubric §29, Resilience & Business Continuity]assesses whether a surface degrades instead of failing when a dependency is gone. Declaring the payload as aprivate sealed recordinside the only type that reads and writes it makes the snapshot format an implementation detail: no consumer can take a dependency on the field names, so the shape can change without a public-API break. The trade-off is the flip side of that: because the format is private and unversioned, a shape change silently orphans whatever is already in the device store. - Walkthrough: one line.
private sealed record CachedPage(List<TItem> Items, int TotalItems);(line 26). Written byRememberAsync, which materializes the fetched rows into a fresh list with a collection expression,new CachedPage([.. fetched.Items], fetched.TotalItems)(line 44), so the cached copy is decoupled from the caller's live list. Read back byTryReadAsyncasstore.GetAsync<CachedPage>(cacheKey, cancellationToken)(line 63) and immediately destructured into the tuple the grid expects,(cached.Items, cached.TotalItems)(line 64). - Why it's built this way: a
recordgives value semantics and a positional constructor for free, which is all a serialization payload needs;List<TItem>rather thanIReadOnlyList<TItem>is the concrete collection the round-trip materializes into. Nesting it privately keeps the type out of the package's public surface entirely. - Where it's used: only inside
OfflineFirstPageSnapshot<TItem>. Its round-trip is covered end to end byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/OfflineFirstPageSnapshotTests.cs:15. - Caveats / not-in-source: the doc comment states that
TItem"must be JSON round-trippable" (OfflineFirstPageSnapshot.cs:14), but nothing in this file enforces that; a DTO the store's serializer cannot handle fails at runtime, not at compile time.
ErrorMessages
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ErrorMessages.cs:24· Level 0 · class (static)
- What it is: a small factory of user-facing failure strings (load, save, delete, delete-failed, not-found, validation) so every page code-behind reports an outcome with identical, culture-correct phrasing, resolved through a shared localizer once one is configured (ADR-027).
- Depends on:
IStringLocalizer/LocalizedString(Microsoft.Extensions.Localization, NuGet) andstring.FormatwithCultureInfo.CurrentCulture(BCL). No first-party types at all. The localizer it is handed is anIStringLocalizer<SharedResource>(doc comment,ErrorMessages.cs:32), so it shares theSharedResource.resxkeys. - Concept introduced, the static helper back-filled with an injected localizer, and the "never show raw exception text" rule.
[Rubric §27, Internationalization]assesses whether user-facing copy resolves per UI culture from resources instead of being hard-coded English;[Rubric §15, Best Practices & Code Quality]assesses whether a wording change lands in one place;[Rubric §24, Forms, Validation & UX Safety]assesses that internal error text never leaks to the user. The mechanism is the interesting part: the API isstatic, so any page can callErrorMessages.LoadError(Title, ex)without taking a DI dependency, yet the output is culture-aware because the root layout hands the class one shared localizer at startup. Every method routes through a privateLocalize(key, fallbackFormat, args)that returns the resource value when the localizer is set and the key resolves, and the inline English format string otherwise. The scope note in the class comment (lines 14-22) is what pins the responsibility boundary: a server answer reaches a page as aResultand is rendered byResultUiExtensions(NotifyOnFailure,OnFailureSetError), so these helpers only cover the exceptions a page can still see, which are its own faults (a JS-interop failure, a mapping bug, a callback the page supplied). Such an exception'sMessageis never rendered: raw exception text is neither localizable nor safe to surface (ADR-027 Decision 9). - Walkthrough: one mutable static field plus pure builders.
_localizer(line 26), a nullableIStringLocalizer?, null until configured.Configure(IStringLocalizer localizer)(line 33), the single wiring point: an expression-bodied assignment, idempotent, called once from the root layout.Localize(key, fallbackFormat, args)(lines 35-47), the resolution core: when_localizeris set and the lookup'sResourceNotFoundis false it returnslocalized.Value(lines 37-44); otherwisestring.Format(CultureInfo.CurrentCulture, fallbackFormat, args)(line 46).LoadError/SaveError/DeleteError(lines 56-57, 60-61, 64-65), the three CRUD failure paths, keyedCommon.Error.Load/Save/Delete. Each passes the entity name andex.Messageas format arguments, and the shipped templates deliberately ignore the second one (doc comment, lines 49-55), so the exception text is available to a resource that wants it while the shipped copy never prints it. The two siblings carry<inheritdoc cref="LoadError"/>(lines 59, 63) rather than repeating the rationale.DeleteFailed(string entityName)(lines 67-68, keyCommon.Error.DeleteFailed), the "the call returned but the delete did not happen" case, distinct fromDeleteError, which carries an exception.NotFound(string entityName, object id)(lines 70-71, keyCommon.Error.NotFound), interpolating the entity name and the missing id.ValidationError(lines 73-74, keyCommon.Error.Validation), a parameterless property and the only fixed sentence.
- Why it's built this way: keeping the API static means call sites never move, while the
Configureindirection adds localization without a signature change anywhere. The uniform "template only" answer is what makes the class safe to call from anycatch: there is no branch on exception type, so no curated-message path can accidentally become a leak path. The mutable static is a deliberate, single exception to the framework's no-static-state rule and is named explicitly in the architecture fitness allowlist (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Ui/StateManagementConventionTests.cs:22, with the reasoning at lines 16-21: write-once wiring, not per-user state). - Where it's used: configured once per host by
ErrorMessages.Configure(L)in the root layout (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:103). Called byDataGridListPageBase<TDto>on the two non-Resultfailure paths (DataGridListPageBase.cs:570paged,:665virtualized,:767mobile), byNotificationSend(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:103), and by the Store entity pages forNotFoundandValidationError(for exampleMMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Product/ProductDetail.razor.cs:99and:200). - Caveats / not-in-source: the
.resxpayloads (SharedResource.resx,SharedResource.es.resx) are resources, not.cs, so per-key contents are not enumerable here; a shipped template that did consume{1}would print the exception text, and only the unit tests pin that it does not (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/ErrorMessagesTests.cs:11, including the explicit case that even aDomainInvariantViolationExceptiongets the plain template,:45).
ForgotPasswordModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ForgotPasswordModel.cs:9· Level 0 · class (sealed)
- What it is: the
EditFormbacking model for the Forgot Password page: oneEmailstring carrying DataAnnotations for shape validation. Nothing else is collected, because nothing else is needed to start a reset. - Depends on:
System.ComponentModel.DataAnnotations(BCL):[Required],[EmailAddress]. Nothing first-party. - Concept introduced, validation deliberately capped at "shape" because of an anti-enumeration contract.
[Rubric §24, Forms, Validation & UX Safety]assesses whether a form gives a clear per-field verdict before submit;[Rubric §26, Front-End Security]assesses whether the front end avoids leaking information the back end withholds. Every other form in this group validates as much as it can client-side. This one stops at "is this a syntactically valid address", because the interesting question, does an account exist for it, is one the server refuses to answer: ADR-091 Decision 3 has the reset request succeed on every path (malformed address, no account, throttled, failed send), so a distinguishable client-side outcome would reintroduce exactly the account-enumeration oracle the endpoint exists to avoid. The doc comment (ForgotPasswordModel.cs:5-8) states that trade-off directly. - Walkthrough: one
get; set;property.Email(line 13) carries[Required(ErrorMessage = "Email is required")]and[EmailAddress(ErrorMessage = "Enter a valid email address")](lines 11-12) and defaults tostring.Empty. - Why it's built this way:
sealedand mutable (set, notinit) becauseEditFormtwo-way-binds the input to the model; keeping the model to one field is what makes the page's anti-enumeration behavior easy to reason about, since there is no second field whose validation could betray a lookup. - Where it's used: instantiated as
_modelbyForgotPassword.razor(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ForgotPassword.razor:66) and bound by its<EditForm Model="_model" OnValidSubmit="HandleRequestAsync">plus<DataAnnotationsValidator />(lines 34-35), with the field wiredFor="@(() => _model.Email)"(line 37) so the message attaches to that input. On valid submitHandleRequestAsync(lines 75-92) callsIAuthUIService.RequestPasswordResetAsync(_model.Email)(line 81, contract atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:57) inside atrywhosecatchis empty on purpose (lines 83-86) and whosefinallysets_isSubmitted = trueunconditionally (line 90), so the confirmation renders for every submitted address whether the call succeeded, failed, or threw. - Caveats / not-in-source:
RequestPasswordResetAsyncreturns aResultand the call site never inspects it (line 81); the comment block above the method (lines 70-74) records that this is the anti-enumeration rule rather than a dropped result. The gallery E2E suite pins the behavior by asserting the confirmation appears with no backend at all (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Auth/ForgotPasswordPageE2ETests.cs:28), with WCAG 2.1 AA scans on both the form and the confirmation state (:41,:50).
LoginModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/LoginModel.cs:9· Level 0 · class (sealed)
- What it is: the
EditFormbacking model for the Login page: two string properties (Email,Password) carrying DataAnnotations for field-level validation. - Depends on:
System.ComponentModel.DataAnnotations(BCL):[Required],[EmailAddress]. Nothing first-party. - Concept introduced, the form-backing model plus
DataAnnotationsValidator.[Rubric §24, Forms, Validation & UX Safety]assesses whether forms validate at the field level with clear inline messages before submit;[Rubric §26, Front-End Security]assesses that client-side checks are a UX convenience, not the trust boundary. A BlazorEditFormbinds to a plain model, a<DataAnnotationsValidator />reads the attributes and surfaces a per-field message as the user types, and the submit handler only fires on a valid form. The doc comment (LoginModel.cs:5-8) is explicit that the server remains the authority on whether the credentials are actually valid: the form only prevents an obviously malformed request. - Walkthrough: two
get; set;properties.Email(line 13),[Required(ErrorMessage = "Email is required")]plus[EmailAddress(ErrorMessage = "Enter a valid email address")](lines 11-12), defaulting tostring.Empty.Password(line 16),[Required(ErrorMessage = "Password is required")](line 15). There is deliberately no complexity rule here: login validates an existing credential, not a new one, and rejecting a legacy password client-side would lock a user out of their own account.
- Why it's built this way:
sealedand mutable becauseEditFormtwo-way-binds each input; the messages are authored inline so each field shows exactly one verdict. - Where it's used: instantiated as
_modeland bound byLogin.razor(<EditForm Model="_model" OnValidSubmit="HandleLoginAsync">plus<DataAnnotationsValidator />,MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:33-34, field at line 159, inputs boundFor="@(() => _model.Email)"andFor="@(() => _model.Password)"at lines 40 and 46 so eachMudTextFieldshows its own message). On valid submit the page hands the credentials toIAuthUIServiceas aLoginRequest(Login.razor:204). Sibling ofRegisterModel; its shape rules are unit-tested alongside the other auth models inMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/AuthModelValidationTests.cs:11.
MudTranslations
MMCA.Common.UI ·
MMCA.Common.UI.Resources·MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/MudTranslations.cs:10· Level 0 · class (sealed)
- What it is: an empty marker class that anchors a
.resxresource pair for MudBlazor's own built-in component text: the data-grid pager and filter menus, pickers, table editing, pagination, snackbar and alert close buttons, and input adornments (ADR-027). - Depends on: nothing first-party. The type has no members: it is the single declaration
public sealed class MudTranslations;(line 10). Its meaning comes from its co-located resources, whose keys mirror MudBlazor's ownLanguageResourcekeys (v9.6.0) with the English values copied verbatim so en-US behavior is unchanged, and fromResxMudLocalizer, which injectsIStringLocalizer<MudTranslations>and hands those strings to MudBlazor's localization interceptor. - Concept reinforced, the resource-anchor type. The idiom is introduced in full at
SharedResource: ASP.NET Core'sIStringLocalizer<T>resolves keys against the.resxwhose base name matchesT, so a dedicated empty class becomes the name of a shared string table.MudTranslationsis the second anchor, scoped to third-party chrome rather than app chrome.[Rubric §27, Internationalization]assesses whether all user-visible copy follows the active culture, including the component library's;[Rubric §20, Design System & Theming]assesses a coherent design system, and a pager that still reads "Rows per page" under anesUI would break that coherence at exactly the surface the user interacts with most. - Walkthrough: there are no members. The whole contract is "be a public sealed type named
MudTranslationsin this namespace, with sibling.resxfiles whose keys match MudBlazor'sLanguageResource". The doc comment (lines 3-9) records the verbatim-English-mirror invariant. - Why it's built this way: MudBlazor exposes exactly one extension point for translating its built-in strings (an injectable
MudLocalizer), and it needs some resource base to read from. A separate anchor keeps the library's keys in their own table, mirroring the upstream names one to one, cleanly apart from the app's ownSharedResourcechrome. This is the ADR-027 way to translate a dependency you do not own. - Where it's used: injected as
IStringLocalizer<MudTranslations>byResxMudLocalizer, whichAddUISharedregisters as MudBlazor'sMudLocalizerviaTryAddTransient(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:77). Because resolution flows through the DIIStringLocalizerFactory, thePseudoStringLocalizerFactorydecorator registered atDependencyInjection.cs:71reaches these strings too. - Caveats / not-in-source: the
.resxfiles and their per-key match to MudBlazor v9.6.0'sLanguageResourceare resources, not.cs; individual key contents are not enumerated here.
PasswordComplexityAttribute
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:12· Level 0 · class (sealed attribute)
- What it is: a custom
ValidationAttributethat enforces the framework's password-strength rule on any form that sets a new password: at least 8 characters including an uppercase, a lowercase, a digit, and a special (non-alphanumeric) character. - Depends on:
System.ComponentModel.DataAnnotations(ValidationAttribute,ValidationResult,ValidationContext) andchar.IsUpper/IsLower/IsDigit/IsLetterOrDigit(BCL). Nothing first-party. - Concept introduced, extending DataAnnotations with a domain rule.
[Rubric §24, Forms, Validation & UX Safety]assesses client-side validation parity with the server. Beyond the built-in[Required]and[EmailAddress], a bespoke rule subclassesValidationAttributeand overridesIsValid, which plugs it straight into the sameDataAnnotationsValidatorthat drives the rest of the form. The doc comment (lines 5-9) states the intent: mirror the server's rule so theEditFormgives the same verdict the API would. What happens to an accepted password server-side (PBKDF2-HMAC-SHA512 hashing with legacy-hash compatibility) is ADR-032; this attribute is only the client-side gate, never the security boundary. - Walkthrough:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)](line 11), so it is applied as[PasswordComplexity]on a single property.- The constructor (lines 14-17) seeds the base
ErrorMessagewith the full human-readable rule, so a form that does not override the message still shows something actionable. IsValid(object?, ValidationContext)(lines 19-39): returnsValidationResult.Successfor a non-string or a null/empty input (lines 21-24), deliberately deferring the "missing" message toRequiredAttributeso the field shows one message rather than two. Otherwise it evaluates five predicates in one boolean (Length >= 8,Any(char.IsUpper),Any(char.IsLower),Any(char.IsDigit),Any(c => !char.IsLetterOrDigit(c)), lines 26-30) and, on failure, returns aValidationResultscoped to the member name (lines 37-38) so the message attaches to the right input.
- Why it's built this way: because the rule is an attribute rather than page code, a second form that sets a password gets identical behavior by adding one line, which is exactly how the reset vertical picked it up. Delegating emptiness to
[Required]is what keeps one field from stacking two errors. - Where it's used: applied to
RegisterModel.Password(RegisterModel,RegisterModel.cs:22) and toResetPasswordModel.NewPassword(ResetPasswordModel,ResetPasswordModel.cs:20); evaluated by the<DataAnnotationsValidator />inRegister.razor(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:28) andResetPassword.razor(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPassword.razor:36). - Caveats / not-in-source: the doc comment (line 6) still describes the attribute as the rule "for the Register form" although the reset form carries it too; the code is the wider truth. The comment also claims parity with the server's rule, but this file encodes only the client check, so whether the server rule is byte-identical is not verifiable from this source.
PersistedGridState
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:1034· Level 0 · record (sealed, private, nested)
- What it is: a tiny serializable record
(List<TDto> Items, int TotalItems)that carries the grid's already-fetched rows from the SSR pre-render pass into the interactive circuit, so the first interactiveServerDatacall can answer instantly instead of re-hitting the API. - Depends on:
Microsoft.AspNetCore.Components.PersistentComponentState(the Blazor mechanism that serializes it). Nested privately insideDataGridListPageBase<TDto>. - Concept introduced,
PersistentComponentStateto skip the double fetch.[Rubric §19, State Management & Data Flow]and[Rubric §23, Front-End Performance & Rendering]assess whether redundant work is avoided across render-mode transitions. Under InteractiveAuto a page renders more than once (static SSR, then interactive Server, then WebAssembly), and naively each transition re-runs the data fetch, which the user sees as a fetch-cancel-refetch flicker. Blazor'sPersistentComponentStateserializes chosen data into the pre-rendered HTML and rehydrates it in the interactive circuit;PersistedGridStateis the payload for the grid's data slice, so that cycle disappears. - Walkthrough: declared as
private sealed record PersistedGridState(List<TDto> Items, int TotalItems)(line 1034) at the very bottom of the file, under a doc comment (lines 1030-1033). On the persisting side, the callback registered inOnInitializedwritesnew PersistedGridState([.. _lastSuccessfulGridData.Items], _lastSuccessfulGridData.TotalItems)(line 189) under the keygrid:{GetType().FullName}(built at line 171), and only when a successful fetch has actually happened (line 187). On the restoring side, the synchronousOnInitializedcallsApplicationState.TryTakeFromJson<PersistedGridState>(persistKey, out var restored)(line 172) and, when present, rebuilds aGridData<TDto>into_persistedGridData(line 174) that the firstLoadServerDataAsyncreturns directly (lines 513-522). - Why it's built this way:
privatebecause the persistence is purely an implementation detail of the base class; asealed recordfor JSON friendliness and value semantics; the items are materialized into a freshList<TDto>with a collection expression (line 189) so the persisted snapshot is decoupled from the live grid data. - Where it's used: exclusively inside
DataGridListPageBase<TDto>, so every derived list page inherits the behavior with no wiring of its own. - Caveats / not-in-source: the persisting callback is registered with an explicit
Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveAuto(line 194) to satisfy the framework's "callback must be associated with a render mode" rule during the static prerender pass, because the page inherits its render mode from<Routes @rendermode="InteractiveAuto">rather than declaring one itself; the inline comment (lines 177-183) quotes the exact framework error this avoids. The restore runs in the synchronousOnInitialized, before any async lifecycle work.
RegisterModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/RegisterModel.cs:9· Level 0 · class (sealed)
- What it is: the
EditFormbacking model for the Register page: name, email, and password fields with DataAnnotations, plus six optional address fields. - Depends on:
System.ComponentModel.DataAnnotations([Required],[EmailAddress],[Compare]) and the sibling first-partyPasswordComplexityAttribute. - Concept reinforced, multi-field form validation with a cross-field compare.
[Rubric §24, Forms, Validation & UX Safety]. This builds on theLoginModelshape with three richer rules:[PasswordComplexity]on the password,[Compare(nameof(Password))]on the confirmation (a cross-field equality check the validator resolves by property name), and an address block left attribute-free because it is optional. The doc comment (lines 5-8) notes the annotations mirror the server's rules so client and server agree. - Walkthrough:
FirstName/LastName(lines 12, 15), each[Required]with its own message (lines 11, 14).Email(line 19),[Required]plus[EmailAddress](lines 17-18).Password(line 23),[Required]plus[PasswordComplexity](lines 21-22).ConfirmPassword(line 27),[Required]plus[Compare(nameof(Password), ErrorMessage = "Passwords do not match")](lines 25-26).AddressLine1plus nullableAddressLine2/City/State/ZipCode/Country(lines 30-35), with no validation attributes; the inline comment (line 29) states that an empty Line 1 means "no address supplied".
- Why it's built this way: the address fields stay attribute-free so a user can register without supplying one; the model is a flat view-model that the page projects onto the wire DTO at submit time rather than reusing a domain type directly, which is what lets the optional-address rule live in page code instead of leaking into the contract.
- Where it's used: instantiated as
_modeland bound byRegister.razor(<EditForm Model="_model" OnValidSubmit="HandleRegisterAsync">plus<DataAnnotationsValidator />,MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:27-28, field at line 123). On valid submit the page projects it into aRegisterRequest(Register.razor:162), folding the address fields into anAddressthroughBuildAddressResult(), which returnsnullwhen all six are blank (lines 132-138) and otherwise callsAddress.Create(...)(line 140). Its password block is mirrored byResetPasswordModel; the accepted password is hashed server-side per ADR-032. Rendering and validation behavior are covered byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/RegisterFormTests.csand the gallery E2E suite (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/RegisterPageE2ETests.cs).
ResetPasswordModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPasswordModel.cs:10· Level 0 · class (sealed)
- What it is: the
EditFormbacking model for the Reset Password page: the address and the emailed reset token that identify the request, plus the new password and its confirmation. - Depends on:
System.ComponentModel.DataAnnotations([Required],[EmailAddress],[Compare]) and the sibling first-partyPasswordComplexityAttribute. - Concept reinforced, the same password block as registration, on a credential-carrying form.
[Rubric §24, Forms, Validation & UX Safety]and[Rubric §26, Front-End Security]. The password half is exactly the shapeRegisterModelintroduced, which is the payoff of expressing the complexity rule as an attribute rather than page code. What is new is the top half:EmailandTokenare not values the user chooses, they are the credential the server minted and mailed. The client validates only that both are present and that the address is well formed; every substantive rejection (unknown, expired, mismatched, or attempt-capped token) collapses into one server-side error by design (ADR-091 Decision 3), so the form must not try to pre-judge a token it cannot verify. - Walkthrough: four
get; set;properties, each defaulting tostring.Empty.Email(line 14),[Required(ErrorMessage = "Email is required")]plus[EmailAddress(ErrorMessage = "Enter a valid email address")](lines 12-13).Token(line 17),[Required(ErrorMessage = "Reset token is required")](line 16) and nothing more: length, encoding, and freshness are all properties of the server-side cache record.NewPassword(line 21),[Required]plus[PasswordComplexity](lines 19-20).ConfirmPassword(line 25),[Required]plus[Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")](lines 23-24), the cross-field check retargeted atNewPassword.
- Why it's built this way: the doc comment (lines 5-9) records the load-bearing choice, that
EmailandTokenarrive prefilled from the reset link but stay editable, so a user who only has the raw token text from the email (the situation on a native head with no working deep link) can paste it by hand. Making those two ordinary bound fields rather than read-only parameters buys that fallback for free. - Where it's used: instantiated as
_modelbyResetPassword.razor(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPassword.razor:95) and bound by its<EditForm Model="_model" OnValidSubmit="HandleResetAsync">plus<DataAnnotationsValidator />(lines 35-36). The page declares[SupplyParameterFromQuery]EmailandTokenproperties (lines 89-93) and copies them into the model inOnParametersSet(lines 102-113), filling a field only when it is still blank (lines 104, 109) so a value the user corrected by hand is not overwritten when parameters are set again.HandleResetAsync(lines 115-140) callsIAuthUIService.ResetPasswordAsync(_model.Email, _model.Token, _model.NewPassword)(line 122, contract atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:64), flips_isCompletedon success, and on failure rendersresult.LocalizedErrorMessage(L)or the genericAuth.Reset.GenericErrorstring (line 129). The prefill path is pinned by a gallery E2E test (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Auth/ResetPasswordPageE2ETests.cs:31), with a WCAG 2.1 AA scan alongside it (:43). - Caveats / not-in-source: the model has no rule tying
Tokento the address; that pairing is enforced by the server's cache record (ADR-091 Decision 1), not by anything visible here.
SharedResource
MMCA.Common.UI ·
MMCA.Common.UI.Resources·MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/SharedResource.cs:9· Level 0 · class (sealed)
- What it is: an empty marker class that anchors
IStringLocalizer<SharedResource>over its co-located.resxfiles, the single home for cross-cutting UI chrome strings (ADR-027). - Depends on: nothing first-party. The type is empty:
public sealed class SharedResource;(line 9). Its meaning comes from the co-located resourcesSharedResource.resx(the English default) andSharedResource.es.resx(Spanish), named in the doc comment (line 7), and from the ASP.NET Core localization stack that bindsIStringLocalizer<T>to the.resxnamed afterT. - Concept introduced, the resource-anchor type.
[Rubric §27, Internationalization]assesses whether user-facing copy is externalized to per-culture resources keyed stably rather than hard-coded. ASP.NET Core'sIStringLocalizer<T>convention resolves keys against the resource file whose base name matches the typeT, so a dedicated empty class becomes the name that ties many components to one shared string table: injectingIStringLocalizer<SharedResource>anywhere reads the same dotted, stable keys (Common.Error.Load,Grid.Snackbar.LoadCancelled,Auth.Sessions.Title). The doc comment (lines 3-8) enumerates the chrome it covers: buttons, layout labels, snackbar and error templates, and the culture- and theme-switcher text. Its counterpart for library chrome isMudTranslations. - Walkthrough: there are no members. The whole contract is "be a public sealed type named
SharedResourcein this namespace, with sibling.resxfiles". The work lives in the key/value pairs and in the localization middleware that resolves them by culture. - Why it's built this way: a marker type is the idiomatic ASP.NET Core way to scope a shared resource table without inventing a real class, and one anchor keeps the chrome strings in a single table every component shares (ADR-027).
- Where it's used: injected as
IStringLocalizer<SharedResource>byDataGridListPageBase<TDto>for its cancellation toast and itsResulterror rendering (DataGridListPageBase.cs:25), bySessionsfor every label on the devices page (Sessions.razor.cs:31), by the auth pages for their field labels and messages, and handed toErrorMessages.Configurefrom the root layout (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:103) so the static helper resolves the same table. - Caveats / not-in-source: the
.resxfiles are resources, not.cs; their per-key contents are not enumerated here.
OfflineFirstPageSnapshot<TItem>
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/OfflineFirstPageSnapshot.cs:22· Level 1 · class (sealed, generic)
- What it is: a small helper that keeps the last successful first page of a list on the device and hands it back when a fetch fails while the device is offline, so a dead venue network still shows content instead of an empty grid (ADR-042).
- Depends on:
ILocalCacheStoreandIConnectivityStatusService, both taken through the primary constructor along with astring cacheKey(lines 21-24), plus the private nestedCachedPagepayload. No external NuGet dependency at all. - Concept introduced, offline-first read-through with a deliberately tiny blast radius.
[Rubric §29, Resilience & Business Continuity]assesses whether a surface degrades gracefully when a dependency is unreachable;[Rubric §19, State Management & Data Flow]assesses where client-side state lives and who owns it;[Rubric §22, Responsive & Cross-Browser]applies because the behavior is head-dependent by design. The teaching point is how narrowly the fallback is scoped. Three conditions must all hold before a cached row is ever shown (CanServe, line 30): the device reports itself offline, the store is available on this head, and the grid asked for page 1. That means the live path is untouched: an online user never reads the cache, a paged-past-page-1 user never reads it, and a head with no local store (Blazor Server, where SSR always has the live API) never reads it becauseILocalCacheStorereports itself unavailable there. The class comment (lines 5-11) states exactly that contract. - Walkthrough: three public members over a primary constructor.
CanServe(int page)(line 30): the single predicate,!connectivity.IsOnline && store.IsAvailable && page == 1. It is public so a caller can also use it as an exception filter, which is how the ADC consumer avoids swallowing a throw it has nothing to answer with.RememberAsync((IReadOnlyList<TItem> Items, int TotalItems) fetched, int page, CancellationToken)(lines 36-46): writes only whenpage == 1 && store.IsAvailable(line 41), materializing aCachedPageand handing it tostore.SetAsync(cacheKey, ..., cancellationToken)(lines 43-44). Any other page is silently left alone, so a user who paged deep does not overwrite the snapshot of page 1 with page 7.TryReadAsync(int page, CancellationToken)(lines 54-65): returnsnullimmediately unlessCanServe(page)(lines 58-61), then readsstore.GetAsync<CachedPage>(cacheKey, ...)(line 63) and projects it back into the same tuple shape the fetch delegate returns (line 64), so the caller substitutes it without reshaping anything.
- Why it's built this way: it is a plain class constructed by the consuming service rather than a DI-registered singleton, because the
cacheKeyis per surface and cannot be resolved from the container. The doc comment on that parameter (lines 16-20) states the invariant plainly: the key must be unique per list surface (and per scope, when one head shows the same list for different tenants or events), since a shared key would let one page serve another page's rows. Returningnullrather than an empty page keeps "nothing cached" distinguishable from "cached and genuinely empty", which is what lets the caller fall through to the real failure. - Where it's used: composed by ADC's
PublicSessionScheduleService, which builds one instance with a per-surface constant key (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Public/PublicSessionScheduleService.cs:28-31) and wires all three members into one fetch:RememberAsyncon every success (:42), a snapshot read when the live query returns a failedResult(:49-50), andCanServeas the exception filter on the guardedcatch(:52-59) so a throw from the store itself is rethrown when there is nothing cached to answer with. Behavior is pinned byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/OfflineFirstPageSnapshotTests.cs:15, including the per-key isolation case (:97-98). - Caveats / not-in-source: the snapshot has no expiry, no size cap, and no versioning; how long a stale first page can be served is a property of
ILocalCacheStoreand of the head's storage, not of this file. The class is best-effort by design: a store write failure insideRememberAsyncis not caught here.
DataGridListPageBase<TDto>
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:22· Level 3 · class (abstract)
- What it is: the abstract Blazor base for every server-paged
MudDataGrid<TDto>list page. It folds the otherwise copy-pasted concerns (cancellation lifecycle, loading and failure flags, mobile/desktop viewport detection, filter and sort extraction, error reporting, scroll tracking and restore, density toggle, URL plus session plus prerender state plumbing, an opt-in virtualization funnel, and disposal) into one reusable component:class DataGridListPageBase<TDto> : ComponentBase, IBrowserViewportObserver, IAsyncDisposable, IDisposable(line 22). - Depends on:
IToastService,SharedResource(asIStringLocalizer<SharedResource>),ListPageState,ListPageStateService,ListPageQueryStateService,BreakpointConstants,ErrorMessages,ResultUiExtensions(NotifyOnFailure),Result, and the nestedPersistedGridState. Externals: MudBlazor'sMudDataGrid<T>,GridState<T>,GridStateVirtualize<T>,GridData<T>,IBrowserViewportObserver/IBrowserViewportService, and Blazor'sPersistentComponentState,NavigationManager,IJSRuntime. - Concept introduced, a behavior-rich Blazor base component.
[Rubric §18, UI Architecture & Component Design]assesses reuse, and every list page in both apps inherits this behavior with no copy-paste.[Rubric §23, Front-End Performance & Rendering]assesses render and fetch cost: only the requested page is ever fetched, the prerender cache skips a redundant round trip, and the opt-in virtualization funnel keeps the DOM small for large sets.[Rubric §19, State Management & Data Flow]covers the four-channel persistence (URL, in-memory, sessionStorage, prerender cache).[Rubric §27, Internationalization]applies because the cancellation toast and everyResultfailure message resolve throughSharedResource.[Rubric §24, Forms, Validation & UX Safety]shows up in theLoadFailedflag: a failed fetch renders zero rows, which is visually identical to a genuinely empty list once the error toast expires, so derived pages branch on the flag to show an inline error-with-retry instead of the "no records" empty state (documented at lines 35-41). Several hard-won defect fixes live here too, each with the diagnosis inline: the MudDataGrid v9RowsPerPagesetter that always resetsCurrentPage(lines 470-473), the disposed-CTS race that stuck theblazor-error-uibanner (lines 781-785), and the stale-write race where a late grid-state save stamped grid parameters onto the next page's URL (lines 196-200), all of which were E2E-discovered, touching[Rubric §28, Front-End Testing]. - Walkthrough, in teaching order:
- Injected services and abstract surface (lines 24-31):
IToastService(line 24, the onlyprotectedone, so derived pages toast through the same abstraction),IStringLocalizer<SharedResource>(line 25),IBrowserViewportService(line 26), the two state services (lines 27-28),NavigationManager(line 29),IJSRuntime(line 30),PersistentComponentState(line 31). Derived pages supply the abstractTitle(line 43) and may overrideSaveFilters/RestoreFilters(lines 114, 117),GridRef(line 127),OnMobileDataRequestedAsync(line 949), and the three virtualization knobs. - Public and protected state (lines 33-78):
IsLoading(line 33),LoadFailed(line 42),IsMobile(line 46), the mobile card-view blockMobileItems/MobileTotalItems/MobileCurrentPage/MobilePageSize(lines 49-52), the bindableCurrentPageState(line 59, 0-indexed),RowsPerPageState(line 69, defaulting to 10 to match MudDataGrid v9's own default), andDenseGrid(line 78). - Constants (lines 84, 88):
PrerenderFetchTimeoutMs = 5000bounds the SSR fetch, andVirtualizedScrollContainerSelector = ".mud-table-container"records where a virtualized grid actually scrolls (the grid's own height-bound viewport, not the document). - Private fields (lines 90-105): the CTS, the
_disposedguard, the scroll module and itsDotNetObjectReference, the persistence subscription, the prerender caches_persistedGridData/_lastSuccessfulGridData,_pendingScrollRestore, the saved-state mirrors_savedPage/_savedPageSize/_savedSortColumn/_savedSortDescending, the re-entrancy and deferral flags, and a per-instance_scrollTrackerIdGUID. The observer contract'sIdandResizeOptions(a 250 ms report rate) sit at lines 108 and 111;_ownRoutePath, the stale-write anchor, is declared later at line 934. - The virtualization opt-in (lines 140, 148, 156):
VirtualizeGriddefaults tofalse, so every existing page keeps its pager untouched. A page that overrides it totruebindsVirtualize,Height="@VirtualizedGridHeight"(default70vh),ItemSize="VirtualizedItemSize"(default 52, the comfortable-density row height) andVirtualizeServerDatainstead ofServerData: the doc comment (lines 129-139) records that MudBlazor v9 accepts only one of the two funnels and that binding both leaves the grid fetching through a pager it no longer renders. Turning it on also disables the pager-restore machinery, which has no meaning without a pager; sort, filter, and density persistence still apply. OnInitialized(lines 165-246), synchronously: (a) restores anyPersistedGridStateunder the keygrid:{GetType().FullName}(lines 171-175); (b) registers the persisting callback with an explicitRenderMode.InteractiveAuto(lines 184-194); (c) pins_ownRoutePathto this page's route (line 201); (d) reads the URL throughListPageQueryStateService(line 203) and falls back to the in-memoryListPageStateServicesnapshot when the URL carries no state (lines 207-214); (e) primesCurrentPageState,RowsPerPageState,MobileCurrentPage, sort, andDenseGrid, then callsRestoreFilters(lines 216-228) so the grid's firstServerDatacall already fetches the right page; (f) sets_deferSessionPersistwhen neither channel had state (line 234) and picks up a pending scroll position (lines 237-240); and (g) subscribes toLocationChanged(lines 242-243).OnLocationChanged(lines 248-292): honors the one-shot_suppressNextLocationChangedflag (lines 250-254), reacts only to same-path back/forward navigation (a different path returns early and is handled by disposal, lines 258-262), re-reads the URL into the mirror fields (lines 264-275), then re-appliesCurrentPageto the live grid through the BL0005-suppressedApplyCurrentPageFromUrl(line 285, helper at lines 294-301) and reloads (line 288). The virtualized path skips the page re-apply entirely (lines 281-286), because there is no pager to move.NotifyBrowserViewportChangeAsync(lines 304-317): theIBrowserViewportObservercallback, recomputingIsMobilefromBreakpointConstants.IsMobileBreakpoint(line 308) and, on a desktop-to-mobile transition only, resetting to page 1 and requesting mobile data (lines 310-314).OnAfterRenderAsync(firstRender)(lines 325-382): on first render it hydrates session state now that interop is available (HydrateFromSessionAsync, line 333), runs the cross-circuit fallback (needsSessionRestoreat line 339,ApplyRestoredStateat line 343), clears the deferral (line 351), subscribes to viewport changes (line 353), imports./_content/MMCA.Common.UI/list-page-scroll.js(lines 355-357) and enables debounced (150 ms) scroll tracking through aDotNetObjectReferencescoped toScrollContainerSelector(lines 358-364), then callsRestoreGridStateAsync(line 366) and forces a sessionStorage sync (line 371). On every render it restores a pending scroll position once the grid has stopped loading (lines 375-379). JS calls back into[JSInvokable] OnScrollPositionChanged(lines 395-397), which updates only the scroll field so page, page size, and filters are untouched.RestoreGridStateAsync(lines 442-482) is the single entry point for the pager-restore machinery, so virtualization opts out in one place (lines 448-456, still honoring a session-driven reload). Otherwise it forcesSetRowsPerPageAsync(_savedPageSize, resetPage: false)when the parameter did not take (lines 465-468), then callsRestoreCurrentPageAfterRowsPerPageReset(lines 407-414) because the v9 setter clobbersCurrentPageto 0, and finally reloads when session hydration changed pagination after the grid's first fetch (lines 478-481).LoadServerDataAsync(state, fetchAsync, additionalFilters, showCancelSnackbar)(lines 503-579), the paged path and the heart of the class. It resets the CTS (line 509); returns the prerender cache on the first interactive call, still saving state (lines 513-522); setsIsLoadingand clearsLoadFailed(lines 524-526); bounds the fetch withCreateFetchCts(line 533); extracts filters and sort inside thetry(lines 540-543, because the caller'sadditionalFilterscallback is arbitrary page code and a throw from it used to strandIsLoadingattrue, comment at lines 535-537); calls the delegate with a 1-based page number (line 545); and then branches on theResultrather than on an exception: a failed result goes tofetched.NotifyOnFailure(Toast, Localizer), setsLoadFailed, and returns an empty grid (lines 546-551), while a success caches_lastSuccessfulGridDataand callsSaveCurrentState(lines 553-555).OperationCanceledExceptionmaps to an empty grid plus an optional localizedGrid.Snackbar.LoadCancelledtoast (lines 558-565); any other exception maps to an empty grid plusErrorMessages.LoadErrorandLoadFailed = true(lines 566-573); andIsLoadingis always cleared in thefinally(lines 574-578).LoadVirtualizedServerDataAsync(state, fetchAsync, additionalFilters, cancellationToken)(lines 606-674), theVirtualizeServerDatacounterpart. It manages loading, failure, and error toasts identically, but maps the row window MudBlazor asks for onto the same page-based fetch delegate, so a page can switch to virtualization without a second API contract. When the requested window straddles two pages it fetches the following page too and concatenates (lines 640-651), then trims to exactly the requested count (line 654). Cancellation here is always silent (lines 658-662): a virtualized grid supersedes its own in-flight fetch on every scroll burst, so a cancel toast would fire continuously and say nothing actionable (remarks at lines 601-605). It also forwards MudBlazor's own per-window token intoCreateFetchCts(line 621) so a superseded fetch stops at the API boundary.ComputeVirtualWindow(startIndex, count)(lines 689-698), the pure arithmetic behind that mapping and the reason it is testable: the window's own size becomes the page size, so an aligned window is exactly one page and an unaligned one spills into the next (offset > 0). It isinternal staticprecisely so the unit tests can drive it directly.CreateFetchCts(additionalToken)(lines 710-721): links to the active_cts, plus the caller's token when one can be cancelled (lines 712-714), and during non-interactive prerender (!RendererInfo.IsInteractive, line 715) callsCancelAfter(PrerenderFetchTimeoutMs)so a cold or unreachable backend cannot block the page load indefinitely.LoadMobileDataAsync(lines 727-777), the mobile-card equivalent with the same flag discipline and the sameResultbranch (lines 743-750); cancellation is silently swallowed (lines 761-764). ItsSaveCurrentState(0, 0, ...)call is deliberate (comment at lines 755-758): persisting the mobile page size would overwrite the desktop grid'sRowsPerPage, so a user who chose 50 rows and then narrowed the viewport would come back to 10.ResetCancellationTokenAsync(lines 779-801): swaps in a fresh CTS first (lines 786-787) so the caller always has a valid token, then tears down the previous one, toleratingObjectDisposedException(lines 796-799).ExtractGridFilters(lines 813-826) flattens MudDataGrid's filter definitions into a one-entry-per-column dictionary, grouping by property name and letting the newest row win (line 822) rather than throwing on the duplicate key a second filter on the same column would produce; it takes the definition collection rather than the state object so the paged and virtualized funnels share one implementation (remarks at lines 808-812).ExtractSortParameters(lines 828-833) takes the first sort definition, andResolveSortParameters(lines 840-852) adds the first-fetch fallback: when MudDataGrid has not yet picked up aSortDefinition, the sort restored from the query string is used, so the data lands sorted from the very first request.SaveCurrentState(lines 854-891): guarded byIsOwnRouteCurrent()(line 858, the stale-write drop), it composes a newListPageStatepreserving the existing scroll position (lines 867-877) and writes it to all three channels: the in-memory service (line 878), the URL viaReplaceStatewith_suppressNextLocationChangedset first so it does not re-trigger its own handler (lines 882-883), and sessionStorage (lines 887-890), skipped during the deferred-hydration window.ToggleDensity/PersistDensity(lines 898-903 and 911-931): flipsDenseGridand mirrors just that one field through the same three channels using awithexpression on the existing state (line 921), under the sameIsOwnRouteCurrentguard (line 914), so a density change made before the grid's firstServerDatasave is not lost.- Route pinning:
_ownRoutePath(line 934),GetRoutePath()(line 936, falling back to the live URI only before initialization), andIsOwnRouteCurrent()(lines 942-943). CancelLoading(line 951), the manual cancel hook a page can bind to a stop affordance.DisposeAsync/Dispose(lines 954-997 and 999-1013): dispose the persistence subscription, unsubscribeLocationChanged(helper at lines 1015-1022), disable scroll tracking and dispose the JS module guarded against shutdown-time races (JSDisconnectedException/JSException, lines 972-979), dispose theDotNetObjectReferencein afinally(line 982), unsubscribe the viewport observer best-effort (lines 985-992), and cancel plus dispose the CTS (lines 994-995). Both paths are_disposed-idempotent (lines 956-959, 1001-1002).
- Injected services and abstract surface (lines 24-31):
- Why it's built this way: every concern here was independently re-implemented (and re-broken) on individual pages before being lifted into one base, so a single fix now propagates to every list page at once. The four-channel persistence covers the full matrix of how a user can leave and return to a list: browser back, in-app navigation, refresh or
forceLoad, and a shared link. The delegate signature deliberately mirrorsIEntityService<TEntityDTO, TIdentifierType>.GetPagedAsyncexactly (remarks at lines 497-502), so a page still passes a method group with no adapter, and the move to aResult-returning delegate means a server failure is handled on the same terms an exception used to be, with the API's own localized wording reaching the toast throughResultUiExtensions. - Where it's used: base class for the list pages in both apps, including ADC's
UserList(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:17) andSessionList(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionList.razor.cs:22), and Store'sOrderList(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Order/OrderList.razor.cs:19), alongside the Catalog, Identity, and Engagement list pages. The virtualized funnel is exercised by the backend-less gallery pageGridGallery(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Gallery/Pages/GridGallery.razor:44,:50), which the deploy-gating E2E suite uses to assert that far fewer rows render than the data set holds and that scrolling happens inside the grid's own viewport (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Layout/GridPageE2ETests.cs:34,:52, with a WCAG 2.1 AA scan at:77). The base's own behavior is covered by bUnit tests atMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/DataGridListPageBaseTests.cs:22. - Caveats / not-in-source: two
BL0005suppressions (lines 294 and 407) setgrid.CurrentPagefrom outside the component; the justification (MudDataGrid v9 exposes no public method for arbitrary-page navigation and the setter is well behaved) is inlined at both. The prerender optimization assumes a warm backend; under a cold one the prerender fetch times out at 5 s and the interactive pass refills the grid. Thelist-page-scroll.jsmodule (enableScrollTracking/setScrollPosition/disableScrollTracking) is JavaScript underwwwroot, invoked here only by name, so its behavior is not verifiable from this.csfile. Note also that the route comparison isOrdinalinOnLocationChanged(line 259) butOrdinalIgnoreCaseinIsOwnRouteCurrent(line 943); the source does not state why the two differ.
ListPageActions
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ListPageActions.cs:15· Level 4 · class (static)
- What it is: two static helpers that every list page shares: reload whichever layout (mobile list or desktop grid) is currently rendered, and run the confirm-delete-toast-reload flow.
- Depends on:
MobileInfiniteScrollList<TItem>,IToastService,Result, and theDeleteConfirmationdialog component (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/DeleteConfirmation.razor:27). Externals: MudBlazor'sMudDataGrid<T>. - Concept introduced, the shared page helper that stays out of the base class.
[Rubric §15, Best Practices & Code Quality]assesses whether a repeated flow exists once;[Rubric §24, Forms, Validation & UX Safety]assesses that destructive actions confirm first and that failures surface to the user. The placement argument is in the class comment (lines 8-13) and is the interesting part: these are kept as plain statics rather than members onDataGridListPageBase<TDto>so that a page which composes its own layout, or holds several grids, can reuse them without inheriting anything. Inheritance would have forced every consumer into the base class's whole lifecycle just to get two flows. - Walkthrough: two static methods.
ReloadActiveLayoutAsync<TDto>(bool isMobile, MobileInfiniteScrollList<TDto>? mobileList, MudDataGrid<TDto>? dataGrid)(lines 25-38). When the mobile layout is active and its ref is bound it callsmobileList.ResetAsync()(line 32); otherwise it callsdataGrid.ReloadServerData()when that ref is bound (line 36). Both refs are nullable by design: only one layout is in the render tree at a time, so the other@refis genuinely null, which makes the null checks the mechanism rather than defensive noise ([Rubric §22, Responsive & Cross-Browser]).DeleteWithConfirmationAsync(...)(lines 56-93) takes the page'sDeleteConfirmationref, the entity display name, aFunc<Task<Result>>delete call, the toast service, a localized success message, aFunc<Result, string>error mapper, and a reload callback. It guards every reference argument withArgumentNullException.ThrowIfNull(lines 65-69), shows the dialog, and returns immediately unless the answer is exactlytrue(lines 71-75): a dialog dismissed withnullis a cancel, not a confirm. On confirm it awaits the delete and branches on theResult(lines 79-87): a failure toasts the mapped error and returns without reloading, a success toasts and reloads. The singlecatch (OperationCanceledException)(lines 89-92) is swallowed with a comment naming the two causes, component disposal and the InteractiveAuto render-mode transition where a Server-rendered circuit is torn down as WebAssembly takes over.
- Why it's built this way: passing the localized strings and the error mapper in as parameters keeps this class free of any resource dependency, so each page supplies its own translated text (ADR-027) while the flow itself stays identical everywhere. The
errorMessagedelegate is what lets a page choose between a fixed sentence and the API's own wording viaresult.LocalizedErrorMessage(L), which the parameter doc (lines 50-54) spells out. - Where it's used: sixteen list pages across both apps in current source. ADC calls both methods from Identity's
UserList(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:40,:77), from Conference'sEventList,SessionList,SpeakerList,RoomList,QuestionList,ConferenceCategoryList,SponsorList,ActivityList,PublicEventList, andPublicSessionListView, and from Engagement'sAttendeeSearchPanel(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/AttendeeSearchPanel.razor.cs:60). Store calls them fromProductList(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Product/ProductList.razor.cs:38,:72,:79),CategoryList,OrderList, andCustomerList. Covered byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/ListPageActionsTests.cs:20. - Caveats / not-in-source:
DeleteWithConfirmationAsynccatches onlyOperationCanceledException; any other throw from the caller'sdeleteAsyncorreloadAsyncdelegate propagates to the page's own handler, which is not visible from this file.
Sessions
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:26· Level 6 · class (partial, page code-behind)
- What it is: the code-behind for the signed-in devices page at
/profile/sessions: one row per live refresh session, with a per-device sign-out and a sign-out-everywhere. - Depends on:
IAuthUIService(line 28),IToastService(line 30),SharedResourceasIStringLocalizer<SharedResource>(line 31),RefreshSessionSummaryResponse(the row DTO, line 38),Result(line 41),ResultUiExtensions(IsNotFound,NotifyOnFailure),UserAgentSummary(line 181), andRoutePaths(line 65). Externals: Blazor'sNavigationManagerand MudBlazor'sBreadcrumbItem/MudTable. - Concept introduced, two revoke paths with deliberately different endings.
[Rubric §11, Security]assesses whether a user can see and end their own live credentials;[Rubric §25, Navigation & IA]assesses whether a destructive action leaves the app in a coherent place. The class comment (lines 16-24) states the model: a row's button calls the per-session revoke, which ends one other device's session and leaves this one alone, while the page-level button is the account-wide revoke, which also ends the session the caller is using and therefore must be followed by the normal local sign-out and a redirect. The consequence is visible in the markup: the row for the current device offers no button at all (Sessions.razor:62-69), because revoking it from a row would leave the app signed in on a dead session until the access token expired, which reads to a user as a broken sign-out. - Concept introduced (2), a single in-flight gate over two different operations.
[Rubric §18, UI Architecture & Component Design].IsBusy(line 55) is_revokingSessionId is not null || IsRevokingAll, and every button in the markup reads it (Sessions.razor:74,:90). One flag over both operations is what stops a second click from starting a concurrent revoke while the list is about to be rebuilt underneath it, and_revokingSessionIddoubles as the per-row spinner selector (Sessions.razor:77). - Walkthrough:
- State (lines 33-58): a component-scoped
CancellationTokenSource(line 35) passed into every service call and cancelled on dispose,_breadcrumbs,_sessions, the nullable_loadResultthat carries the last load outcome for inline rendering (line 41),_revokingSessionId,IsLoading(line 49),IsBusy(line 55), andIsRevokingAll(line 58).LoginRouteis aconst(line 33). OnInitializedAsync(lines 60-70): builds the breadcrumbs here rather than in a field initializer so the injected localizer is available (comment at line 62, ADR-027), then loads.LoadSessionsAsync(lines 72-101): stores the wholeResultin_loadResult(line 80) so the markup can render the failure inline with a retry button rather than as a toast (Sessions.razor:16,:22-29; the comment atSessions.razor:14-15explains why: an empty table and a failed load look identical once a toast expires). On failure it empties_sessionsdeliberately (lines 87-91), so a stale device list can never be left on screen for a user to act on.OperationCanceledExceptionis swallowed as expected-on-disposal (lines 93-96) andIsLoadingclears in thefinally.RevokeSessionAsync(session)(lines 108-147): refuses to run while busy or for the current device (lines 110-113, a second guard behind the markup's), marks the row in flight, and then treats three outcomes distinctly. Success toasts (line 123). A not-found result, which means the session is already gone (a duplicate click, or the device signed itself out), toasts at info severity instead (lines 125-130), because the user's intent is satisfied and there is nothing to correct. Any other failure goes throughresult.NotifyOnFailure(Toast, L)and returns without reloading (lines 131-135). On either satisfied outcome it reloads the list from the server rather than removing the row locally (line 137), because the server is the authority on what is still live and a reload also catches a session that expired while the page sat open (doc comment, lines 103-107).RevokeAllAsync(lines 154-172): guards onIsBusy, callsIAuthUIService.LogoutAsync()(line 165), which is exactly the account-wide revoke plus the local token clear and auth-state notification, and then navigates to/loginwithforceLoad: true(line 166) so the circuit and any cached client state are rebuilt rather than kept alive against a revoked identity.DescribeDevice(session)(lines 179-190): parses browser and platform out of the user agent viaUserAgentSummary.Parse(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/UserAgentSummary.cs:66) and composes them through a resource format string in the both-known case (line 185), so the word order translates rather than being concatenated in English order; a single known part is returned as is, and neither known falls back to an explicit "unknown device" string (line 188).[Rubric §27, Internationalization].FormatInstant(DateTime)(lines 196-197): stamps the incoming value as UTC, converts to local time, and formats withCultureInfo.CurrentCulturegeneral short pattern, because the endpoint reports UTC and "signed in at 03:14" only means something on the clock the reader uses.Dispose(bool)/Dispose()(lines 199-219): the standard idempotent pattern, cancelling and disposing the CTS.
- State (lines 33-58): a component-scoped
- Why it's built this way: rendering the load failure inline instead of as a toast is a deliberate
[Rubric §24, Forms, Validation & UX Safety]choice for a page whose empty state is indistinguishable from its failure state, and it is the same reasoningDataGridListPageBase<TDto>encodes in itsLoadFailedflag. Treating "already revoked" as an informational outcome rather than an error avoids punishing a user for a double click on an idempotent action. - Where it's used: routed at
/profile/sessionsbehind[Authorize]with[StreamRendering(false)](MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor:1-6), reachable in any host that maps the framework's UI pages. Its component behavior is covered by bUnit tests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/SessionsTests.cs:27) and its rendered accessibility by the gallery E2E WCAG 2.1 AA scan (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Auth/SessionsPageE2ETests.cs:21). - Caveats / not-in-source:
RevokeAllAsynchas nocatch, so a throw fromLogoutAsyncpropagates out of the handler withIsRevokingAllreset by thefinallybut no toast; whether the local sign-out completed in that case is a property ofIAuthUIService, not of this file. The accessibility markers the page relies on (the text-variant "this device" chip atSessions.razor:52-53, chosen so the marker does not depend on color alone, and the per-buttonaria-labels at:76and:92) live in the markup half, not in this code-behind.
LazyJsModule
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/LazyJsModule.cs:20· Level 0 · class (internal sealed)
- What it is: a small single-flight importer that owns one JavaScript module reference for a UI service: it imports on first use, shares one import across concurrent callers, and disposes the reference safely when the circuit ends.
- Depends on:
Microsoft.JSInterop(IJSRuntime,IJSObjectReference,JSDisconnectedException) and the .NETSystem.Threading.Locktype. No first-party dependencies. - Concept introduced, single-flight JS module import.
[Rubric §23, Front-End Performance]assesses whether the client loads only what it needs, when it needs it; deferringimport()until first use is the lazy half of that, and collapsing concurrent imports into one is the correctness half. The class comment (LazyJsModule.cs:5-13) states the exact defect this replaces: an unguarded_module ??= await import(...)lets two concurrent callers each start an import, after which the browser holds two module instances and the later assignment leaks the earlier reference, which is never disposed.[Rubric §15, Best Practices & Code Quality]applies to the second half of the design: a failed import is dropped rather than cached, so an import attempted during SSR prerender (when JS interop does not exist yet) does not poison the module for the rest of the circuit. - Walkthrough
- The primary constructor takes the
IJSRuntimeand the module path (LazyJsModule.cs:20). State is three fields: aLock(LazyJsModule.cs:22), the in-flight import task (LazyJsModule.cs:24) and the resolved module (LazyJsModule.cs:25).IsImported(LazyJsModule.cs:28) exists so disposal can skip work. GetOrImportAsync(LazyJsModule.cs:34) starts with a lock-free fast path returning the cached module (LazyJsModule.cs:36-39), then takes the lock only to publish or read the in-flight task (LazyJsModule.cs:44-48); the inline comment (LazyJsModule.cs:41-42) notes why holding a lock there is safe, sinceImportAsyncreaches its first await immediately and nothing slow runs under it. The awaited task is then shared by every caller (LazyJsModule.cs:52).- The
finallyblock is the subtle part (LazyJsModule.cs:54-68): it clears the field only when the task did not complete successfully (LazyJsModule.cs:58), and only when the field still holds this task (LazyJsModule.cs:62), because clearing unconditionally could drop a newer import started after this one completed and split the next set of callers. ImportAsync(LazyJsModule.cs:71-79) performs the actualjs.InvokeAsync<IJSObjectReference>("import", ...)and assigns_module.DisposeAsync(LazyJsModule.cs:82-99) returns immediately when nothing was imported (LazyJsModule.cs:84-87), nulls the field before awaiting (LazyJsModule.cs:89), and swallowsJSDisconnectedException(LazyJsModule.cs:95-98), since a torn-down circuit is the normal end of life for a scoped UI service.
- The primary constructor takes the
- Why it's built this way: the remarks (
LazyJsModule.cs:14-19) draw the responsibility line. This class deliberately does not swallow anything on the import path, so each consuming service keeps its own degradation contract (return a default, fall back to a navigation, no-op). That is why ListPageStateService wraps its calls in catch-and-ignore blocks while MmcaCultureBootstrap, which does not use this type at all, imports its module directly under anawait using. - Where it's used: ThemeService (
ThemeService.cs:20), ListPageStateService (ListPageStateService.cs:69), NavigationHistoryService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationHistoryService.cs:16) and CapabilitiesJsModule (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Capabilities/CapabilitiesJsModule.cs:19). - Caveats / not-in-source: it is
internal(LazyJsModule.cs:20), so it is not part of the published package surface: consumer apps get the benefit through the services that use it, not by using it directly.
ListPageState
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageStateService.cs:9· Level 0 · record (sealed)
- What it is: the immutable snapshot of everything a list page needs to look the same after you navigate away and come back: which page, how many rows, how far down, which sort, which density and which filters.
- Depends on: nothing first-party. It is the currency shared by
ListPageStateService (in-memory plus
sessionStorage), ListPageQueryStateService (URL encoding) and DataGridListPageBase<TDto> (the consumer). - Concept introduced, one state shape over three transports.
[Rubric §19, State Management & Data Flow]assesses whether UI state has a single defined shape rather than being reconstructed ad hoc per page; this record is that shape, and the fact that memory, session storage and the address bar all move the same record is what keeps the three from drifting. The record is documented as update-by-with(ListPageStateService.cs:5-7), so a caller changing scroll position cannot accidentally reset paging. - Walkthrough
- Eight
initmembers, all with defaults, sonew ListPageState()is a valid pristine state. Page(ListPageStateService.cs:12) is the MudDataGrid 0-indexed page;PageSize(ListPageStateService.cs:15) the chosen rows per page;MobilePage(ListPageStateService.cs:18) the 1-indexed card-list page, and it is the only member with a non-default default (= 1), because a mobile page zero does not exist.ScrollPosition(ListPageStateService.cs:26) is adoubleof pixels, and its doc comment (ListPageStateService.cs:20-25) names which element it measures: the document (document.scrollingElement.scrollTop) for a normal paged list page, and the grid's own height-bound viewport (.mud-table-container) for a page that opts into grid virtualization, where the document itself does not scroll.SortColumn(ListPageStateService.cs:32) holds theSortByproperty name of the active sort definition and is null or empty when unsorted;SortDescending(ListPageStateService.cs:38) is documented as ignored when it is.DenseGrid(ListPageStateService.cs:46) carries the compact-density opt-in, persisted alongside paging and sort so the chosen density survives navigation, refresh and shared links.Filters(ListPageStateService.cs:52) is anIReadOnlyDictionary<string, string>of page-specific named values (the doc gives"search"and"status"as examples) defaulting to an empty dictionary, so each page decides what it saves.
- Eight
- Why it's built this way: a sealed record gives value equality and
with-based copies for free, which is what makes the "update only scroll position" and "update only density" paths in the services one-liners. - Where it's used: produced and consumed by both list-page state services and by
DataGridListPageBase<TDto> (
DataGridListPageBase.cs:416andDataGridListPageBase.cs:420); serialized tosessionStorageas JSON and encoded into the query string.
IOAuthUISettings
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.OAuth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/OAuth/IOAuthUISettings.cs:9· Level 0 · interface
- What it is: three booleans that tell the shared login page which external identity providers this host can actually use, so a social login button renders only where the provider is really wired up.
- Depends on: nothing first-party. Implemented in the framework by
DefaultOAuthUISettingsandConfigurationOAuthUISettings; consumed by the sharedLoginpage. - Concept introduced, default interface members as a safe-off baseline. All three members carry a
body returning
false(IOAuthUISettings.cs:12,15,18), so an implementation can be an empty class and still compile with every provider hidden. That is what makes the framework's default a seven-line file rather than a stub with three properties.[Rubric §18, UI Architecture & Component Design]assesses whether a component asks a typed contract rather than reaching into configuration. The login page injects this interface and never touchesIConfiguration, so the same markup works on a host that has no OAuth at all.[Rubric §26, Front-End Security]assesses what the client is told. The contract carries availability only: no client id, no secret, no redirect URI. The class docs state the intent directly, that implementations declare availability so the login page can conditionally render social buttons (IOAuthUISettings.cs:3-8).
- Walkthrough: three get-only members,
GoogleEnabled(IOAuthUISettings.cs:12),GitHubEnabled(line 15) andAppleEnabled(line 18), each declared asbool X => false. - Why it's built this way: external login is optional per host, and the decision has to be readable from the render tree. Making the interface the question (rather than a settings object) lets the framework register a no-op default and lets a host swap in a real answer without any page change. The federated login flow the flags gate is recorded in ADR-036, and the mobile callback variant in ADR-043.
- Where it's used:
AddUIShared()registers the no-op default withTryAddSingleton(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:139, with the override instructions in the comment at lines 133-134). The shared login page injects it (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:11), guards each provider button on it (lines 86, 107, 128) and folds the three flags into one_hasExternalProvidersvalue that decides whether the whole external-login block renders (line 167). MMCA.ADC registersConfigurationOAuthUISettingson all three heads (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:55,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:44,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:99).
ISessionCookieSync
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ISessionCookieSync.cs:8· Level 0 · interface
- What it is: a two-method contract for mirroring the client's in-memory tokens into the browser's HttpOnly auth cookies, and for clearing them again on logout.
- Depends on: nothing first-party. Implemented by
JsFetchSessionCookieSync; consumed byWasmTokenStorageServiceand byServerTokenStorageService(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:21). - Concept introduced, the prerender visibility gap. A Blazor Web App renders a server-side pass
before the interactive circuit exists. During that pass there is no
Authorizationheader and no way to read the interactive client's in-memory access token, so an[Authorize]page opened by a deep link, an F5, or right-click "open in new tab" would bounce to/logineven for a signed-in user. The interface doc says exactly that (ISessionCookieSync.cs:3-7). The cookie is the one thing both sides can see, so keeping it in step with the in-memory token is what makes fresh GETs work.[Rubric §26, Front-End Security]assesses where browser credentials live. The target is an HttpOnly cookie, unreadable from JS, rather thanlocalStorage.[Rubric §25, Navigation, Routing & Information Architecture]assesses whether deep links behave. This contract is the reason a bookmarked authorized route renders instead of redirecting.
- Walkthrough: two members, both returning a bare
Taskbecause neither has anything to report.SyncAsync(accessToken, refreshToken)writes the pair (ISessionCookieSync.cs:10) andClearAsync()removes it (line 12). - Why it's built this way: the shape is the client half of
ADR-022, which decided
the BFF-style
mmca_auth_access/mmca_auth_refreshHttpOnly cookie pair and the/auth/session/tokenhydration endpoint. Keeping it an interface (rather than calling JS interop inline from token storage) is what lets a bUnit or unit test drive the storage services with a mock and no browser, whichWasmTokenStorageServiceTestsdoes (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/WasmTokenStorageServiceTests.cs:27). - Where it's used: registered by the dedicated extension
AddClientAuthSessionCookieSync(), whichTryAddScopedsJsFetchSessionCookieSync(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:174-178); the doc there records that both the Blazor Server host and the WebAssembly client call it (lines 165-169).
UserAgentSummary
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/UserAgentSummary.cs:18· Level 0 · class (internal, static)
- What it is: a deliberately tiny
User-Agentreader that returns the two words a person recognizes their own device by, the browser and the platform, for the signed-in-devices page. - Depends on: nothing first-party; only
string.ContainswithStringComparison.OrdinalIgnoreCase. - Concept introduced, scoping a parser to the question actually asked. The class doc argues the
design rather than describing it (
UserAgentSummary.cs:6-12): a device list only has to let someone answer "is that me?", so a full UA database buys precision nobody reads, while the browser-and-platform pair separates a phone from a work laptop. Anything unrecognized reportsnull, and the page supplies its own "unknown device" wording rather than dumping the raw header, which is neither readable nor localizable.[Rubric §27, Internationalization & Localization]assesses whether user-visible text survives translation. This is the sharpest example in the package: the two parts are returned separately and never joined, because composing "Chrome on Windows" in code would hard-code English word order. The caller formats them through a resource string (UserAgentSummary.cs:13-16, and ADR-027 is named there).[Rubric §32, Dependency & Supply-Chain]applies to what is absent: no UA-parsing library and no data file to keep current, which is a real dependency avoided for a cosmetic feature.
- Walkthrough:
Browsers, eleven(Token, Name)pairs in most-specific-first order (UserAgentSummary.cs:25-38). Order is load-bearing and the comment says why (lines 20-24): every Chromium browser also says "Chrome", and Chrome and Edge both say "Safari", soEdg/,EdgiOS/andEdgA/come beforeOPR/, which comes beforeCriOS/andChrome/, which come beforeSafari/.Platforms, ten pairs with the same rule (lines 44-56):Windows PhonebeforeWindows,Mac OS XandMacintoshbeforeLinux, because an iPad reports "Macintosh" in desktop mode and Android reports "Linux" (lines 40-43).Parse(string? userAgent)(line 66) returns(null, null)for a missing or blank header (lines 68-71), otherwise runs the shared matcher over each table and returns the pair (line 73).Match(userAgent, candidates)(line 76) walks the table in order and returns the first name whose token appears case-insensitively, ornull(lines 78-86).
- Why it's built this way: two ordered tables plus one loop is the entire implementation, so
adding a browser is one line and the ordering rule is visible at the point it matters. It is
internalbecause nothing outside the package should treat it as a UA parser. - Where it's used: exactly one call site,
Sessions.DescribeDevice(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:181), whoseswitchcovers all four null combinations and falls back to a localized "unknown device" string (lines 183-189). Pinned byUserAgentSummaryTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/UserAgentSummaryTests.cs:13). The page itself is the UI half of ADR-097.
ListPageQueryStateService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageQueryStateService.cs:28· Level 1 · class (sealed)
- What it is: the two-way translator between a ListPageState and the browser address bar, so browser back/forward, a refresh and a link pasted into chat all restore the same filtered, sorted, paged view.
- Depends on: ListPageState; externals
NavigationManager,Microsoft.AspNetCore.WebUtilities.QueryHelpersandStringValues. - Concept introduced, the URL as shareable state.
[Rubric §25, Navigation & Information Architecture]assesses whether the address bar reflects what the user is looking at; this class is that contract for every list page. The remarks (ListPageQueryStateService.cs:14-27) document the reserved keys and why they are terse (they end up in shareable links):p(0-indexed desktop page),ps(page size),mp(1-indexed mobile page),s(sort column),sd(desconly, since ascending is the default),d(1only, since comfortable density is the default),q(free-text search) andf:<name>for any other named filter. Defaults are omitted entirely, so a pristine list page has a clean URL.[Rubric §19, State Management & Data Flow]applies because this is the second of the three transports the same record travels over. - Walkthrough
- The key names are private constants (
ListPageQueryStateService.cs:30-40), including the"search"filter name that maps toqby convention and thedesc/1markers. ReadCurrent()(ListPageQueryStateService.cs:45-49) is the instance entry point: it resolves the absolute URI from the injectedNavigationManagerand hands the query to the parser.ParseQueryString(ListPageQueryStateService.cs:56) is deliberatelystaticand public, documented as a pure helper exposed for unit testing without aNavigationManager(ListPageQueryStateService.cs:51-55). It reads the three integers throughTryGetInt(ListPageQueryStateService.cs:212-222, which parses withCultureInfo.InvariantCultureand falls back to the supplied default rather than throwing), treats a blank sort value as no sort (ListPageQueryStateService.cs:65-72), matchesdesccase-insensitively (ListPageQueryStateService.cs:77) but the dense marker1ordinally (ListPageQueryStateService.cs:83), then walks every remaining key, foldingqinto thesearchfilter and stripping thef:prefix off the rest (ListPageQueryStateService.cs:87-103).BuildPath(ListPageQueryStateService.cs:122) is the inverse, and the omission rules are visible one by one: page only when> 0(ListPageQueryStateService.cs:129), page size only when> 0(ListPageQueryStateService.cs:134), mobile page only when> 1(ListPageQueryStateService.cs:139),sdonly when a sort column exists and is descending (ListPageQueryStateService.cs:144-151),donly when dense (ListPageQueryStateService.cs:153-156); with no parameters at all it returns the bare base path (ListPageQueryStateService.cs:175-177).ReplaceState(ListPageQueryStateService.cs:196) writes the URL back usingNavigationOptions { ReplaceHistoryEntry = true }(ListPageQueryStateService.cs:209) so filter changes do not pollute the back stack.
- The key names are private constants (
- Why it's built this way: the most instructive part is the guard in
ReplaceState(ListPageQueryStateService.cs:201-206), which drops the write when the current path no longer matches the owningbasePath. The remarks (ListPageQueryStateService.cs:186-195) record the diagnosed defect: a grid-state write is inherently deferred (a debounced search, a lateServerDatacompletion), so it can land after the user has already navigated away. Building from the then-current URI used to stamp grid parameters onto the next page's URL and issue a spurious navigation that disposed it mid-load, and detail pages reached by clicking a list row had their first data fetch canceled about 66ms in, leaving them stuck on their loading state. - Where it's used: registered
TryAddScoped(DependencyInjection.cs:115) and injected into DataGridListPageBase<TDto> (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:28), which reads the URL on initialization and on parameter changes (DataGridListPageBase.cs:203andDataGridListPageBase.cs:264) and writes it back after a grid or filter change (DataGridListPageBase.cs:883andDataGridListPageBase.cs:925).
ListPageStateService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageStateService.cs:63· Level 1 · class (sealed)
- What it is: the per-circuit memory of list-page state, keyed by route, with an optional
write-through to
sessionStorageso the state survives things a circuit-scoped dictionary cannot. - Depends on: ListPageState and LazyJsModule; externals
IJSRuntime,IJSObjectReferenceand thenav-interop.jsmodule shipped in the package'swwwroot. - Concept introduced, a synchronous fast path with an asynchronous durable path.
[Rubric §19, State Management & Data Flow]assesses how state survives lifecycle boundaries; this class answers with two tiers. The class comment (ListPageStateService.cs:55-62) names exactly what the durable tier buys: state survives circuit teardowns,forceLoad: truenavigations and the SSR to WASM render-mode transition. The synchronous dictionary matters just as much, because it is safe to read fromOnInitializedduring prerender, when JS interop does not exist yet. - Walkthrough
- Two constants set the contract: the module path
./_content/MMCA.Common.UI/nav-interop.js(ListPageStateService.cs:65) and themmca.lps:session-key prefix (ListPageStateService.cs:66). State is a plainDictionary<string, ListPageState>(ListPageStateService.cs:68) plus one LazyJsModule (ListPageStateService.cs:69). GetState(ListPageStateService.cs:76-77) is aGetValueOrDefaultand is documented as safe to call during SSR prerender (ListPageStateService.cs:71-75).SaveState(ListPageStateService.cs:84-85) stores in memory only.UpdateScrollPosition(ListPageStateService.cs:92-95) is the fast path for scroll events: it uses awithexpression to preserve every other field, and creates a minimal entry when none exists yet, for the case where the user scrolls before the grid has fired its first save.HydrateFromSessionAsync(ListPageStateService.cs:103) invokessessionGeton the JS module (ListPageStateService.cs:113, the export atMMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/nav-interop.js:12) and adopts any persisted snapshot (ListPageStateService.cs:114-117).PersistToSessionAsync(ListPageStateService.cs:138) does the reverse throughsessionSet(ListPageStateService.cs:153,nav-interop.js:24), returning early when there is nothing in memory to write (ListPageStateService.cs:140-143).- Both wrap the interop in the same three-catch shape:
InvalidOperationExceptionfor prerender,JSDisconnectedExceptionfor a torn-down circuit andJSExceptionas the defensive catch for storage failures such as Safari Private mode or an exceeded quota (ListPageStateService.cs:119-130andListPageStateService.cs:155-166). - The private
GetModuleAsync(ListPageStateService.cs:169-183) converts an unavailable runtime into anullmodule rather than an exception, which is what makes the two public methods' early returns read cleanly.DisposeAsync(ListPageStateService.cs:186) simply forwards to the module wrapper.
- Two constants set the contract: the module path
- Why it's built this way: the degradation contract here is "never let storage failures break the calling page", which is why this class swallows what LazyJsModule deliberately does not. Scoped registration means one instance per circuit, so the in-memory dictionary is naturally per-user without any keying by identity.
- Where it's used: registered
TryAddScoped(DependencyInjection.cs:114) and injected into DataGridListPageBase<TDto> (DataGridListPageBase.cs:27), which reads it during state restore (DataGridListPageBase.cs:205), hydrates from session on first render (DataGridListPageBase.cs:333-338), records scroll offsets (DataGridListPageBase.cs:397), and saves plus persists after grid and density changes (DataGridListPageBase.cs:864-889andDataGridListPageBase.cs:920-929).
MudAppDialogService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/MudAppDialogService.cs:11· Level 1 · class (internal sealed)
- What it is: the MudBlazor-backed implementation of the framework's confirm-prompt facade. It asks
MudBlazor's message box for a yes/no answer and reduces it to a single
bool. - Depends on: IAppDialogService (implemented) and MudBlazor's
IDialogService(MudAppDialogService.cs:1-2). It has no other state. - Concept introduced, quarantining the component library behind a facade.
[Rubric §15, Best Practices & Code Quality]assesses how much of the codebase would have to change if a vendor dependency changed; the class comment (MudAppDialogService.cs:6-9) records the answer for dialogs: this type and MudToastService are the only two types in the framework that name a component-library service.[Rubric §14, Testability]is the other half of the payoff, spelled out on the interface (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IAppDialogService.cs:8-12): a test can answer the prompt with a stub instead of driving a rendered dialog. - Walkthrough
- The primary constructor takes MudBlazor's
IDialogService(MudAppDialogService.cs:11); the class isinternal, so consumers only ever see the interface. ConfirmAsync(string title, string message, string confirmText, string cancelText)(MudAppDialogService.cs:14) forwards toShowMessageBoxAsyncwith the confirm label asyesTextand the decline label ascancelText(MudAppDialogService.cs:19-23). The labels are already localized by the caller, per the interface doc (IAppDialogService.cs:21-24).- The return is
confirmed is true(MudAppDialogService.cs:25). The comment above it (MudAppDialogService.cs:16-18) states the contract:ShowMessageBoxAsyncanswersnullwhen the user dismissed the dialog without choosing (backdrop click, escape), and collapsing that ontofalsemeans only an active confirmation counts as one, so callers never have to branch on three outcomes.
- The primary constructor takes MudBlazor's
- Why it's built this way: the interface deliberately exposes only the one shape the framework needs
(a yes/no question before something irreversible or lossy), leaving richer entity-specific dialogs
component-side (
IAppDialogService.cs:3-7). Keeping the implementationinternaland registered byAddUISharedmeans an app cannot accidentally depend on the MudBlazor type through this path. - Where it's used: registered with
TryAddScoped<IAppDialogService, MudAppDialogService>()(DependencyInjection.cs:165, under the facade-registration doc atDependencyInjection.cs:148-161). Consumers resolve the interface: the sharedUnsavedChangesGuardcomponent (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/UnsavedChangesGuard.razor:14andUnsavedChangesGuard.razor:57) and the Helpdesk seed's ticket pages (MMCA.Helpdesk/Source/Hosts/UI/MMCA.Helpdesk.UI.Web/Components/Pages/Tickets.razor:104andComponents/Pages/TicketDetail.razor:348). A bUnit test pins the registration to this implementation (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Infrastructure/BunitComponentTestBaseFacadeTests.cs:32).
AuthDelegatingHandler
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:10· Level 1 · class (sealed)
- What it is: the
DelegatingHandlerthat attaches the stored JWT as aBearerheader on every outgoing API request, so no UI service ever sets anAuthorizationheader by hand. - Depends on:
ITokenStorageService(its only constructor parameter); externalsSystem.Net.Http.Headers.AuthenticationHeaderValueandDelegatingHandler. - Concept introduced, the HTTP message-handler pipeline.
HttpClientcomposes handlers into a chain, each free to inspect or mutate a request before passing it to the next. Registering this one on the named"APIClient"client means auth is applied once, at the transport, for every typed service built on top of it.[Rubric §6, CQRS & Event-Driven Design]assesses whether concerns like auth are centralized rather than repeated per call. This is the client-side twin of the server's middleware pipeline: one registration covers every request.[Rubric §1, SOLID]shows in the single responsibility. The handler knows nothing about login, refresh, or expiry; it asks storage for whatever token exists and moves on.
- Walkthrough:
SendAsync(request, cancellationToken)(AuthDelegatingHandler.cs:14) awaitsGetAccessTokenAsync()(line 17), and only when the result is non-blank setsrequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token)(lines 18-21) before delegating tobase.SendAsync(line 23). An anonymous call therefore goes out with no header at all rather than an empty one, which matters for the endpoints that are deliberately anonymous. Note the freshness work happens inside the token service: by the time this handler sees a token, a stale one has already been re-acquired. - Why it's built this way: a handler rather than a base-class helper, because the pipeline applies
to everything the named client sends, including calls made by code that never inherits from a
framework base. It is registered
AddTransient(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:81), the lifetimeAddHttpMessageHandlerexpects. - Where it's used: added to the
"APIClient"pipeline alongside the culture handler (DependencyInjection.cs:105-106, with the intent stated at lines 75-76). One documented bypass exists:AuthenticatedServiceBasebuilds a client with the token set directly for the cases where the pipeline is not in play (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:47). Covered byAuthDelegatingHandlerTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/AuthDelegatingHandlerTests.cs:16) and by a DI resolution test that exists because the pipeline must be able to construct it (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/ApiClientRegistrationTests.cs:29).
ConfigurationOAuthUISettings
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.OAuth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/OAuth/ConfigurationOAuthUISettings.cs:13· Level 1 · class (sealed)
- What it is: the real
IOAuthUISettings: it computes provider availability once at construction from theOAuthconfiguration section, and covers both a server host and a WASM client with a single class. - Depends on:
IOAuthUISettings(the contract it implements); externallyMicrosoft.Extensions.Configuration(IConfiguration,IConfigurationSection). - Concept introduced, one class over two configuration shapes. A server host holds the actual
OAuth client ids, so "is Google available" is answered by whether
OAuth:Google:ClientIdis populated. A WASM client must never receive a client id, so it is handed pre-computedOAuth:GoogleEnabledflags through its runtime configuration endpoint instead (ConfigurationOAuthUISettings.cs:5-12). The class accepts either signal.[Rubric §26, Front-End Security]assesses what configuration reaches the browser. The WASM path carries availability flags only, never the client id, and the class shape is what makes that possible without a second implementation.[Rubric §15, Best Practices & Code Quality]assesses duplication. One class, one rule, three providers.
- Walkthrough: three get-only auto-properties (
ConfigurationOAuthUISettings.cs:16,19,22) set in the constructor (line 24), which null-guards the configuration (line 26), takes theOAuthsection (line 28) and evaluates each provider through the shared helper (lines 29-31).IsProviderEnabled(oauth, provider)(line 34) is the whole rule: parse{provider}Enabledas a bool (line 36), and return true when that flag is set or when{provider}:ClientIdis non-empty (line 37). Because the values are read once into properties, a render pass never re-walks configuration. - Why it's built this way: the flag-or-client-id disjunction is what lets the same type serve both
hosts. The server side of the pairing is documented from the API package, which notes that the same
OAuth:{Provider}:ClientIdkeys the API reads are what this class reads for itsGoogleEnabled/GitHubEnabledanswers (MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:17). The federated-login design behind the flags is ADR-036. - Where it's used: MMCA.ADC registers it with
AddSingleton(which replaces the framework'sTryAddSingletondefault regardless of ordering) on the Blazor Server head (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:55), the WASM client (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:44) and MAUI (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:99, whose comment at line 78 explains that the MAUI registration goes beforeAddUISharedbecause that callTryAdds the default). The server head also projects the resolved flags to the WASM client through its/client-configendpoint (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:148).
DefaultOAuthUISettings
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.OAuth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/OAuth/DefaultOAuthUISettings.cs:7· Level 1 · class (internal, sealed)
- What it is: the framework's no-op
IOAuthUISettings, which reports every provider as unavailable so an app that has not configured external login shows no social buttons. - Depends on:
IOAuthUISettingsonly. - Concept reinforced, the Null Object as a registration default (the same move
NullNotificationScopeProvidermakes for notification scoping).[Rubric §2, Design Patterns]assesses whether a pattern removes branching: because a default is always registered, the login page injects the interface unconditionally and never tests whether one exists. - Walkthrough: the entire type is one line,
internal sealed class DefaultOAuthUISettings : IOAuthUISettings;(DefaultOAuthUISettings.cs:7). It has no members becauseIOAuthUISettingsgives all three propertiesfalse-returning default implementations; the class doc records the contract, that downstream apps override this registration to enable specific providers (lines 3-6). - Why it's built this way:
internalbecause nothing outside the package should name it, and a semicolon body because the default interface members already say everything. It is registered withTryAddSingleton(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:139), so a host that registers first keeps its own, and a host that registers afterwards withAddSingletonwins the resolution. - Where it's used: resolved as
IOAuthUISettingsin every host that has not registered its own, which today is all of MMCA.Store's UI heads and MMCA.Helpdesk.
JsFetchSessionCookieSync
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JsFetchSessionCookieSync.cs:11· Level 1 · class (sealed)
- What it is: the
ISessionCookieSyncimplementation. It calls two small JS helpers that issue a browser-sidefetch, so the resultingSet-Cookielands in the user's cookie jar on both Blazor Server and WebAssembly. - Depends on:
ISessionCookieSync(the contract) andIJSRuntime(Microsoft.JSInterop), plus themmcaAuthCookieobject defined in the package's static web assetMMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/mmca-auth-cookie.js:4. - Concept introduced, why the fetch has to come from the browser. On Blazor Server the code runs on
the server, so a server-issued HTTP call would put the cookie in the server's own handler, not the
user's browser. Routing the call through JS interop makes the browser the one issuing the request,
which is the only way the
Set-Cookiereaches the right cookie jar (JsFetchSessionCookieSync.cs:5-9).[Rubric §26, Front-End Security]again: the tokens transit JS only for this one same-origin POST and are never persisted anywhere JS can read afterwards.[Rubric §29, Resilience & Business Continuity]assesses degradation. Every interop failure is absorbed, so a prerender pass or a disconnected circuit cannot throw out of a token write.
- Walkthrough:
IsInteropUnavailable(ex)(JsFetchSessionCookieSync.cs:13) is the shared exception filter, naming the four types that mean "there is no live JS runtime right now":InvalidOperationException,JSDisconnectedException,JSExceptionandOperationCanceledException(line 14).SyncAsync(accessToken, refreshToken)(line 16) invokesmmcaAuthCookie.setwith both tokens (line 20) and swallows an interop failure, the comment noting the cookie will be synced on the next write (lines 22-25).ClearAsync()(line 28) invokesmmcaAuthCookie.clear(line 32) under the same filter, the comment noting the cookie will still be cleared when the user next logs in or the token expires (lines 34-37).
- Why it's built this way: a shared static filter rather than two duplicated
whenclauses keeps the definition of "interop unavailable" in one place, and swallowing rather than rethrowing matches the fact that this is a mirror of state that already exists in memory. The cookie contract itself belongs to ADR-022. - Where it's used:
TryAddScopedbehindISessionCookieSyncbyAddClientAuthSessionCookieSync()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:176); consumed byWasmTokenStorageServiceandServerTokenStorageService. - Caveats / not-in-source: the
mmcaAuthCookie.set/.clearJS implementations live inmmca-auth-cookie.js, outside this unit; only the C# side is described here.
JwtAuthenticationStateProvider
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtAuthenticationStateProvider.cs:13· Level 1 · class (sealed)
- What it is: the custom
AuthenticationStateProviderthat turns the stored JWT into theClaimsPrincipalBlazor'sAuthorizeViewand[Authorize]read, and pushes a new state immediately on login and logout. - Depends on:
ITokenStorageService(its only constructor parameter); externalsMicrosoft.AspNetCore.Components.Authorization.AuthenticationStateProvider,System.Security.ClaimsandJwtSecurityTokenHandler. - Concept introduced, Blazor's auth-state contract. Blazor does not know about JWTs; it asks an
AuthenticationStateProviderfor anAuthenticationStateand re-renders everyCascadingAuthenticationStateconsumer when the provider says the state changed. This class is the adapter between "there is a token in storage" and that framework contract.[Rubric §19, State Management & Data Flow]assesses how a cross-cutting piece of UI state propagates. Notifying rather than reloading is the whole point: a sign-in updates the navbar and every guarded fragment without a page refresh (lines 55-58).[Rubric §11, Security]assesses the trust boundary, and the class doc draws it: claims are extracted client-side without server validation to keep the UI responsive, and the WebAPI performs full token validation on every request (lines 7-11).
- Walkthrough:
AnonymousState, a single staticAuthenticationStateover an emptyClaimsIdentity(JwtAuthenticationStateProvider.cs:15-16). Because aClaimsIdentitywith no authentication type reportsIsAuthenticated == false, this one shared instance is the "signed out" answer.GetAuthenticationStateAsync()(line 22) reads the token (line 26) and returns the anonymous state on a blank token (lines 27-30), an unreadable token (lines 33-36), or an expired one (ValidTo < DateTime.UtcNow, lines 39-42). On success it builds aClaimsIdentityfrom the token's claims with the authentication type"jwt", and the comment at line 44 records why that string matters: naming an authentication type is what makes the identityIsAuthenticated. A barecatch(lines 49-52) turns any remaining failure, including JS interop being unavailable, into anonymous rather than an exception inside a render.NotifyUserAuthentication(token)(line 59) rebuilds the principal the same way and calls the baseNotifyAuthenticationStateChanged(line 65). It does not consult storage, because the caller has just been handed the token.NotifyUserLogout()(line 71) pushesAnonymousStateback out.
- Why it's built this way: falling back to anonymous on every failure is the safe direction for a
UI gate, since the API rejects anything the client wrongly let through. Keeping the two notify
methods public (rather than internal to the auth service) is what lets
AuthUIServicedrive the state transition at the exact moment tokens change; it does so behind anis JwtAuthenticationStateProvidertype test (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:132,161,169,303), so a host that registered a different provider still works. - Where it's used: registered against
AuthenticationStateProvideron every head (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:116,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:46,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:98). Covered byJwtAuthenticationStateProviderTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/JwtAuthenticationStateProviderTests.cs:14);AuthUIServiceTestsconstructs a real one rather than a double, precisely because the type test above would not match a mock (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/AuthUIServiceTests.cs:65-67).
MudToastService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/MudToastService.cs:12· Level 5 · class (internal sealed)
- What it is: the MudBlazor-backed IToastService. It is one of only two types in the framework that name a component-library service, the other being its sibling MudAppDialogService.
- Depends on: IToastService (the contract,
MudToastService.cs:12) and ToastSeverity (the vendor-neutral level, line 27); MudBlazor'sISnackbar,Severity,Variant,ColorandSnackbarOptions(lines 2, 12, 47, 63), andMicrosoft.AspNetCore.Components.Rendering.RenderTreeBuilder(ASP.NET Core) for the one method that renders markup (lines 32-40). - Concept introduced, the vendor boundary.
[Rubric §20, Design System, Theming & UI Consistency]assesses whether the app depends on its own design vocabulary rather than on a specific component library's API. Every page, component andResulthelper in both applications depends onIToastService; only this class andMudAppDialogServiceknow that MudBlazor exists, and the DI comment says so outright (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:150-153).[Rubric §14, Testability & Test Strategy]is the practical payoff: a test records toasts against the interface without rendering a snackbar host, which is how ResultUiExtensions.NotifyOnFailurecan be tested at all (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:265,277).[Rubric §1, SOLID Principles]covers the shape: an internal implementation behind a public interface means no consumer can name the concrete type even by accident. - Walkthrough
- Four one-liners cover the common levels:
Success,Info,WarningandError(lines 15-24), each a directsnackbar.Add(message, Severity.X).Show(message, severity)(line 27) is the same call with the level chosen at runtime. ShowPersistent(title, body, severity)(line 30) is the push-notification shape. It renders a two-line body through aRenderTreeBuilder(a bolded title, a line break, then the body, lines 34-39) and setsRequireInteraction = truewithVariant.Filled(lines 46-47). The comment states the rule (lines 44-45): the message arrived unprompted, so it must survive until the user has actually looked at the screen rather than expiring on the default timer.ShowAction(message, actionText, onAction, severity, requireInteraction)(line 51) is the undo-style toast. It setsActionandActionColor(lines 62-63) and adapts MudBlazor's click signature to the caller's parameterless delegate by discarding theSnackbarinstance MudBlazor passes (line 67).requireInteractionis opt-in: when false the options are left untouched so the host's own snackbar timing applies, and when true bothRequireInteractionandVariant.Filledare stated outright rather than relying on MudBlazor's null default (comment at lines 71-74).Map(ToastSeverity)(line 85) projects the neutral enum onto MudBlazor's with an explicit switch over all five members plus aNormaldefault (lines 87-92). It is written out rather than cast on purpose: the two enums agree numerically today, and an implicit dependency on that would break silently the day either side gains a member (comment at lines 80-84).- Nothing wraps
onAction. The absence is a documented contract, pinned by a test that asserts a throwing callback propagates (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/MudToastServiceTests.cs:49-59): a caller whose work can fail guards it instead of discovering the failure as a swallowed no-op.
- Four one-liners cover the common levels:
- Why it's built this way: keeping the vendor type behind a facade is what makes the component
library swappable in principle and mockable in practice, and it is the reason the framework ships
AddCommonUiFacades()as its own registration (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:162-167), factored out so a bUnit harness can register exactly these two services without pulling in the whole shared-UI surface (comment at lines 144-157). Every method returnsvoid: a toast is fire-and-forget by design, and MudBlazor'sISnackbar.Addis synchronous. - Where it's used: registered by
AddCommonUiFacadeswithTryAddScoped(DependencyInjection.cs:164), whichAddUISharedcalls for every host (DependencyInjection.cs:110). Consumers resolveIToastService, never this type: the framework'sNotificationListenerraises an incoming push as a persistent toast (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Notifications/NotificationListener.razor:49), ResultUiExtensions.NotifyOnFailureturns a failed Result into one (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:277), and ADC's LiveEventListener uses the action shape for its reconnect prompt (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Components/LiveEventListener.razor.cs:80,165). Its own behavior is pinned by MudToastServiceTests, which captures the options lambda and applies it to a freshSnackbarOptionscarrying MudBlazor's defaults, so the assertions see exactly what a rendered snackbar would (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/MudToastServiceTests.cs:114). - Caveats:
ShowPersistentrenders the title and body as content in a render fragment, so both are escaped by the renderer, but neither string is length-bounded in source: a long push body produces a correspondingly tall toast.Show,Successand the rest pass the caller's string straight to MudBlazor, so any localization has to happen before the call; the facade does not touchIStringLocalizer.
IAuthUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:18· Level 5 · interface
- What it is: the single client-side authentication contract: login, register, OAuth code exchange, logout, refresh, the three password flows, and the signed-in-devices list with revoke. Every auth page in the framework talks to this and nothing else.
- Depends on:
Resultand its generic form fromMMCA.Common.Shared.Abstractions; theMMCA.Common.Shared.AuthcontractsLoginRequest,RegisterRequest,AuthenticationResponseandRefreshSessionSummaryResponse; andErrorTypefor the failure kinds it documents. Implemented byAuthUIService. - Concept introduced, the failure travels with the call. The interface doc states the rule
(
IAuthUIService.cs:11-16): every call that talks to the API returns aResultcarrying the server's own errors, so the failure arrives with the call rather than on aLastErrorproperty that the next call would overwrite. Pages render it throughMMCA.Common.UI.Common.ResultUiExtensions(ResultUiExtensions).[Rubric §9, API & Contract Design]assesses whether return types carry meaning. Three different shapes appear here on purpose, and each deviation is argued in source rather than assumed.[Rubric §24, Forms, Validation & UX Safety]assesses whether a form can show the server's real message. Because the failure is the return value, a login page can bind the error next to the field without a second lookup.[Rubric §11, Security]shows in two documented behaviors: the anti-enumeration contract on password reset, and the OAuth exchange keeping tokens out of the address bar.
- Walkthrough: eleven members, in the order they appear.
LoginAsync(LoginRequest, ct)andRegisterAsync(RegisterRequest, ct)both returnResult<AuthenticationResponse>and both store tokens on success (IAuthUIService.cs:21,24).ExchangeOAuthCodeAsync(code, ct)(line 29) trades a single-use completion code carried in the redirect URL for the token pair throughauth/oauth/exchange, stores the tokens and notifies auth state; the doc's closing sentence names the reason for the indirection, keeping tokens out of the address bar (lines 24-28).LogoutAsync()(line 37) is the deliberate no-Resultmember: it revokes the server-side refresh sessions and clears local storage, and returns nothing because the local sign-out happens whatever the server answered. A user who asked to leave must never be kept signed in by a failed network call (lines 31-36).TryRefreshTokenAsync(ct)(line 45) is the deliberateboolmember: it makes no API call of its own, since the host'sITokenRefresherowns the exchange, and its two states ("session still live", "session gone") are not errors to render (lines 39-44).ChangePasswordAsync(currentPassword, newPassword, ct)(line 48) hitsauth/password.RequestPasswordResetAsync(email, ct)(line 55) hits the anonymousauth/forgot-password. The doc pins the semantics: the endpoint answers 202 for every well-formed address as an anti-enumeration measure, so a success means "accepted", never "an account exists" (lines 50-54).ResetPasswordAsync(email, token, newPassword, ct)(line 62) completes the reset via the anonymousauth/reset-password; an invalid, expired or already-consumed token comes back as a failure carrying the server's generic message (lines 57-61).GetSessionsAsync(ct)(line 70) returnsResult<IReadOnlyList<RefreshSessionSummaryResponse>>fromauth/my-sessions, newest first, and documents that exactly one row can carryIsCurrent, resolved server-side from the access token'ssidclaim (lines 64-69).RevokeSessionAsync(sessionId, ct)(line 79) signs one device out viaauth/revoke/{sessionId}; another account's session id (or a nonexistent one) answers 404 and arrives as anErrorType.NotFoundfailure, while revoking an already-revoked session succeeds (lines 72-76).
- Why it's built this way: the interface is where the three return shapes are justified, and each justification is a behavior rule rather than a style preference. Keeping all eleven operations on one contract (rather than splitting sessions or password flows onto their own) matches how they are consumed: the shipped auth pages are themselves framework types, so there is one implementation and one registration. The device-list and revoke members are the UI surface of ADR-097; the OAuth exchange is the client end of ADR-036 and ADR-043.
- Where it's used: registered
TryAddScopedagainstAuthUIServicebyAddUIShared()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:113, the comment at line 108 notingTryAddprevents duplicate registration when several hosts call in); injected by the shipped auth pages, includingLogin(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor) andSessions.
AuthUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:37· Level 6 · class (sealed)
- What it is: the one client-side service that owns a user's session on a Blazor or MAUI head. It
calls the WebAPI
auth/*endpoints (sign in, register, OAuth code exchange, change password, forgot and reset password, list and revoke devices, sign out), persists the returned token pair, and tells Blazor's authentication state that something changed, soAuthorizeViewand[Authorize]routes react without a page reload. Every method that talks to the API hands back a Result carrying the server's own error text, so no page has to interpret anHttpResponseMessage. - Depends on: first-party
IAuthUIService (the contract it implements,
AuthUIService.cs:43), ITokenStorageService (token persistence, injected atAuthUIService.cs:39), ITokenRefresher (the host-specific renewal path,AuthUIService.cs:40), JwtAuthenticationStateProvider (injected as the frameworkAuthenticationStateProviderbase type atAuthUIService.cs:41and pattern-matched back down), IPushRegistrationService (native push cleanup,AuthUIService.cs:42), IUiReadCache (optional, defaulted tonullatAuthUIService.cs:43), HttpResultExecutor (transport-fault translation), ProblemDetailsResultReader (response translation), and the shared auth contracts LoginRequest, RegisterRequest, OAuthCodeExchangeRequest, ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest, AuthenticationResponse and RefreshSessionSummaryResponse. Externals:IHttpClientFactory,System.Net.Http.Json(PostAsJsonAsync,PutAsJsonAsync),System.Net.Http.Headers.AuthenticationHeaderValue, andMicrosoft.AspNetCore.Components.Authorization.AuthenticationStateProvider. - Concept introduced, the client-side session lifecycle as one service, with a sign-out that cannot
fail. Everything a session needs on the client (acquire a token pair, hold it, renew it, publish the
identity to the component tree, and tear all of that down) lives behind a single injectable interface,
so no component ever touches
localStorage, a Bearer header, or a token string.[Rubric §26, Front-End Security]assesses whether credentials are confined to a narrow, auditable surface in the browser: here the only code that reads or writes tokens is this service plus AuthDelegatingHandler, both of them going through ITokenStorageService rather than doing JS interop of their own.[Rubric §11, Security]assesses the end-to-end auth design: sign-out is deliberately local-first, the remote revoke is best effort, and both the server call and the local clear are wrapped so a dropped connection can never strand a user inside a session they asked to leave (AuthUIService.cs:103-136).[Rubric §19, State Management]assesses who owns mutable client state and when it is invalidated: this service is the single writer of auth state, and it is also the thing that empties the read cache, because on WebAssembly and MAUI the DI scope is the app lifetime, so cached rows would otherwise outlive the account that fetched them (AuthUIService.cs:32-36,124-127).[Rubric §18, UI Architecture]sees the same shape the entity services use, a typed service over the named"APIClient"returningResult, so pages render failures with ResultUiExtensions instead of catching exceptions.[Rubric §14, Testability]is served by taking all five collaborators through the primary constructor with no statics: AuthUIServiceTests drives the whole class through a stubHttpMessageHandler. - Walkthrough
- Two public error codes head the class.
TokenStorageUnavailableCode = "Auth.TokenStorageUnavailable"(AuthUIService.cs:49) is reported when authentication succeeded but the tokens could not be written because JS interop was unavailable (SSR prerender, or a render-mode transition), andMissingAccessTokenCode = "Auth.MissingAccessToken"(AuthUIService.cs:55) covers a 2xx whose body carried no access token, which means the response shape drifted. Both areconst string, so tests and pages branch on them without duplicating literals. The privateApiClientName = "APIClient"(AuthUIService.cs:57) names the shared client registered inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:84. LoginAsyncandRegisterAsyncare one-liners over the privateAuthenticateAsync, differing only in the relative URL,auth/loginandauth/register(AuthUIService.cs:60-65).ExchangeOAuthCodeAsync(AuthUIService.cs:68) guards the code client-side first: a blank code returnsError.Validation("Auth.OAuth.MissingCode", ...)without a round trip (AuthUIService.cs:70-74), then it posts an OAuthCodeExchangeRequest toauth/oauth/exchange(AuthUIService.cs:76). The single-use code arrives in the redirect URL, which is what keeps the tokens themselves out of the address bar (ADR-036,Website/docs-src/adr/036-external-oauth-login.md).AuthenticateAsync(AuthUIService.cs:262) is the shared body of all three. It posts the credential through HttpResultExecutor and reads the response withProblemDetailsResultReader.ReadAsync<AuthenticationResponse>(AuthUIService.cs:267-274), returns early on failure (273-276), then checks the access token is actually present and fails withMissingAccessTokenCodeif it is not (279-283). Only then does it calltokenStorageService.SetTokensAsyncinside atrythat converts anInvalidOperationExceptioninto aTokenStorageUnavailableCodefailure carrying the exception message as detail (285-298). The point of that branch is that valid credentials nothing can hold are a failure, not a silent no-op. Finally it pattern-matches the injectedAuthenticationStateProviderdown to JwtAuthenticationStateProvider and callsNotifyUserAuthentication(accessToken)(300-303, andMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtAuthenticationStateProvider.cs:60). Theistest rather than a cast is what lets a host register a different provider without breaking sign-in.LogoutAsync(AuthUIService.cs:80) runs four steps, each isolated so a failure cannot stop the next. FirstpushRegistration.UnregisterAsync()inside a barecatch(82-91): the Devices DELETE is authenticated, so it has to happen while the access token is still valid, and it is a no-op on web heads (ADR-044,Website/docs-src/adr/044-native-push-delivery.md). Second, if a token can be read, a Bearer header is attached andauth/revokeis posted, again inside acatch(93-113). Third,tokenStorageService.ClearTokensAsync()undercatch (InvalidOperationException)for the interop-unavailable case (115-122). Fourth,readCache?.Clear()(127) andNotifyUserLogout()(129-132, and.../Services/Auth/JwtAuthenticationStateProvider.cs:71). The two#pragma warning disable CA1031blocks (86-88,107-109) are deliberate and annotated in place: catching everything is the correct policy for a best-effort cleanup step.TryRefreshTokenAsync(AuthUIService.cs:139) makes no HTTP call of its own. It askstokenRefresher.AcquireAccessTokenAsyncfor a token (141); browser hosts renew through the same-origin cookie proxy so the refresh token never reaches JS (SameOriginProxyTokenRefresher) and MAUI renews straight from secure storage (DirectApiTokenRefresher). A null or blank answer means the session is gone, which this method treats exactly like a sign-out: clear tokens, clear the read cache, notify logout, returnfalse(143-164). A token that comes back is published withNotifyUserAuthenticationand answered withtrue(166-171). Theboolreturn is the honest type here, because neither outcome is an error a page would render (IAuthUIService.cs:41-47).- The password trio all wrap HttpResultExecutor.
ChangePasswordAsync(175) is the only one that authenticates: it builds a ChangePasswordRequest andPUTs it toauth/password(179-188).RequestPasswordResetAsync(191) andResetPasswordAsync(208) deliberately use the plain factory client with no Bearer header (197,216), because a reset must not be bound to whatever session happens to be open. The comment at201-202records the contract that matters:auth/forgot-passwordanswers 202 for every well-formed address, so a success never means "this account exists". - The two session methods back the devices page.
GetSessionsAsync(226)GETsauth/my-sessionsand reads it with the generic reader intoIReadOnlyList<RefreshSessionSummaryResponse>(234-235).RevokeSessionAsync(240) postsauth/revoke/{sessionId}and reads it with the non-generic reader (249-250), because that endpoint answers 204 and ProblemDetailsResultReader's generic overload turns an empty 2xx body into anEmptyResponseCodefailure (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:274-279). Picking the wrong overload there would turn every successful revoke into an error. - Two private helpers close the class.
CreateAuthenticatedClientAsync(314) creates the APIClient and setsDefaultRequestHeaders.Authorizationfrom the stored token; its doc comment states plainly that it mirrors AuthenticatedServiceBase and cannot inherit it, because this is not an entity service and takes a different dependency set (308-313).ReadAccessTokenAsync(327) swallowsInvalidOperationExceptionfrom the store and returnsnull, so an SSR prerender proceeds tokenless and lets the API answer 401 like any other failure (333-338).
- Two public error codes head the class.
- Why it's built this way: ADR-051 (
Website/docs-src/adr/051-client-auth-token-lifecycle.md) is the record behind the split visible in the constructor: storage, renewal and orchestration are three different abstractions because each render mode (SSR prerender, Blazor Server, WebAssembly, MAUI) can hold and renew a credential differently, while the orchestration above them stays identical. The refresh path itself is server-side rotation with reuse detection (ADR-050,Website/docs-src/adr/050-jwt-refresh-token-rotation.md), generalized to one row per device by ADR-097 (Website/docs-src/adr/097-multi-device-refresh-sessions.md), which is what givesGetSessionsAsyncandRevokeSessionAsyncsomething to list and revoke. Browser hosts keep the refresh token in an HttpOnly cookie rather than in reachable storage (ADR-022,Website/docs-src/adr/022-browser-session-cookie-auth.md), which is exactly whyTryRefreshTokenAsyncdelegates instead of calling a refresh endpoint itself. ReturningResultinstead of throwing keeps every auth failure renderable: the API's Problem Details payload already carries the server's own wording and ErrorType, so the page shows that rather than a client-invented message. - Where it's used: registered
TryAddScoped<IAuthUIService, AuthUIService>()inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:113, so every host that adds the shared UI gets it. Consumers inside the shared UI are the auth pages (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:204,Pages/Auth/Register.razor:161,Pages/Auth/OAuthComplete.razor:65,Pages/Auth/ForgotPassword.razor:81,Pages/Auth/ResetPassword.razor:122), the devices page Sessions (Pages/Auth/Sessions.razor.cs:79,119,165), and both shells (Layout/MainLayout.razor:108,Layout/NavMenu.razor:185, which callLogoutAsync). Downstream, the ADC and Store profile pages callChangePasswordAsync(Profile,MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/Profile.razor.cs:220andMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.UI/Pages/Profile/Profile.razor.cs:252). Note howForgotPassword.razorconsumes it: it discards theResultand swallows exceptions (Pages/Auth/ForgotPassword.razor:75-92), because the page must look identical whether or not the address exists. The component gallery substitutes NoOpAuthUIService, and AuthUIServiceTests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/AuthUIServiceTests.cs:36) pins the behavior end to end, including the storage-unavailable and missing-token branches (AuthUIServiceTests.cs:212,229). - Caveats:
TryRefreshTokenAsynchas no production call site in the workspace; the only callers are AuthUIServiceTests (AuthUIServiceTests.cs:442,454) and the gallery stub. Renewal in a running app happens further down, inside the storage service and the refreshers, so this method is a public entry point that nothing currently enters. Theauth/revokecall inLogoutAsyncis described in the comment as "fire-and-forget" but is in fact awaited (AuthUIService.cs:108); the accurate reading is best-effort-and-ignored, and on a bad network the awaited call can add its full timeout to a sign-out. That same call passes noCancellationToken, becauseLogoutAsynctakes none by design (IAuthUIService.cs:39). Finally,CreateAuthenticatedClientAsyncsets aDefaultRequestHeadersBearer while AuthDelegatingHandler is already attaching one to every APIClient request from the same store (DependencyInjection.cs:105,Services/Auth/AuthDelegatingHandler.cs:17-21), so the header is computed twice per authenticated call; both values come from the same source, so this is redundancy rather than a defect.
CultureDelegatingHandler
MMCA.Common.UI ·
MMCA.Common.UI.Services.Culture·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/CultureDelegatingHandler.cs:13· Level 0 · class (sealed)
- What it is: a one-method
DelegatingHandlerthat stamps the active UI culture onto every outgoing API call as anAccept-Languageheader, so validation and error text come back from the backend in the language the user selected. - Depends on: no first-party types. Externals:
System.Net.Http.DelegatingHandler,System.Globalization.CultureInfo, andSystem.Net.Http.Headers.StringWithQualityHeaderValue. It rides the same named"APIClient"pipeline as AuthDelegatingHandler. - Concept introduced, culture as a transport header rather than only a cookie.
[Rubric §27, Internationalization]assesses whether locale is carried end to end instead of being applied only at the rendering edge; this handler is the piece that closes that loop for server-produced strings. The class comment (CultureDelegatingHandler.cs:7-11) states the reason plainly: the cross-origin Gateway does not carry the ASP.NET culture cookie through to the services, so a cookie-only design would render the page in Spanish while the API answered in English.[Rubric §6, CQRS & Event-Driven Design]also applies, because this is a concern every service call needs and no service call implements: it is attached once in the HttpClient pipeline instead of at each call site. - Walkthrough
- The only member is the
SendAsyncoverride (CultureDelegatingHandler.cs:16). - It reads
CultureInfo.CurrentUICulture.Name(CultureDelegatingHandler.cs:20) and does nothing when that is blank (CultureDelegatingHandler.cs:21), so an unresolved culture sends no header rather than an empty one. - When there is a value it calls
AcceptLanguage.Clear()beforeAdd(...)(CultureDelegatingHandler.cs:23-24); the clear matters because a retried request object would otherwise accumulate a second language entry. - It returns
base.SendAsync(request, cancellationToken)directly (CultureDelegatingHandler.cs:27) rather than awaiting it, so the handler adds no async state machine to the hot path.
- The only member is the
- Why it's built this way: ADR-027
makes multi-locale a whole-stack concern. Registering the behavior as a message handler means the
culture travels on calls made by code that has never heard of localization. It is registered
transient in DependencyInjection
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:82) and appended to the"APIClient"pipeline after the auth handler (DependencyInjection.cs:105-106). - Where it's used: every request through the
"APIClient"named client, which is every call made by EntityServiceBase<TEntityDTO, TIdentifierType>, ChildEntityServiceBase, ApiUserPreferenceReader and ApiUserPreferenceWriter. - Caveats / not-in-source: it reads whatever ambient UI culture the head has already established. On a Blazor WebAssembly head that is set by MmcaCultureBootstrap before the host runs; the handler itself makes no attempt to resolve or validate a culture.
ICultureApplier
MMCA.Common.UI ·
MMCA.Common.UI.Services.Culture·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/ICultureApplier.cs:14· Level 0 · interface
- What it is: the one-method contract for "switch the language and put the user back where they were", written as an abstraction because the mechanism differs per head.
- Depends on: nothing. Implemented by EndpointCultureApplier (the Blazor Web default) and by MauiCultureApplier in the device-capability layer.
- Concept introduced, a terminal call.
[Rubric §18, UI Architecture]assesses whether host-specific mechanics are hidden behind contracts the components can share; this interface is the clearest example in the UI package. The doc comment (ICultureApplier.cs:3-13) spells out both halves of the contract: a Blazor Web head round-trips the server/culture/setendpoint so the cookie, the SSR prerender and the WASM runtime all agree, while a MAUI Blazor Hybrid head has no ASP.NET pipeline and switches the process culture in place. Because each implementation owns landing the user back on the return path (a redirect on the web, a WebView reload on a hybrid head), callers must treatApplyAsyncas terminal and do no navigation of their own.[Rubric §25, Navigation & Information Architecture]applies for the same reason: navigation ownership is part of the contract rather than an afterthought at each call site. - Walkthrough
ApplyAsync(string culture, string returnPath, CancellationToken cancellationToken = default)(ICultureApplier.cs:27) is the whole surface.- Two documented behaviors belong to the contract rather than to any one implementation: a culture
outside
SupportedCultures.Allis ignored by the underlying mechanism rather than throwing (ICultureApplier.cs:19-22), and an emptyreturnPathfalls back to"/"(ICultureApplier.cs:23-25).
- Why it's built this way:
ADR-027. Hard-coding the endpoint
navigation into the culture switcher component would have made that component unusable on MAUI,
where the URL matches no route and the Blazor
Routerrenders the not-found page. The interface lets one shared component serve both heads. - Where it's used: injected by the shared
CultureSwitchercomponent, which persists the choice first and then treats the applier call as the last thing it does (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/CultureSwitcher.razor:6andCultureSwitcher.razor:44-48), and by the login page when reconciling a returning user's stored culture (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:15andLogin.razor:239-244).
ISecureTokenStore
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ISecureTokenStore.cs:16· Level 0 · interface
- What it is: raw token persistence with no freshness semantics. It reads back exactly what was
written and never triggers a refresh, which is what separates it from the storage contract callers
actually consume,
ITokenStorageService. - Depends on: nothing first-party. Implemented by
MauiSecureTokenStoreover OS SecureStorage; consumed byDirectApiTokenRefresherand byMauiTokenStorageService. - Concept introduced, splitting storage from freshness to keep the graph acyclic. This interface
exists for a dependency-graph reason that the source states in full
(
ISecureTokenStore.cs:4-9):ITokenStorageService, the layer callers consume, depends onITokenRefresher; the refresher in turn depends on this raw store. The chain runs storage, then refresher, then raw store, with no loop. Collapse the two storage interfaces into one and the refresher would depend on the very acquisition that invoked it, which is a re-entrancy hazard at runtime, not just a diagram problem.[Rubric §1, SOLID]assesses whether a single interface has one reason to change. Here two responsibilities that look identical from the outside (read a token, write a token) are separated precisely because one of them is allowed to go to the network and the other is not.[Rubric §11, Security]assesses where credentials rest. The remarks record that only hosts which persist tokens themselves implement it: MAUI backs it with OS SecureStorage, while the browser hosts hold the access token in memory and keep the refresh token in an HttpOnly cookie, so they have no raw store to expose (ISecureTokenStore.cs:10-14).
- Walkthrough: four members, all verbatim.
GetAccessTokenAsync()reads the stored access token ornull(ISecureTokenStore.cs:19).GetRefreshTokenAsync()does the same for the refresh token (line 22).SetTokensAsync(accessToken, refreshToken)persists both, replacing whatever was there (line 25).ClearTokensAsync()removes both, the logout path (line 28).
- Why it's built this way: the interface is deliberately dumber than the one above it. Because it promises no freshness, an implementation can be a thin wrapper over a platform API with no policy, and the single-flight hydration policy lives once, higher up, in the storage services.
- Where it's used: registered on the MAUI head only,
services.AddScoped<ISecureTokenStore, MauiSecureTokenStore>()(MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:99, with the rationale at lines 66-77). Injected intoDirectApiTokenRefresher(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/DirectApiTokenRefresher.cs:21) and mocked directly inDirectApiTokenRefresherTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/DirectApiTokenRefresherTests.cs:31). - Caveats / not-in-source: no browser host implements it. Resolving it on a Blazor Server or WASM head is a DI failure by design, not an oversight.
ITokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ITokenRefresher.cs:13· Level 0 · interface
- What it is: a one-method contract for acquiring a fresh JWT access token, abstracting over the fact that each host holds its refresh credential somewhere completely different.
- Depends on: nothing first-party. Implemented by
SameOriginProxyTokenRefresheron the browser heads andDirectApiTokenRefresheron MAUI; consumed byWasmTokenStorageService,ServerTokenStorageService,MauiTokenStorageServiceandAuthUIService. - Concept introduced, one contract over two materially different security models. The interface
doc enumerates both (
ITokenRefresher.cs:4-11): on the browser the refresh token lives in an HttpOnly cookie and rotation happens server-side behind the same-origin/auth/session/tokenendpoint, so the refresh token is never exposed to JS; on MAUI the refresh token sits in OS SecureStorage and is exchanged directly against the API's cross-originauth/refresh.[Rubric §11, Security]assesses whether the strongest available mechanism is used per platform. A browser has an XSS surface and gets the cookie proxy; a native app has no DOM and gets direct token handling. The abstraction is what lets both be correct without a shared lowest common denominator.[Rubric §7, Microservices Readiness]shows in the fact that the two implementations talk to two different origins (the UI host for one, the API for the other) behind one signature.
- Walkthrough: a single member,
Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken = default)(ITokenRefresher.cs:20). The nullable return is the whole error model:nullmeans no valid session exists, whether the refresh credential is missing, expired, or revoked (lines 15-19). There is no exception path a caller has to know about. - Why it's built this way: null-rather-than-throw matches how the callers use it. Token storage
calls this on a hot path (every outgoing request may hydrate) and "the session is gone" is an
ordinary outcome, not an exceptional one. Modelling it as an exception would force a
tryaround every hydrate. - Where it's used: registered per host,
AddScoped<ITokenRefresher, SameOriginProxyTokenRefresher>()on the Blazor Server and WASM heads (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:115,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:45) andAddScoped<ITokenRefresher, DirectApiTokenRefresher>()on MAUI (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:97).
ITokenStorageService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ITokenStorageService.cs:8· Level 0 · interface
- What it is: the platform-agnostic token contract every other UI type depends on. Anything that needs a bearer token asks this, and never asks where the token is kept.
- Depends on: nothing first-party. Implemented by
WasmTokenStorageService,ServerTokenStorageServiceandMauiTokenStorageService. - Concept introduced, the freshness-checking storage layer. It shares its four member signatures
with
ISecureTokenStore, and the difference is entirely in the promise: this one may go and acquire a token, the raw store may not. Reading the two interfaces side by side is the fastest way to understand the auth layering in this package.[Rubric §3, Clean Architecture]assesses whether presentation code depends on abstractions rather than platform APIs. Nothing above this line namesSecureStorage,HttpContext, or a cookie.[Rubric §11, Security]assesses the storage decision itself, and the doc makes it explicit: browser hosts hold the access token in memory and mirror the refresh token to an HttpOnly cookie, neverlocalStorage; MAUI uses OS SecureStorage (ITokenStorageService.cs:3-7).
- Walkthrough:
GetAccessTokenAsync()(ITokenStorageService.cs:11),GetRefreshTokenAsync()(line 14),SetTokensAsync(accessToken, refreshToken)called after a successful login or refresh (line 17), andClearTokensAsync()for logout (line 20). - Why it's built this way: four methods is the smallest surface that covers the whole client auth lifecycle, and keeping it free of any freshness parameter means the policy (skew, single-flight) belongs to the implementation, where it can differ per host.
- Where it's used: injected into
AuthDelegatingHandler,JwtAuthenticationStateProvider,AuthUIService,AuthenticatedServiceBase(which uses it for the direct-token path that bypasses the delegating handler,MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:47) andNotificationHubServicefor the SignalR access-token provider. Test hosts substituteStubTokenStorageServiceandNullTokenStorageService(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Gallery/Stubs/NullTokenStorageService.cs:8).
JwtTokenInfo
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/JwtTokenInfo.cs:9· Level 0 · class (static)
- What it is: one static predicate,
IsFresh, that answers whether a cached access token is still worth using or whether the caller should go and re-acquire one. - Depends on: nothing first-party. Externals:
System.IdentityModel.Tokens.Jwt(JwtSecurityTokenHandler) andDateTime.UtcNow. - Concept introduced, unvalidated client-side token inspection. Reading a JWT without checking its
signature looks alarming until you see what the answer is used for: it decides whether to call the
refresher, nothing else. The class doc states the boundary plainly, no signature validation because
the API validates every request (
JwtTokenInfo.cs:5-7).[Rubric §11, Security]assesses where trust decisions are made. A forged token that passed this check would still be rejected by the API on the first call, so the client-side read is an optimization, not an authorization.[Rubric §12, Performance & Scalability]assesses avoidable round trips. Without this check every outgoing request would have to hydrate; with it, a live token short-circuits the whole refresh path.
- Walkthrough:
IsFresh(string? token, TimeSpan skew)(JwtTokenInfo.cs:16) runs four guards and returnsfalseon all of them, so every uncertain case biases toward refreshing:- a null, empty, or whitespace token (lines 18-21);
- a token
JwtSecurityTokenHandler.CanReadTokenrejects, meaning it is not a readable JWT (lines 23-27); - otherwise it reads the token and compares
ValidTo > DateTime.UtcNow + skew(line 31), so the skew is a proactive margin: the token is called stale slightly before it truly expires; - and a parse blowing up as
ArgumentExceptionorFormatExceptionis caught and reported as not fresh (lines 33-36).
- Why it's built this way: a static pure function of two arguments is trivially testable and has no lifetime to manage, and the deliberate fail-to-false makes every failure mode converge on the one safe action (refresh). The exception filter is narrow on purpose: only the two malformed-input types are swallowed, so a genuinely unexpected failure still surfaces.
- Where it's used:
WasmTokenStorageService.GetAccessTokenAsyncwith a 30-second skew (WasmTokenStorageService.cs:15,24), and the same pattern inServerTokenStorageService(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:24).
IUiReadCache
MMCA.Common.UI ·
MMCA.Common.UI.Services.Caching·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Caching/IUiReadCache.cs:32· Level 0 · interface
- What it is: the contract for a per-circuit read-through cache sitting in front of the API client, so a page that re-reads the same list twice within a few seconds (a grid re-mounted by navigation, a lookup rendered in two components) does not pay for two round trips.
- Depends on: nothing first-party in its signature. Its freshness policy lives in
UiReadCacheOptions (named in the doc at
IUiReadCache.cs:17); its only framework implementation is UiReadCache. Externals:System.Diagnostics.CodeAnalysisfor the analyzer suppression at lines 28-31. - Concept introduced, a two-tier cache whose key shape is deliberately shared with the server.
[Rubric §23, Front-End Performance]assesses avoidable network work in the browser, and[Rubric §12, Performance & Scalability]the same question for the API behind it. The load-bearing design decision is not that a cache exists, it is what a key is: the relative URL, path plus the full query string, used verbatim (IUiReadCache.cs:9-15). That is the same key shape the server's authenticated output cache uses, whose policy setsCacheVaryByRules.QueryKeys = "*"so every query-string variant is its own entry (ADR-040). Mirroring the shape means the two tiers agree on what "the same read" is: a filter, page or sort change misses on both sides rather than being served a stale answer by one of them.[Rubric §26, Front-End Security]and[Rubric §30, Compliance, Privacy & Data Governance]both land onClear(). The cache is registered scoped, which is one instance per Blazor Server circuit but one per app lifetime on WebAssembly and MAUI, where the scope outlives a sign-out. The contract therefore states that the sign-out path callsClearso one account's reads can never be served to the next (IUiReadCache.cs:22-26,61-65).[Rubric §29, Resilience & Business Continuity]shows in the storage rule: only successful reads are stored, so a transient outage cannot pin an error in front of the user (IUiReadCache.cs:19-20).- Why the parameter is a
stringand not aUri. TheCA1054suppression (lines 28-31) is worth reading as a small design argument: the parameter is a cache key that happens to be spelled as a relative URL, it is compared by ordinal prefix and stored verbatim, andSystem.Uriwould re-encode and re-normalize it, which is exactly what must not happen to a key when the point is matching the server's key byte for byte.
- Walkthrough: four members.
bool TryGetFresh<T>(string url, out T? value)(line 42) reports a fresh hit; a miss, an expired entry, or a disabled cache all read asfalse, and the doc notes that a hit stored under a different type also reads as a miss (line 37).void Set<T>(string url, T value)(line 51) stores a successfully read value stamped with the current time, and is a no-op when caching is disabled. The doc is explicit that only success values are ever passed here (line 50).void InvalidatePrefix(string routePrefix)(line 59) drops every entry whose key starts with the prefix, ordinally. That is how one endpoint's create, update or delete clears that endpoint's list, paged, lookup and by-id entries in a single call (lines 53-58).void Clear()(line 66) drops everything, for the sign-out case above.
- Why it's built this way: an interface (rather than a concrete helper) is what lets the whole
feature be optional. Both consumers take
IUiReadCache?with anulldefault, so a host that registers nothing gets exactly the plain GET the read methods issued before a cache existed (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:47,MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:43). Prefix invalidation rather than per-key invalidation matches how the framework's endpoints are shaped: one resource owns one route prefix, so a write knows what it invalidated without enumerating the reads. - Where it's used: registered
TryAddScopedagainst UiReadCache byAddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:61). Read through by EntityServiceBase<TEntityDTO, TIdentifierType>'sGetCachedAsync(EntityServiceBase.cs:248-268) and invalidated by itsInvalidateOnSuccess(EntityServiceBase.cs:281-287); cleared on sign-out and on an unrefreshable session byAuthUIService(AuthUIService.cs:130,159). - Caveats / not-in-source: nothing here is shared between users or between tabs. It is an in-memory per-scope cache, so a second browser tab on WebAssembly has its own instance and its own entries.
EndpointCultureApplier
MMCA.Common.UI ·
MMCA.Common.UI.Services.Culture·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/EndpointCultureApplier.cs:18· Level 1 · class (sealed)
- What it is: the Blazor Web implementation of ICultureApplier. It
force-navigates to the server's
GET /culture/setendpoint, which writes the culture cookie and redirects the user back to where they were. - Depends on: ICultureApplier (implemented) and
Microsoft.AspNetCore.Components.NavigationManager. It pairs with the server endpoint mapped byMapCultureEndpoint()(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:102, theMapGet("/culture/set", ...)atWebApplicationExtensions.cs:107) and with MmcaCultureBootstrap on the WASM side. - Concept introduced, why the full page reload is deliberate.
[Rubric §27, Internationalization]assesses whether a locale switch is coherent across every rendering path; the class comment (EndpointCultureApplier.cs:8-10) says the force-load is load-bearing, because the server has to re-render SSR under the new cookie and the WASM runtime has to re-read it on startup, which keeps prerender and hydration on the same culture. A soft, client-only switch would leave the two disagreeing and show a locale flash on the next full load. - Walkthrough
ApplyAsync(EndpointCultureApplier.cs:21) rejects a null or whitespace culture up front withArgumentException.ThrowIfNullOrWhiteSpace(EndpointCultureApplier.cs:23).- It falls back to
"/"for an empty return path (EndpointCultureApplier.cs:25) and builds the URL withUri.EscapeDataStringon both values (EndpointCultureApplier.cs:26), so a return path containing a query string survives round-tripping. - It then calls
navigation.NavigateTo(url, forceLoad: true)(EndpointCultureApplier.cs:30) and returnsTask.CompletedTask(EndpointCultureApplier.cs:31): the method is synchronous in substance andTask-shaped only because the interface must also fit asynchronous heads. - The inline comment (
EndpointCultureApplier.cs:28-29) notes that validating the culture is the endpoint's job: an unsupported value lands the user back on the same page unchanged rather than failing.
- Why it's built this way:
ADR-027. The class comment
(
EndpointCultureApplier.cs:11-15) also records the boundary of its validity: a head with no ASP.NET pipeline would route/culture/setthrough the BlazorRouter, match no page and render the not-found page, which is exactly why MAUI heads register their own applier afterAddUIShared. - Where it's used: registered as the default with
TryAddScoped<ICultureApplier, EndpointCultureApplier>()(DependencyInjection.cs:121), with the comment above it (DependencyInjection.cs:117-120) recording that a hybrid head overrides it afterwards. The MAUI replacement is MauiCultureApplier.
MmcaCultureBootstrap
MMCA.Common.UI ·
MMCA.Common.UI.Services.Culture·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/MmcaCultureBootstrap.cs:14· Level 1 · class (static)
- What it is: the Blazor WebAssembly culture bootstrap. It reads the same ASP.NET culture cookie the server used for SSR prerender and sets the WASM runtime's default thread cultures before the host starts running.
- Depends on: SupportedCultures from the
Shared layer and the
culture.jsmodule in the package'swwwroot. Externals:IJSRuntimeandCultureInfo. - Concept introduced, closing the prerender/hydration culture gap.
[Rubric §27, Internationalization]assesses whether every rendering path resolves the same locale; the class comment (MmcaCultureBootstrap.cs:7-13) states the outcome this buys: the interactive client renders in the same language the server prerendered, with no locale flash and no prerender/hydration mismatch.[Rubric §23, Front-End Performance]applies too, because the alternative (letting the client discover the culture after first render) costs a visible re-render of the whole page. - Walkthrough
- One method,
SetBrowserCultureAsync(IJSRuntime jsRuntime)(MmcaCultureBootstrap.cs:22), null-guarded atMmcaCultureBootstrap.cs:24. - It imports
./_content/MMCA.Common.UI/culture.jsunder anawait using(MmcaCultureBootstrap.cs:26-27), so the module reference is released as soon as the one call is done: unlike the long-lived services, this runs once at startup and has no reason to hold it. - It calls
getCulture(MmcaCultureBootstrap.cs:28), whose JS side parses the.AspNetCore.Culturecookie'suic=segment and returns null when the cookie is absent or unparseable (MMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/culture.js:4). - The returned value is filtered through
SupportedCultures.IsSupportedand falls back toSupportedCultures.Defaultotherwise (MmcaCultureBootstrap.cs:30; the predicate is atMMCA.Common/Source/Core/MMCA.Common.Shared/Globalization/SupportedCultures.cs:35, the allowlist atSupportedCultures.cs:18, and the"en-US"default atSupportedCultures.cs:12). - It then assigns only
CultureInfo.DefaultThreadCurrentCultureandCultureInfo.DefaultThreadCurrentUICulture(MmcaCultureBootstrap.cs:32-33), neverCurrentCulture/CurrentUICulturedirectly: setting the defaults makes every subsequently created thread inherit the culture, which is what a later switch needs in order to take effect.
- One method,
- Why it's built this way:
ADR-027. The cookie is deliberately
non-HttpOnly for exactly this reader, a decision recorded in the suppression comment on the server
side (
MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:111, written atWebApplicationExtensions.cs:120), which namesMmcaCultureBootstrap.SetBrowserCultureAsyncas the consumer. The doc comment (MmcaCultureBootstrap.cs:11-12) also pins the call ordering: it must run afterbuilder.Build()and beforehost.RunAsync(). - Where it's used: both Blazor Web clients call it exactly as documented:
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:88andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:69. Its MAUI counterpart is MauiCultureInitializer.
DirectApiTokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/DirectApiTokenRefresher.cs:19· Level 1 · class (sealed)
- What it is: the MAUI
ITokenRefresher. It reads the token pair out of OS SecureStorage, exchanges it against the API's cross-originauth/refreshendpoint, and writes the rotated pair back. - Depends on:
ISecureTokenStore(deliberately, notITokenStorageService),RefreshTokenRequestandAuthenticationResponsefromMMCA.Common.Shared.Auth; externalsIHttpClientFactoryandSystem.Net.Http.Json. - Concept introduced, direct token handling where there is no DOM. The class doc justifies the
choice: MAUI has no browser and thus no XSS surface, so holding a refresh token client-side and
posting it is acceptable there in a way it is not in a browser
(
DirectApiTokenRefresher.cs:7-10). This is the counterpart toSameOriginProxyTokenRefresher, and reading the two together is the clearest statement of the framework's per-platform threat model.[Rubric §11, Security]assesses per-platform credential handling, exactly as above.[Rubric §1, SOLID], dependency direction: the second doc paragraph is unusually explicit (lines 10-16). Every operation it performs is a raw read or write, so it takes the raw store; takingITokenStorageServiceinstead would close the loop and let a refresh re-enter the acquisition that started it.
- Walkthrough:
AcquireAccessTokenAsync(cancellationToken)(DirectApiTokenRefresher.cs:25) is a straight line of early returns, each producingnullrather than an exception:- read both tokens from the store (lines 26-27), and bail when either is blank (lines 29-32), so a never-logged-in device costs one storage read and no network call;
- create the named
"APIClient"(ApiClientName, line 22) and POST aRefreshTokenRequestcarrying both tokens to the relativeauth/refresh(lines 34-36); - bail on any non-success status (lines 38-41), which is how a revoked or reused refresh token arrives;
- deserialize an
AuthenticationResponseand bail when the access token came back blank (lines 43-47); - persist the rotated pair through
ISecureTokenStore.SetTokensAsyncand return the new access token (lines 49-50).
- Why it's built this way: the rotation-on-refresh shape it participates in is
ADR-097, which made
refresh sessions hashed, rotating and per device: writing the returned pair back is not an
optimization, it is required, because the old refresh token is dead after the exchange. The
using var httpClient(line 34) and the relativeUriboth lean on the named client configured once inAddUIShared(DependencyInjection.cs:81-102). - Where it's used: registered on the MAUI head,
AddScoped<ITokenRefresher, DirectApiTokenRefresher>()(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:97). Covered byDirectApiTokenRefresherTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/DirectApiTokenRefresherTests.cs:17), which mocks the store and the HTTP handler (lines 22-34).
SameOriginProxyTokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/SameOriginProxyTokenRefresher.cs:11· Level 1 · class (sealed)
- What it is: the browser
ITokenRefresher, used by both Blazor Server and WebAssembly. It asks a JS helper to POST the same-origin/auth/session/tokenendpoint and returns whatever access token comes back. - Depends on:
ITokenRefresher(the contract) andIJSRuntime, plus themmcaAuthSessionhelper inMMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/mmca-auth-cookie.js:32, whosegetTokenissues thefetch(line 35). - Concept introduced, the BFF hop. The refresh token never enters this process. The browser sends
its HttpOnly cookies with
credentials:'same-origin', the UI host validates or refreshes server-side, and only the access token comes back over the wire (SameOriginProxyTokenRefresher.cs:5-9). The server half isSessionCookieEndpoints, which mapsPOST /auth/session/token(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:45).[Rubric §26, Front-End Security]assesses whether a long-lived credential is reachable from scripts. It is not: an XSS on this page can steal an access token that expires in minutes, not the refresh token behind it.[Rubric §7, Microservices Readiness]shows in the origin choice. The call goes to the UI host, not the API, so it stays same-origin and needs no CORS or cross-site cookie policy.
- Walkthrough: the entire class is one method.
AcquireAccessTokenAsync(cancellationToken)(SameOriginProxyTokenRefresher.cs:13) invokesmmcaAuthSession.getTokenthrough interop (line 17) and normalizes a blank result tonull(line 18). The same four interop-unavailable exception typesJsFetchSessionCookieSyncfilters on are caught here inline (line 20) and reported asnull, with the comment recording that the server-side cookie path already covers the prerender and disconnected phases (lines 22-24). - Why it's built this way: a proxy hop instead of a direct API call is the decision recorded in
ADR-022, including the
SameSite=LaxplusSec-Fetch-Sitecheck that hardens the refresh endpoint. Returningnullrather than throwing satisfies theITokenRefreshercontract, so token storage never has to distinguish "no session" from "interop not ready". - Where it's used: registered on both browser heads
(
MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:115andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:45); consumed indirectly byWasmTokenStorageServiceandServerTokenStorageService. Covered bySameOriginProxyTokenRefresherTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/SameOriginProxyTokenRefresherTests.cs:14).
WasmTokenStorageService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/WasmTokenStorageService.cs:11· Level 1 · class (sealed)
- What it is: the WebAssembly
ITokenStorageService. The access token lives in memory only and is hydrated on demand from the HttpOnly cookies; there is nolocalStorageanywhere in it. - Depends on:
ISessionCookieSyncandITokenRefresher(both constructor parameters,WasmTokenStorageService.cs:11-13), plusJwtTokenInfofor the freshness check; externalsSystem.Threading.Lock. - Concept introduced, single-flight hydration. Several callers can ask for a token in the same
instant: the
AuthDelegatingHandleron an outgoing request, theJwtAuthenticationStateProviderduring a render, andNotificationHubService's access-token provider. Without coordination each would start its own refresh, and the last one to finish would overwrite the others' token. The fix is to share one in-flightTask, and the source is explicit that the lock, not a??=, is what makes it single (lines 29-32).[Rubric §19, State Management & Data Flow]assesses ownership of shared per-circuit state; the in-memory token and its in-flight hydration are exactly that.[Rubric §12, Performance & Scalability]assesses redundant work: a burst of concurrent callers costs one network round trip, not one each.[Rubric §11, Security]assesses at-rest exposure. The refresh token is never held client-side, the comment at line 58 saying it lives only in the HttpOnly cookie.
- Walkthrough:
ExpirySkew, 30 seconds (WasmTokenStorageService.cs:15), the proactive margin handed toJwtTokenInfo.IsFresh._hydrateSync(line 17), theLock;_accessToken(line 19), the in-memory token; and_hydrateInFlight(line 20), the shared hydration task.GetAccessTokenAsync()(line 22) returns the cached token immediately when it is fresh (lines 24-27). Otherwise it takes the lock only long enough to publish or read the in-flight task (lines 33-38), which is safe becauseHydrateAsyncreaches its firstawaitimmediately so nothing slow runs under the lock (line 32). It then awaits the shared task (line 42) and, in afinally, clears_hydrateInFlightonly if it is still the same task (ReferenceEquals, lines 48-54). That guard is the subtle half: an unguarded clear can drop a newer hydrate started after this one completed, splitting the next set of callers all over again (lines 46-47).GetRefreshTokenAsync()(line 59) returnsnullunconditionally, an honest answer rather than a stub: in the browser there is nothing to return.SetTokensAsync(accessToken, refreshToken)(line 61) stores the access token in memory and seeds the HttpOnly cookies throughISessionCookieSync.SyncAsync(line 66); the comment records that the refresh token transits JS only for that same-origin POST (lines 64-65).ClearTokensAsync()(line 69) nulls the field and clears the cookies (lines 71-72).HydrateAsync()(line 75) is one line of work: callITokenRefresher.AcquireAccessTokenAsync, store the result, return it (lines 77-78).
- Why it's built this way: the class doc records that it was hoisted out of the app WASM clients
because it carries no app-specific state, and names its Blazor Server sibling
ServerTokenStorageServicein MMCA.Common.UI.Web (lines 3-10). The two share the skew constant, theLock, and the same single-flight shape; the server one adds anHttpContextbranch for the prerender pass. The cookie-only storage model is ADR-022. - Where it's used: registered on the WASM client,
AddScoped<ITokenStorageService, WasmTokenStorageService>()(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:44). Covered byWasmTokenStorageServiceTests, which drives the concurrency path directly (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/WasmTokenStorageServiceTests.cs:16,104).
UiReadCache
MMCA.Common.UI ·
MMCA.Common.UI.Services.Caching·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Caching/UiReadCache.cs:18· Level 1 · class (internal, sealed)
- What it is: the default IUiReadCache: an in-memory dictionary keyed by the relative request URL, guarded by a lock, with lazy TTL expiry and a longest-prefix TTL lookup.
- Depends on: IUiReadCache (the contract it implements) and
UiReadCacheOptions (the staleness policy, taken as
IOptions<UiReadCacheOptions>and unwrapped once at construction,UiReadCache.cs:27). Externals: BCLTimeProvider,System.Threading.Lock,Dictionary<string, (object, DateTimeOffset)>, andMicrosoft.Extensions.Options. - Concept introduced, lazy expiry and why there is no sweeper. The remarks state the reasoning
directly (
UiReadCache.cs:11-15): an entry past its TTL is removed when it is next read, not by a timer, because a UI cache holds tens of entries for the life of a circuit, so a sweeping timer would cost more than the entries it reclaims, and a stale entry that is never read again is never served either.[Rubric §23, Front-End Performance]assesses exactly this kind of trade: the cheapest correct expiry policy for the actual entry count.- Why a lock at all. Circuit code is not single-threaded: a periodic poll, a SignalR push handler
and a user-driven page load can all reach the same instance (lines 7-9).
[Rubric §19, State Management & Data Flow]covers the resulting ownership rule, that every dictionary touch happens under_sync. - Ordinal comparison as a correctness requirement. The comment at lines 22-24 makes the point
that two URLs differing only in case are two different requests to the server, so they must be two
different entries here. That is why the default
Dictionary<string, ...>comparer is left alone and every prefix check passesStringComparison.Ordinalexplicitly (lines 95, 127). [Rubric §14, Testability]shows in the injectedTimeProvider(line 16): TTL behavior is exercised with a fake clock rather than by sleeping, inUiReadCacheTests.
- Why a lock at all. Circuit code is not single-threaded: a periodic poll, a SignalR push handler
and a user-driven page load can all reach the same instance (lines 7-9).
- Walkthrough:
- Fields: the
Lock _sync(line 20), the entry dictionary mapping URL to a(object Value, DateTimeOffset StoredAt)tuple (line 25), the null-guarded_timeProvider(line 26), and the eagerly unwrapped_options(line 27). Storing the value asobjectis what lets one dictionary hold every read shape the app makes. TryGetFresh<T>(lines 30-67) guards the URL, setsvalue = default, and short-circuits tofalsewhen_options.Enabledis off (lines 36-39). It then takes the lock and applies three exits: no entry (lines 45-48); an entry older thanResolveTtl(url), which is removed on the way out (lines 50-54); and an entry whose stored value is not aT, which is also removed, because the same URL read back as a different type means the caller changed shape and the stored value can no longer answer the question (lines 56-62). Only then is it a hit (lines 64-65).Set<T>(lines 70-85) is a no-op when caching is disabled or the value is null (line 74), stampsGetUtcNow()outside the lock, and assigns inside it (lines 79-84).InvalidatePrefix(lines 88-103) materializes the matching keys into a list under the lock before removing them (lines 94-101), because removing while enumerating the same dictionary would throw.Clear(lines 106-112) empties the dictionary under the lock.ResolveTtl(lines 120-135) is the freshness lookup: it starts fromUiReadCacheOptions.DefaultTtland scans every configured route prefix, keeping the TTL of the longest prefix the URL starts with (lines 125-132). The doc says why longest-match rather than first-match (lines 114-118): a nested route can state a stricter budget than the endpoint above it, whatever order the configuration happens to enumerate in.
- Fields: the
- Why it's built this way:
internalbecause the interface is the supported surface and the DI registration is the only supported way to get one (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:61registers itTryAddScoped, so a host can substitute its own implementation). The clock is registered alongside it withTryAddSingleton(TimeProvider.System)(DependencyInjection.cs:56), which the comment notes is aTryAddso a host that already registered one (asAddInfrastructuredoes) keeps it and a test substitutes aFakeTimeProvider. The defaults come from the options object rather than constants: caching isEnabledby default with a 60-secondDefaultTtl(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/UiReadCacheOptions.cs:24,32), long enough to collapse the burst of identical reads a page issues while it mounts and short enough that a stale list corrects itself within one user's attention span. - Where it's used: resolved as IUiReadCache by
EntityServiceBase<TEntityDTO, TIdentifierType> and
AuthUIService; covered byUiReadCacheTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Caching/UiReadCacheTests.cs:15).
IPublicLinkBuilder
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/IPublicLinkBuilder.cs:9· Level 0 · interface
- What it is: a one-method abstraction that turns an app-relative path into an absolute, publicly
shareable URL (
IPublicLinkBuilder.cs:9-13). It exists so share sheets, copy-link buttons and QR payloads produce a URL that still works once it leaves the app. - Depends on: nothing first-party. BCL
Uriandstring. Implemented by NavigationPublicLinkBuilder in this package and by MauiPublicLinkBuilder on the hybrid head. - Concept introduced, head-agnostic absolute link building.
[Rubric §18, UI Architecture]assesses whether shared components stay host-agnostic instead of branching on the host they run in, and[Rubric §25, Navigation & Information Architecture]assesses whether outbound links are built from one authority rather than string-concatenated per call site. The doc comment (IPublicLinkBuilder.cs:3-8) states the problem exactly: web heads can derive a shareable origin from the browser, but the MAUI head cannot, because its internal origin is the WebView's virtual host. Encoding that virtual origin into a QR code or a shared link would produce a URL nobody outside the app can open. One interface with two implementations moves the head-specific knowledge to the composition root and lets the pages stay identical on every head. - Walkthrough
- A single member,
Uri BuildAbsolute(string relativePath)(IPublicLinkBuilder.cs:13). It returns aUrirather than astring, so callers that need text do theToString()themselves, and the doc comment gives/sessions/42as the shape of the argument (IPublicLinkBuilder.cs:11). - The default binding resolves the path against
NavigationManager.BaseUri(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationPublicLinkBuilder.cs:25), after rejecting a blank path (NavigationPublicLinkBuilder.cs:23). - The hybrid binding resolves against the
PublicSite:BaseUrlkey pinned in the head's embedded configuration (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Services/MauiPublicLinkBuilder.cs:17andMauiPublicLinkBuilder.cs:28-32), and throwsInvalidOperationExceptionat construction when the key is missing, so a misconfigured head fails at startup instead of shipping unusable links.
- A single member,
- Why it's built this way: the default is registered with
TryAddScoped(DependencyInjection.cs:143) and the comment above it (DependencyInjection.cs:139-142) records the override rule: the hybrid head callsAddCommonMauiPublicLinkBuilder()afterAddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:134-135, invoked atMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:143) and the last registration wins. That is the same head-override composition convention ADR-042 establishes for the device capability layer. - Where it's used: the shared
SharePageButtoncomponent (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/SharePageButton.razor:4andSharePageButton.razor:43), the sharedQrCodeButtoncomponent (QrCodeButton.razor:1andQrCodeButton.razor:79), and app pages such as ADC's speaker QR page (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerQr.razor.cs:21andSpeakerQr.razor.cs:55). A bUnit test pins the default registration to the browser-origin builder (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Navigation/NavigationPublicLinkBuilderTests.cs:59).
BackNavigationResult
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:19· Level 0 · record (sealed)
- What it is: the outcome of a hardware-back or WebView-back attempt routed through MauiBackNavigationBridge: whether the WebView consumed the gesture, and whether the WebView is sitting at the root of its history stack.
- Depends on: nothing first-party. It is a two-field positional record produced and consumed by MauiBackNavigationBridge.
- Concept introduced, the interop return contract. This record is also the wire shape of a single
JS interop call:
nav-interop.js'stryGoBack()returns an object that deserializes straight into it (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:45), so the C# type and the JS return value are one contract. - Walkthrough:
public sealed record BackNavigationResult(bool Handled, bool AtRoot)(MauiBackNavigationBridge.cs:19).Handledistruewhen the WebView's history stack contained a previous entry andhistory.back()fired (MauiBackNavigationBridge.cs:9-13);AtRootistruewhen no previous entry exists, and the doc comment records that MAUI hosts typically exit the app on Android when that is reported (MauiBackNavigationBridge.cs:14-18). - Why it's built this way: a
sealed recordbuys structural equality and positional deconstruction for free, and it is the smallest thing that can carry the two facts the native host needs. Modeling the answer as data (rather than throwing, or mutating shared state) keeps the interop call pure and trivially testable. - Where it's used: returned by
MauiBackNavigationBridge
.HandleBackPressedAsync; consumed by MAUI hostContentPage.OnBackButtonPressedhandlers.
ChannelReferenceCounter
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/ChannelReferenceCounter.cs:16· Level 0 · class (internal, sealed)
- What it is: a small self-synchronized counter that tracks how many outstanding joins the circuit holds for each live-channel key, so NotificationHubService tells the SignalR server to join a group on the first join and to leave it only on the last matching leave.
- Depends on: nothing first-party. It is a
System.Threading.Lockplus aDictionary<string, int>(BCL). It is owned as a private field by NotificationHubService (NotificationHubService.cs:42), and sits beside (not inside) the separate handler bookkeeping that ChannelSubscription unwinds. - Concept introduced, reference-counted group membership.
[Rubric §19, State Management & Data Flow]assesses how a shared per-circuit resource is owned when more than one component holds it at once. A live channel is exactly that resource: an invisible layout listener and a page can both be watchingevent:1. The class remarks state why a set is the wrong structure (ChannelReferenceCounter.cs:5-10): with set semantics the first leaver removes the only entry and cuts the channel off for every other subscriber still holding it. Counting joins per key turns membership into two edges, 0 to 1 and 1 to 0, and only those two moments need to reach the server.[Rubric §29, Resilience & Business Continuity]applies as well, becauseSnapshot()is the replay list the hub service re-joins after an automatic reconnect.[Rubric §14, Testability]shows in the visibility choice: the type isinternalwith anInternalsVisibleTofor the test project, and the project file records exactly why (MMCA.Common/Source/Presentation/MMCA.Common.UI/MMCA.Common.UI.csproj:11-16): the ref-count semantics cannot be reached through the public API, sinceJoinChannelAsyncstarts a realHubConnection, so a join-based test would need a live server and a multi-second backoff.
- Walkthrough: two fields, the
Lock(ChannelReferenceCounter.cs:18) and the outstanding-joinDictionary<string, int>(line 22, whose default string comparer is ordinal, matching the hub's group-name semantics, lines 20-21).AddRef(channelKey)(line 30) reads the current count, writescurrent + 1, and returnscurrent == 0, so only the 0-to-1 transition reports "the server must be told to join" (lines 34-36).Release(channelKey)(line 49) returnsfalsefor a key that was never joined (lines 53-56), removes the entry and returnstruewhen the decrement reaches zero or below (lines 58-63), and otherwise stores the decremented value and returnsfalse(lines 65-66). The count therefore never goes negative and an unpaired leave is a no-op.Snapshot()(line 74) returns[.. _counts.Keys]under the lock: the distinct keys with at least one outstanding join, so a channel held twice is re-joined once.RefCountFor(channelKey)(line 85) returns the outstanding count, or zero when the channel is not held.- Every method takes the lock, because joins and leaves arrive from component lifecycle callbacks on different render batches (lines 11-14).
- Why it's built this way:
ADR-039 decided the shape this
implements: one hub,
JoinChannel/LeaveChannelmapping a connection into a SignalR group, multicast subscriptions so an invisible listener and a page can observe the same channel concurrently, and a re-join onReconnectedbecause group membership does not survive a new connection. The ADR says the hub service tracks membership; this class is how that tracking is done so two concurrent holders cannot evict each other. - Where it's used: three call sites, all inside NotificationHubService:
AddRefinJoinChannelAsync(NotificationHubService.cs:197, deliberately counted before the connection is started so the replay insideStartAsyncsees it, line 196),ReleaseinLeaveChannelAsync(line 229), andSnapshotinRejoinChannelsAsync(line 351). Covered directly byChannelReferenceCounterTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Notifications/NotificationHubServiceTests.cs:320, whose summary names the H13 regression it locks down, lines 314-319). The two concurrent holders it exists for are real: LiveEventListener and the HappeningNow page both join the same event channel key. - Caveats / not-in-source: it counts joins only; it knows nothing about handlers. Subscriptions
live in a separate
_channelSubscriptionsdictionary under a different lock (NotificationHubService.cs:36,43), so disposing a ChannelSubscription does not decrement the count, and leaving a channel does not remove handlers (NotificationHubService.cs:221-222states that pairing requirement).
INotificationScopeProvider
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/INotificationScopeProvider.cs:15· Level 0 · interface
- What it is: the contract that supplies the scope key the notification UI sends and reads under, plus an optional human-readable name for that scope, so an application can narrow its notifications to whatever it considers "current" (a conference event, a tenant, a season) without the framework knowing what that is.
- Depends on: nothing first-party. Both members return
Task<string?>and take aCancellationToken(BCL). Implemented in the framework by NullNotificationScopeProvider and consumed by both notification HTTP services, NotificationInboxService and PushNotificationService. - Concept introduced, the opaque scope key. The framework ships the notification feature but has
no vocabulary for what notifications belong to, so it inverts the question: the app answers with a
string and the framework treats it as opaque (
INotificationScopeProvider.cs:3-8). The value of putting one provider behind both HTTP services is agreement: a send and the reads that follow it resolve through the same instance, so the inbox, the unread badge and a bulk mark-read cannot disagree about which slice the user is looking at.[Rubric §9, API & Contract Design]assesses contract minimality and the meaning of defaults. Both members return a nullable string, and null carries a defined meaning ("unscoped", "no caption"), which is what lets the scoped and unscoped worlds share one code path instead of branching.- A default interface method as a non-breaking extension.
GetCurrentScopeDisplayNameAsyncis declared with a body,=> Task.FromResult<string?>(null)(INotificationScopeProvider.cs:39), so an application with no display name, and every implementation written before the member existed, keeps compiling untouched (the rationale is stated in the doc at lines 29-35).[Rubric §15, Best Practices & Code Quality]assesses whether a contract can grow without a coordinated sweep across every implementor; a default member is the language feature that makes that possible here. [Rubric §11, Security]assesses where authorization decisions live, and this contract is explicit that it is not one. The remarks require implementations never to throw, and they state the direction to fail in: in an application whose notifications are all scoped, fail closed, that is, return the last known scope key or fail the operation, rather than returning null, because degrading to null silently widens the view to every notification (INotificationScopeProvider.cs:9-14). Null is reserved for an application that genuinely runs unscoped. Ownership filtering itself stays on the server: a scope is a view filter, not a permission.- The display-name member fails closed differently, and the source says so: a missing caption hides
information, while a wrong one would state the wrong audience, so returning null is the safe
direction there (
INotificationScopeProvider.cs:32-35).
- Walkthrough: two members.
Task<string?> GetCurrentScopeKeyAsync(CancellationToken ct = default)(INotificationScopeProvider.cs:22) returns the key currently in force (the example in source is"event:2") or null when the application is unscoped (lines 17-21).Task<string?> GetCurrentScopeDisplayNameAsync(CancellationToken ct = default)(line 39) returns a human-readable name for that scope (the conference event's title, the tenant's name). The send page uses it to caption who a notification will actually reach, so an operator can see the auto-applied target rather than infer it (lines 24-28); the one call site isMMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:77.
- Why it's built this way: an interface rather than a settings value, because the answer is dynamic
(it changes as the app's current context changes) and may need an async lookup. Keeping the key a
plain string keeps the framework free of any domain concept, and the never-throw rule written into
the contract is what makes the fail-closed guarantee real rather than aspirational. See
ADR-024, which records the
optional
ScopeKeytravelling with a send. - Where it's used: injected into NotificationInboxService
(
NotificationInboxService.cs:32) and PushNotificationService (PushNotificationService.cs:19); registered withTryAddScopedagainst NullNotificationScopeProvider byAddNotificationUI()(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:24). In MMCA.ADC the real implementation is CurrentEventNotificationScopeProvider.
NotificationState
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationState.cs:18· Level 0 · class (sealed)
- What it is: the scoped shared state for the notification unread count. It holds the count, the timestamp of when that count was last established, and the single active-poller slot that keeps duplicate notification bells from each running their own poll loop.
- Depends on: nothing first-party. Externals: BCL
TimeProvider(injected, defaulting toTimeProvider.System,NotificationState.cs:18,21),System.Threading.Lock, and threeEventHandlerevents. Consumed by NotificationBell and the inbox page NotificationInbox. - Concept introduced, a scoped state store that owns both the value and its freshness.
[Rubric §19, State Management & Data Flow]assesses how shared UI state is owned and observed without threading it through the component tree.NotificationStateis registered scoped (MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:33), so each Blazor circuit gets its own instance and components subscribe to its events instead of receiving cascading parameters. Two mechanisms are worth studying.- The staleness stamp.
LastFetchedUtc(line 41) records when the count was established, which is the state half of the client's staleness policy: a subscriber that fires on an ambient trigger (a navigation, a re-render) asksIsStale(maxAge)instead of re-reading the API every time the trigger happens to fire (lines 77-85). The subtle rule is stamped inSetUnreadCount: the stamp is written before the unchanged-count early return (line 66), because an API read that came back with the same number is still a read. Without that ordering, a quiet inbox (where the count almost never changes, so almost every read is the no-op read) would look permanently stale and re-fetch forever (lines 63-65).[Rubric §23, Front-End Performance]is the payoff. - The active-poller slot as an owner reference, not a counter.
_pollerOwner(line 29) holds the component instance that currently polls, or null when the slot is free, and the field's own doc explains why a counter was the wrong shape: a counter leaks one increment per teardown that never unregisters, and once it leaks no bell can ever win the slot again for the life of the circuit (lines 23-28). An owner reference makes register and unregister symmetric. [Rubric §14, Testability]shows in the constructor: the clock is aTimeProvider?parameter defaulting toTimeProvider.System(lines 14-18,21), so a test drivesIsStalewith a fake clock while an existing host keeps the previous no-argument constructor shape.
- The staleness stamp.
- Walkthrough: members in teaching order.
- Fields: the
Lock _pollerSync(line 20), the resolved_timeProvider(line 21), and the nullable_pollerOwner(line 29). UnreadCountwith a private setter (line 32) andLastFetchedUtcwith a private setter (line 41).- Three events:
OnChangewhen the count changes (line 44),OnRefreshRequestedwhen a real-time notification arrives and the badge should refetch the authoritative count (lines 46-50), andOnPollerSlotFreedwhen the active-poller slot becomes free so a surviving bell can take polling over (lines 52-57). SetUnreadCount(int)(lines 61-75) stampsLastFetchedUtcfirst (line 66), returns early when the value is unchanged (lines 68-71), and otherwise assigns and raisesOnChange.IsStale(TimeSpan maxAge)(lines 84-85) istruewhen there is no stamp at all or the stamp is older thanmaxAge;MarkStale()(line 92) discards the stamp outright, for a subscriber that learned the data moved (a real-time push) and knows age is no longer evidence of freshness.IncrementUnreadCount()(lines 95-99) bumps by one for an optimistic real-time update and always raisesOnChange.RequestRefresh()(line 102) raisesOnRefreshRequested.TryRegisterPoller(object owner)(lines 111-125) null-guards, takes_pollerSync, and returnsfalseonly when a different owner already holds the slot (lines 117-120); a caller that already holds it getstrueagain, so the call is idempotent.UnregisterPoller(object owner)(lines 133-150) releases the slot only when the caller is the holder (lines 139-142), so a non-owner disposing cannot evict the live poller.OnPollerSlotFreedis raised outside the lock (lines 147-149), because a subscriber claims the slot from its handler and would otherwise re-enter the lock on the disposing component's thread.
- Fields: the
- Why it's built this way: scoped because the count is per-user-session; event-based because subscribers live at arbitrary render-tree depth. The private setters funnel every mutation through the named methods, so no change can bypass the change-notification path or the freshness stamp. The owner-keyed slot plus the freed event is what survives the real lifecycle in a Blazor shell, where the desktop and mobile bell placements are rebuilt independently whenever the authentication state changes (lines 52-56).
- Where it's used: injected into NotificationBell, which claims the slot with
TryRegisterPoller(this), listens onOnPollerSlotFreedto take over, and gates its navigation refresh onIsStale(MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:53,56,107,119,155,173,256), and into NotificationInbox; driven by real-time pushes that arrive over NotificationHubService. Covered byNotificationStateTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Notifications/NotificationStateTests.cs:13).
ReturnUrlProtector
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/ReturnUrlProtector.cs:9· Level 0 · class (static)
- What it is: a pure sanitizer for
returnUrlquery parameters: it accepts only same-origin relative paths and replaces anything else with a safe fallback, closing the open-redirect hole in post-login and post-action redirects. - Depends on: nothing first-party;
System.Uri(BCL) supplies the final relative-URI parse guard. - Concept introduced, open-redirect defense.
[Rubric §26, Front-End Security]assesses whether user-controlled navigation targets are validated before use. An open redirect lets an attacker craft/login?returnUrl=https://evil.comso the victim lands on an attacker site after authenticating, a classic phishing amplifier.Sanitizerejects every off-host form rather than trying to enumerate attacks, and the ordered guards read as a documented threat model. - Walkthrough:
Sanitize(string? candidate, string fallback = "/")(ReturnUrlProtector.cs:18) runs a sequence of cheap, regex-free checks (a regex here would invite ReDoS), each returningfallbackon failure:- null or empty (lines 20-23);
- must start with
/, which rules out scheme-prefixed absolutes such ashttp://andjavascript:(lines 25-30); - the second character must not be
/or\, which browsers read as the start of an authority component and would send the user off-host (lines 32-37); - no backslash anywhere, since some browsers normalize
\to/(the source names"/\\evil.com"becoming//evil.comin Chrome, lines 39-44); - no control characters, which are header-injection, response-splitting and cookie-smuggling vectors (lines 46-51);
- and finally it must parse as a well-formed relative URI (
Uri.TryCreate(..., UriKind.Relative), lines 53-57). Only a candidate that survives all six is returned unchanged (line 59).
- Why it's built this way: a static pure function whose only input is the candidate is trivially unit-testable across every attack vector, and calling it centrally means no page hand-rolls its own redirect validation.
- Where it's used: login and post-authentication redirects sanitize the
returnUrlthey read from the query string; NavigationHistoryService.GoBackAsyncalso runs its fallback path through it (NavigationHistoryService.cs:82), so even the "safe" branch cannot be turned into a redirect vector.
NavigationPublicLinkBuilder
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationPublicLinkBuilder.cs:11· Level 1 · class (sealed)
- What it is: the default IPublicLinkBuilder. It turns an app-relative route
such as
sessions/42into an absolute URL by resolving it against the origin the browser is currently served from, which is what a share sheet, a copy-link button or a QR payload needs. - Depends on: IPublicLinkBuilder (the contract it implements,
NavigationPublicLinkBuilder.cs:11);Microsoft.AspNetCore.Components.NavigationManager(ASP.NET Core Blazor,NavigationPublicLinkBuilder.cs:1,13) for the origin. Nothing else: no HTTP, no configuration, no JS interop. - Concept introduced, the origin a link is built from is a per-head decision, not a per-page one.
[Rubric §25, Navigation, Routing & Information Architecture]assesses whether routes and the URLs built from them are modelled once rather than reconstructed ad hoc at each call site. A page that wants to share itself has two candidate origins available, and only one of them is right: the in-process origin the component is rendering under, and the public web origin a recipient can open. On the Server and WebAssembly heads those coincide, soNavigationManager.BaseUriis the correct answer and this class is a two-line adapter over it. On the MAUI Blazor Hybrid head they do not: the WebView serves the app from an internal virtual host, so an absolute URL built fromBaseUrithere would be unopenable anywhere else (the class comment records exactly this at lines 5-10). Hoisting the decision behind an interface is what lets the sharedSharePageButtonandQrCodeButtonstay head-agnostic (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/SharePageButton.razor:4,MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/QrCodeButton.razor:1).[Rubric §18, UI Architecture & Component Design]reads the same choice from the component side: the shared components inject a contract, never aNavigationManager. - Walkthrough
- One readonly field,
_navigationManager(line 13), assigned by an expression-bodied constructor (lines 17-18). The class issealedand holds no other state, so a scoped instance costs a reference. BuildAbsolute(string relativePath)(line 21) rejects a blank path outright withArgumentException.ThrowIfNullOrWhiteSpace(line 23): an empty share link is a caller bug, not a condition to render, and this is the one place cheap enough to catch it.- The build itself is one expression (line 25):
new Uri(new Uri(_navigationManager.BaseUri, UriKind.Absolute), relativePath). The innerUriforces the base to be parsed as absolute, and the outer resolution applies standard URI reference resolution to the path. - The behavior callers rely on is pinned rather than assumed:
NavigationPublicLinkBuilderTests
asserts that
"/sessions/42"and"sessions/42"both resolve tohttp://localhost/sessions/42against the bUnit origin (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Navigation/NavigationPublicLinkBuilderTests.cs:21-25), that a query string survives (:27-29), and that a blank path throws (:31-41).
- One readonly field,
- Why it's built this way:
AddUISharedregisters this implementation withTryAddScoped(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:131), so every head gets a working builder without opting in, and the one head that must differ replaces it afterwards:AddCommonMauiPublicLinkBuilder()registers MauiPublicLinkBuilder over the configured public site URL (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:135). The registration shape is itself asserted, implementation type and lifetime, so a refactor that changes the default is a red test rather than a silently wrong share link (NavigationPublicLinkBuilderTests.cs:43-62). - Where it's used: injected by the framework's share affordances,
SharePageButtonandQrCodeButton, and by ADC's SpeakerQr page, which encodes the absolute public URL into the badge QR rather than the WebView origin (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerQr.razor.cs:21). The bUnit harnesses in both repos register it explicitly so component tests exercise the real builder (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/BunitTestBase.cs:42,MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/BunitTestBase.cs:30). - Caveats:
BuildAbsoluteperforms no allow-list check onrelativePath, and nothing in the class restricts the result to the app's own origin, so a path value that came from user input should be sanitized upstream.
MauiBackNavigationBridge
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:28· Level 1 · class (static)
- What it is: a static bridge that routes a native MAUI back gesture (Android hardware back, iOS swipe) into the BlazorWebView's internal history stack, so pressing back inside a hybrid app behaves like a web back rather than tearing down the page.
- Depends on: BackNavigationResult (its return type);
Microsoft.JSInterop(IJSRuntime,IJSObjectReference,JSDisconnectedException,JSException) and thenav-interop.jsmodule shipped as static web assets of this package. - Concept introduced, MAUI-to-WebView interop.
[Rubric §22, Responsive & Cross-Browser]extends to hybrid hosts here: the same Blazor UI runs inside a MAUI WebView, and native chrome events must be reconciled with web navigation. The class doc states the required call site precisely, fromContentPage.OnBackButtonPressedviaBlazorWebView.TryDispatchAsync, so the call runs on the renderer thread with access to the WebView'sIJSRuntime(MauiBackNavigationBridge.cs:21-27). - Walkthrough:
HandleBackPressedAsync(IJSRuntime js)(line 38) null-checks the runtime (ArgumentNullException.ThrowIfNull, line 40), dynamically imports./_content/MMCA.Common.UI/nav-interop.js(ModulePath, line 30) and invokes itstryGoBack()helper, deserializing the answer into a BackNavigationResult (lines 44-46). Three interop failure modes are caught explicitly and collapse to the same safe valuenew BackNavigationResult(Handled: false, AtRoot: true):InvalidOperationExceptionwhen Blazor is not yet hydrated (lines 48-52),JSDisconnectedException(lines 53-56), andJSException(lines 57-60). A not-yet-ready WebView therefore reports "at root, not handled" and the host falls back to its default back behavior. - Why it's built this way: a static helper with no state fits a one-shot interop call, and returning a data record instead of throwing keeps the native handler branch-free. Collapsing the three JS exception types into one safe default means an unhydrated or disconnected circuit never crashes the native back button.
- Where it's used: MAUI host projects call it from their page back-button handler; the returned BackNavigationResult tells the host whether to exit the app.
- Caveats / not-in-source: the
nav-interop.jstryGoBack()implementation and the MAUI host wiring live outside this unit; only the C# side of the bridge is visible here.
NavigationHistoryService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationHistoryService.cs:12· Level 1 · class (sealed)
- What it is: a per-circuit service that bridges Blazor's
NavigationManagerwith the browser history API, so a "Back" button can perform a realhistory.back()when an in-history entry exists and fall back to an explicit route otherwise. - Depends on: ReturnUrlProtector (sanitizes the fallback) and
LazyJsModule (the shared single-flight JS module importer, held as
_module,NavigationHistoryService.cs:16);NavigationManagerandIJSRuntimearrive through the primary constructor (line 12). ImplementsIAsyncDisposable. - Concept introduced, honoring real browser history.
[Rubric §25, Navigation, Routing & Information Architecture]assesses predictable, source-aware navigation. A hard-coded "back to list" link ignores where the user actually came from; this service instead asks the browser whether a previous entry exists and navigates to it, falling back to a route only when it does not (NavigationHistoryService.cs:50-54).[Rubric §26, Front-End Security]applies to the fallback: it is sanitized rather than trusted (line 82). - Walkthrough:
ModulePath(line 14) names./_content/MMCA.Common.UI/nav-interop.js, the same module the MAUI bridge imports;_modulewraps it in a LazyJsModule (line 16) so concurrent callers share one import.CanGoBackAsync()(lines 23-48) resolves the module, returnsfalsewhen it is unavailable (lines 28-31), then invokeshistoryLengthand reportslength > 1(lines 33-34). Interop failures during SSR prerender or after a disconnect are swallowed asfalse(InvalidOperationException,JSDisconnectedException,JSException, lines 36-47).GoBackAsync(string fallback = "/")(lines 55-83) callshistoryBackwhen history is available and returns (lines 57-66); every interop failure falls through the three catch blocks (lines 68-79) to the single exit at line 82,navigation.NavigateTo(ReturnUrlProtector.Sanitize(fallback)). The method therefore always ends in a navigation.GetModuleAsync()(lines 85-99) delegates to_module.GetOrImportAsync()and turns a prerender or disconnect failure intonullrather than an exception.DisposeAsync()(line 102) forwards to the module wrapper, releasing the imported JS reference with the circuit.
- Why it's built this way: sealed and scoped per circuit, because the cached JS module reference
and history semantics are per-connection. Delegating the import to LazyJsModule
removes a real bug class: an unguarded
_module ??= await import(...)lets two concurrent callers each start an import and leaks the loser's reference (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/LazyJsModule.cs:5-13). Routing the fallback through ReturnUrlProtector means even the safe branch cannot be turned into a redirect vector, and the layered exception handling guaranteesGoBackAsyncnever strands the user. - Where it's used: injected into detail-page "Back" buttons; the same
nav-interop.jsprimitives back the MAUI hardware-back path through MauiBackNavigationBridge.
NullNotificationScopeProvider
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NullNotificationScopeProvider.cs:8· Level 1 · class (sealed)
- What it is: the framework's default INotificationScopeProvider: a no-op that always reports "unscoped", so an application that never scopes its notifications keeps exactly the behavior it had before the scope key existed.
- Depends on: INotificationScopeProvider (the interface it
implements);
Task.FromResult(BCL). - Concept reinforced, the Null Object pattern as a registration default. Rather than making the
scope provider optional and null-checking it in both HTTP services, the framework registers a
do-nothing implementation and lets the consumers depend on the interface unconditionally.
[Rubric §2, Design Patterns]assesses whether a pattern is used where it removes branching, which is exactly what happens here: NotificationInboxService and PushNotificationService contain no "is a provider registered" test.[Rubric §15, Best Practices & Code Quality]follows: the feature was additive, and an app that ignores it sees a byte-identical request. - Walkthrough: the whole type is one expression-bodied member,
GetCurrentScopeKeyAsync(CancellationToken ct = default) => Task.FromResult<string?>(null)(NullNotificationScopeProvider.cs:10-12). It does not overrideGetCurrentScopeDisplayNameAsync, which is why that member was added to the interface with a default body: the null object inherits the interface's own null answer unchanged. The class doc records the registration contract: it is the default wired byAddNotificationUI, and an app that scopes registers its own implementation, which wins (NullNotificationScopeProvider.cs:3-7). - Why it's built this way: the "wins" part is mechanical, not conventional.
AddNotificationUI()registers this type withTryAddScoped(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:24), and the source comment states the reason:TryAddmeans an app that registers its own provider wins whichever order the two registration calls run in (lines 22-23). A plainAddScopedwould have made host startup ordering load-bearing. - Where it's used: resolved as INotificationScopeProvider by NotificationInboxService and PushNotificationService in every host that has not registered its own; MMCA.ADC replaces it with CurrentEventNotificationScopeProvider.
ChannelSubscription
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:412· Level 2 · class (private, sealed, nested)
- What it is: the disposable handle returned when a caller subscribes to a live channel on NotificationHubService; disposing it removes the handler from the channel's subscriber list.
- Depends on: its owning NotificationHubService (a back-reference), a
channel-key string, and a
Func<string, string, Task>handler; implementsIDisposable. - Concept introduced, subscription-as-token. This is the classic "return an
IDisposableto unsubscribe" pattern. Instead of exposing anUnsubscribe(handler)method (which forces callers to hold and match the exact delegate),OnChannelEventreturns aChannelSubscription; when the component disposes it, the subscription calls back into the owner to unregister itself.[Rubric §1, SOLID]shows in the encapsulation: only the hub service can construct one, and only it knows how to remove one, so the bookkeeping has a single owner. - Walkthrough: a primary-constructor nested class capturing
owner,channelKeyandhandler(NotificationHubService.cs:412), exposingChannelKey(line 414) andHandler(line 416) as get-only properties.Dispose()(line 418) simply callsowner.RemoveSubscription(this), which takes the shared_channelSynclock, removes the entry, and prunes the channel's list once it empties (lines 373-386). TheHandlerproperty is whatDispatchChannelEventAsyncinvokes on each delivery (line 339). - Why it's built this way: nesting it privately inside
NotificationHubService keeps subscription bookkeeping fully encapsulated,
and the
IDisposableshape lets Blazor components tie unsubscription to their own lifetime. - Where it's used: constructed and returned by
NotificationHubService
.OnChannelEvent(line 260); disposed by the component that subscribed. - Caveats / not-in-source: disposing a subscription unregisters the handler only. It does not
release a channel join: those are counted separately by
ChannelReferenceCounter, and the source states the pairing requirement
explicitly (
NotificationHubService.cs:221-222).
NotificationHubService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:26· Level 2 · class (sealed, partial)
- What it is: the client-side SignalR connection manager. It opens a connection to
/hubs/notificationsafter login, invokes a callback for received notifications, and carries the ephemeral live-channel events that components join and subscribe to. - Depends on: ApiSettings (for the hub URL, via
IOptions<ApiSettings>) and ITokenStorageService (for the bearer token); ChannelReferenceCounter (membership counting) and ChannelSubscription (its subscription handle). Externals:Microsoft.AspNetCore.SignalR.Client(HubConnection,HubConnectionBuilder),ILogger<T>with[LoggerMessage]source generation,System.Threading.LockandSemaphoreSlim; implementsIAsyncDisposable. - Concept introduced, resilient client-side real-time with re-joinable channels.
[Rubric §6, CQRS & Event-Driven]extends to the browser here: the server pushes notifications and channel events over SignalR instead of the client polling for everything.[Rubric §29, Resilience & Business Continuity]shows in four distinct mechanisms, each with its rationale in source:- the initial connect retries with exponential backoff up to
MaxRetries = 3starting atInitialRetryDelayof 2 seconds and doubling (lines 28, 71, 145-180), so a token-not-yet-ready or API-still-starting race recovers; WithAutomaticReconnect()(line 128) keeps long sessions alive, and because SignalR group membership does not survive a new connection,Reconnectedre-joins every held channel (lines 141-143,RejoinChannelsAsynclines 348-371);- a terminal start failure discards the connection object (
DiscardUnstartedConnectionAsync, lines 310-319) instead of leaving it in the field, because the null guard at line 121 would otherwise make every laterStartAsynca permanent no-op (the comment at lines 169-171 records exactly this); StartAsyncis serialized by aSemaphoreSlim(line 40), since two components callingJoinChannelAsyncat once on Blazor Server (which has no single synchronization context) could both see a null connection, both build one, and leak the loser socket with duplicate server registrations (lines 76-83).[Rubric §13, Observability & Operability]applies too: every outcome is a source-generated structured log (lines 388-410), and failures on the channel paths are logged, never thrown.
- the initial connect retries with exponential backoff up to
- Walkthrough:
- Constants and fields: the four hub method names (lines 29-32),
_channelSyncguarding the subscription dictionary (line 36),_startSyncguarding start (line 40, aSemaphoreSlimrather than aLockbecause the guarded body awaits, lines 38-39), the ChannelReferenceCounter (line 42), and_channelSubscriptionsmapping a channel key to its handler list (line 43). NotificationCallback(line 51) is a settableFunc<string, string, Task>?the host assigns to surface a snackbar;IsConnected(line 65) reports the connection state;InitialRetryDelay(line 71) isinternaland settable so a test can exercise the terminal-failure path without waiting out the real multi-second backoff (lines 67-70).- The constructor (lines 53-62) builds
_hubUrlfromApiSettings.ApiEndpointtrimmed of its trailing slash plus/hubs/notifications(line 61), throwing if the options are absent (line 60). StartAsync(lines 85-117) bails when disposed (lines 87-90), takes_startSync(toleratingObjectDisposedException, lines 92-100), runsStartCoreAsync, and releases in afinallythat tolerates the same disposal race (lines 106-116).StartCoreAsync(lines 119-181) returns immediately when a connection already exists (line 121), builds theHubConnectionwith anAccessTokenProviderbound toITokenStorageService.GetAccessTokenAsync(line 127), registersReceiveNotificationfanning out toNotificationCallback(lines 131-137) andReceiveChannelEventtoDispatchChannelEventAsync(line 139), wires the reconnect re-join (line 143), then runs the retry loop, which on success also replays any channel joins requested before the connection came up (line 160).JoinChannelAsync(channelKey)(lines 192-214) counts the join before starting the connection so the replay insideStartAsyncsees it (lines 196-197), then invokes the serverJoinChannelonly on the first join and only when connected (lines 202-213).LeaveChannelAsync(channelKey)(lines 225-244) is the mirror: it invokesLeaveChannelonly whenReleasereports the last outstanding leave (lines 229-243).OnChannelEvent(channelKey, handler)(lines 255-273) creates a ChannelSubscription and appends it to the channel's handler list under the lock, returning the subscription as the unsubscribe token. Subscribing deliberately does not join the channel; the doc says to callJoinChannelAsyncas well (lines 249-250).DispatchChannelEventAsync(lines 321-346) snapshots the subscriber list under the lock (line 331), then invokes each handler in isolation, logging (never rethrowing) a failure so one bad subscriber cannot starve the rest (lines 334-345).StopAsync(lines 278-285) disposes and clears the connection;DisposeAsync(lines 288-303) sets_disposed, stops, and disposes the semaphore. The comment at lines 298-301 records the deliberate choice not to wait for an in-flight start: it can be sitting in a multi-second backoff, and blocking a Blazor circuit teardown on it would be worse.
- Constants and fields: the four hub method names (lines 29-32),
- Why it's built this way: sealed and scoped per circuit, because a connection and its channel membership are per-user-session. Best-effort semantics (join, leave and handler failures are logged, not thrown) match the reality that live updates are a convenience layered over the authoritative API, not a correctness guarantee, and isolating handler invocations protects the fan-out. The overall shape is the client half of ADR-039, with push notifications themselves covered by ADR-024.
- Where it's used: registered as a scoped service by
AddNotificationUI()(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:36); started after login and stopped on logout; its notification callback drives NotificationState and MudBlazor snackbars, and its channel API is what LiveEventListener and the HappeningNow page use. The server side is NotificationHub.
INotificationInboxUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/INotificationInboxUIService.cs:11· Level 3 · interface
- What it is: the UI-side contract for the per-user notification inbox: paged retrieval, unread count, mark-one-read, and mark-all-read. Every member returns a Result carrying the API's own errors.
- Depends on: Result and its generic form
Result<T>, PagedCollectionResult<T>, and UserNotificationDTO (its return shapes), plus theUserNotificationIdentifierTypealias (INotificationInboxUIService.cs:26). - Concept introduced, the Result pattern carried all the way to the component.
[Rubric §18, UI Architecture & Component Design]assesses whether components talk to typed services rather than rawHttpClient; components depend on this interface, not on the HTTP implementation, so a bell or an inbox page can be tested against a stub. The sharper point is the return shape: every member isTask<Result...>, so a caller can tell a real answer apart from a failure without catching anything (INotificationInboxUIService.cs:6-10). See the primer's Result section for the pattern itself; this is where it crosses the presentation boundary.[Rubric §24, Forms, Validation & UX Safety]shows in the unread-count doc (lines 16-21), which defines what a failure means to a caller: the count could not be established (expired session, transient failure) and must be treated as "unknown". Callers leave the displayed count untouched, because reporting zero would erase a badge that a real-time push had just incremented. That is a contract-level statement about UI behavior, not just about data.[Rubric §9, API & Contract Design]shows in the paged signature: the inbox is fetched a page at a time with sane defaults, never as one unbounded dump.
- Walkthrough: four members (
INotificationInboxUIService.cs:13-29).GetInboxAsync(pageNumber = 1, pageSize = 20, cancellationToken)returns aResult<PagedCollectionResult<UserNotificationDTO>>(line 14);GetUnreadCountAsyncreturnsResult<int>(line 23);MarkReadAsync(id, ct)(line 26) andMarkAllReadAsync(ct)(line 29) are the two mutations, both returning a bare Result. - Why it's built this way: a thin interface at the presentation edge keeps components decoupled from transport and makes the inbox mockable in bUnit tests. Note the contract deliberately says nothing about scoping: the scope key is resolved inside the implementation through INotificationScopeProvider, so adding scoping did not change this interface or any caller.
- Where it's used: implemented by NotificationInboxService; consumed by NotificationBell (for the unread count) and the NotificationInbox page.
IPushNotificationUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/IPushNotificationUIService.cs:10· Level 3 · interface
- What it is: the UI-side contract for admin push operations: broadcast a notification and read paginated send history, both returning a Result.
- Depends on: Result, PagedCollectionResult<T>, PushNotificationDTO, and SendPushNotificationRequest.
- Concept reinforced: the same Result-returning UI-service abstraction as
INotificationInboxUIService (
[Rubric §18, UI Architecture & Component Design]), with the same "errors are values the API described, not exceptions the caller catches" rule stated in the doc (IPushNotificationUIService.cs:6-9). The difference is audience: this is the organizer/admin surface (send plus history), not the per-user inbox, and splitting the two keeps each page's dependency surface minimal. - Walkthrough: two members (
IPushNotificationUIService.cs:12-16).SendAsync(SendPushNotificationRequest, ct)returnsResult<PushNotificationDTO>for the created notification (line 13);GetHistoryAsync(pageNumber = 1, pageSize = 10, ct)returnsResult<PagedCollectionResult<PushNotificationDTO>>(line 16). - Why it's built this way: separating the admin contract from the inbox contract lets an app that never sends notifications avoid taking a dependency on the send path at all, and keeps the two registrations independent.
- Where it's used: implemented by PushNotificationService; consumed by the admin pages NotificationList and NotificationSend.
NotificationInboxService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:29· Level 4 · class (sealed)
- What it is: the HTTP implementation of the inbox contract. It calls the
notifications/inboxWebAPI resource for paged retrieval, unread count, and the two mark-read operations, stamping every scopeable request with the application's current scope key and giving the two reads one forced token refresh and replay when the API answers401. - Depends on: AuthenticatedServiceBase (its base, supplying
CreateAuthenticatedClientAsync,CreateClientWithTokenand the shared staticRetryPolicy), INotificationInboxUIService (the contract it implements), ITokenStorageService, INotificationScopeProvider, ITokenRefresher (optional, defaulted to null), HttpResultExecutor (turns a thrown transport failure into a failed Result), ProblemDetailsResultReader (turns the response into a Result carrying the API's ownProblemDetailserrors), PagedCollectionResult<T>, and UserNotificationDTO. Externals:IHttpClientFactory,System.Net.HttpStatusCode,CultureInfo.InvariantCulture. - Concept introduced, a typed HTTP UI service over a non-CRUD resource.
[Rubric §18, UI Architecture & Component Design]assesses UI-to-API access through typed services. This is a sibling of EntityServiceBase<TEntityDTO, TIdentifierType> for a resource whose verbs are read and mark rather than create/update/delete, so it inherits only the authenticated-client half. Every method has the same three-layer shape:HttpResultExecutor.ExecuteAsyncon the outside (exceptions become failed Results), the sharedRetryPolicyin the middle, andProblemDetailsResultReader.ReadAsyncat the end (a non-success response becomes the failure the API described).- The 401 refresh-and-replay, and why only reads get it.
SendReadWithAuthRefreshAsync(lines 124-149) is the interesting mechanism: a badge poll or an inbox load that lands on an access token the server has already rejected gets one forced token refresh and one replay, instead of surfacing as an empty inbox or a blanked badge (lines 16-19). The doc states the constraint that makes it safe (lines 117-121): only the reads use it, because they are safe to replay, whereas a mark-read PUT is left with its existing single-shot behavior.[Rubric §29, Resilience & Business Continuity]is the category;[Rubric §11, Security]is the reason it is bounded, one attempt, no loop. - A failure is "unknown", deliberately not zero. The comment on the unread count (lines 69-71)
records the defect this shape fixed: reporting zero let a rejected token or a transient failure
erase a badge that a real-time push had just incremented. Now the failure travels as a failed
Result and the caller keeps the displayed count.
[Rubric §24, Forms, Validation & UX Safety]covers this class of "what does the UI show when the read failed" decision. [Rubric §30, Compliance, Privacy & Data Governance]shows in the scope query: the scope is what keeps a bulk mark-read from silently clearing notifications the user is not currently looking at.
- The 401 refresh-and-replay, and why only reads get it.
- Walkthrough:
- A primary constructor forwards
IHttpClientFactoryand ITokenStorageService to AuthenticatedServiceBase and keepsscopeProviderand the optionaltokenRefresher(NotificationInboxService.cs:29-34);Endpointis the constant"notifications/inbox"(line 35). The refresher's default ofnullis documented as the graceful-degradation path: a host that registers none simply skips the retry, and the read reports failure rather than a fabricated empty result (lines 24-27). ScopeQueryAsync(separator, ct)(lines 178-185) is the shared helper: it asks INotificationScopeProvider for the current key and returns either an empty string (leaving the request byte-identical to the pre-scope one, lines 170-173) or{separator}scope={Uri.EscapeDataString(scopeKey)}(line 184). The separator parameter is"&"for a URL that already carries query parameters and"?"for one that does not (lines 174-176).GetInboxAsync(lines 38-56) resolves the scope with"&"(line 45), builds an invariant-culture relative URL (lines 46-48), sends throughSendReadWithAuthRefreshAsync(lines 50-51), and reads aResult<PagedCollectionResult<UserNotificationDTO>>(lines 53-54).GetUnreadCountAsync(lines 59-74) resolves the scope with"?"(line 63), goes through the same read path, and reads aResult<int>(line 72).MarkReadAsync(lines 77-93) PUTs to{Endpoint}/{id}/read(line 84) on a plain authenticated client. It is the one method that sends no scope: the id already identifies a single notification.MarkAllReadAsync(lines 96-111) PUTs to{Endpoint}/read-allwith the scope query (lines 100-102), so a bulk operation is bounded by the same filter the list was read under.- Both mutations pass the
cancellationTokeninto the retry policy as well as the request (lines 86-89, 104-107), and the comment says why: without it an abandoned mark-read sleeps out its full backoff budget instead of aborting. SendReadWithAuthRefreshAsync(lines 124-149) runs the send under the retry policy inside ausingfor the first client (lines 129-132), returns the response untouched when it is not a401or no refresher was registered (lines 134-137), and otherwise acquires a token, disposes the first response, and replays on a client built with the new token (lines 139-148). The doc notes the response content is fully buffered before the send task completes, which is what makes it legal to read the body after the client that produced it is disposed (lines 119-120).TryAcquireRefreshedTokenAsync(lines 156-168) forces one re-acquisition and returnsnullfor a blank token or when JS interop is unavailable during SSR prerender (lines 163-167), which is a "no refresh is possible here", not an error.
- A primary constructor forwards
- Why it's built this way: inheriting from AuthenticatedServiceBase
removes per-method boilerplate for auth and retry, and wrapping every body in
HttpResultExecutor means no method has to hand-write a try/catch to honor the
Result-returning contract. Routing the scope through a provider (rather than a parameter on every
call) is what keeps the UI contract unchanged while the inbox, badge and mark-all agree on one slice
(
NotificationInboxService.cs:11-16). - Where it's used: registered against
INotificationInboxUIService as scoped by
AddNotificationUI()(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:30); consumed by NotificationBell and the NotificationInbox page.
PushNotificationService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/PushNotificationService.cs:16· Level 5 · class (sealed)
- What it is: the HTTP implementation of the admin push contract: send a notification and read
paginated send history against the
notificationsWebAPI resource, stamping a send with the application's current scope key when the caller did not name one. - Depends on:
EntityServiceBase<TEntityDTO, TIdentifierType>
(its base, typed on
PushNotificationDTO/PushNotificationIdentifierType, which suppliesEndpointand the Result-returningSendRequestAsync), IPushNotificationUIService (the contract), ITokenStorageService, INotificationScopeProvider, Result, PagedCollectionResult<T>, PushNotificationDTO, and SendPushNotificationRequest. - Concept reinforced, the base-class HTTP service pattern at its cleanest
(
[Rubric §18, UI Architecture & Component Design]). Where NotificationInboxService hand-builds each request because its resource is not CRUD-shaped, this one leans on EntityServiceBase'sSendRequestAsync, so each method reduces to one send that already returns a Result.[Rubric §9, API & Contract Design]appears in the scope precedence rule: an explicit caller choice outranks the ambient one, which is the difference between a default and an override. - Walkthrough:
- The primary constructor passes the resource name
"notifications"plus the factory and token service to EntityServiceBase and keepsscopeProvider(PushNotificationService.cs:16-22). SendAsync(request, ct)(lines 23-47) null-guards the request (line 27), then applies scoping conditionally: a request that already carries aScopeKeyis sent unchanged, and only an unscoped one picks up the ambient key via arecord withexpression (lines 31-39, rationale at lines 29-30). It then POSTs throughSendRequestAsync<PushNotificationDTO>(lines 41-46).GetHistoryAsync(pageNumber = 1, pageSize = 10, ct)(lines 50-59) builds an invariant-culturepageNumber/pageSizequery (line 55) and sends a GET through the same helper (lines 56-58). Note it does not send a scope: history is the admin's full send log.
- The primary constructor passes the resource name
- Why it's built this way: delegating transport, auth, retry and error translation to
EntityServiceBase keeps this class down to two short
methods, matching the framework's "UI services are typed HTTP clients, never raw
HttpClient" convention. Reading the scope through the same INotificationScopeProvider the inbox service uses is what makes a send and the reads that follow it resolve to one scope (PushNotificationService.cs:10-15); the wire-levelScopeKeyon the request is recorded in ADR-024. - Where it's used: registered against
IPushNotificationUIService as scoped by
AddNotificationUI()(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:27); injected into the admin pages NotificationList and NotificationSend. - Caveats / not-in-source:
SendAsyncdoes not readGetCurrentScopeDisplayNameAsync; that member exists for the send page's caption and is consumed by NotificationSend directly (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:77), not by this service.
IUserPreferenceWriter
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/IUserPreferenceWriter.cs:9· Level 0 · interface
- What it is: the write half of cross-device UI preferences. It persists the signed-in user's culture and theme choice to the backend so the choice follows them to their next browser or device.
- Depends on: nothing. Implemented by ApiUserPreferenceWriter; the read half is IUserPreferenceReader.
- Concept introduced, best-effort persistence over a local source of truth.
[Rubric §19, State Management & Data Flow]assesses where state lives and which copy wins; the doc comment (IUserPreferenceWriter.cs:3-8) answers both. The cookie and localStorage remain the runtime channel, this interface is a roaming convenience, and a failed or skipped persist must never break the in-page switch. Implementations must no-op for anonymous users. Anullfield means "leave unchanged" (IUserPreferenceWriter.cs:11-13), which is what lets the theme toggle and the culture switcher share one method without either clobbering the other's value. - Walkthrough
SaveAsync(string? culture, string? theme, CancellationToken cancellationToken = default)(IUserPreferenceWriter.cs:18). Both value arguments are nullable by design, per the null-means-unchanged rule stated atIUserPreferenceWriter.cs:15-16.
- Why it's built this way:
ADR-027 and
ADR-028. Keeping it an interface is
what lets a host with no
auth/preferencesendpoint (the Helpdesk seed is the named example atApiUserPreferenceWriter.cs:11-12) simply not register it: the callers resolve it withGetService<T>and skip the persist when it is absent. - Where it's used: the theme toggle resolves it optionally and saves only the theme
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/ThemeToggle.razor:23-27); the culture switcher does the same for culture before handing off to the applier (CultureSwitcher.razor:38-42).
UserPreferences
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/UserPreferences.cs:9· Level 0 · record (sealed)
- What it is: the two-field result of reading a user's stored UI preferences, their culture and their theme.
- Depends on: nothing. Returned by IUserPreferenceReader and its implementation ApiUserPreferenceReader.
- Concept introduced: nothing new. It reuses the null-means-unset convention introduced by
IUserPreferenceWriter: the doc comment (
UserPreferences.cs:3-6) states that anullfield means the user never chose that preference, so the request default or the OS preference applies.[Rubric §19, State Management & Data Flow]applies in the small, because "no stored value" and "stored value that happens to be the default" stay distinguishable, which is what lets the login reconciliation skip a redundant culture round-trip. - Walkthrough
- A positional record with two members,
CultureandTheme, bothstring?(UserPreferences.cs:9). There is no factory and no validation: the values are whatever the backend returned.
- A positional record with two members,
- Why it's built this way: a positional record is the smallest thing that deserializes cleanly from
the
auth/preferencespayload and compares by value. - Where it's used: returned by ApiUserPreferenceReader, including its
static
Emptyinstance (ApiUserPreferenceReader.cs:18); consumed by the login page's preference reconciliation (Login.razor:228-247).
UserPreferencesRequest
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/ApiUserPreferenceWriter.cs:29· Level 0 · record (private sealed, nested)
- What it is: the request body the writer PUTs to
auth/preferences, the same culture and theme pair in the write direction. - Depends on: nothing. It is declared inside ApiUserPreferenceWriter and used only there.
- Concept introduced: nothing new; it is the wire-facing twin of
UserPreferences.
[Rubric §9, API & Contract Design]applies in the small: the request type is kept separate from the response type even though the two currently have identical members, so the directions can diverge without a breaking change, and it is declaredprivateso it never becomes part of the package's public surface. - Walkthrough
private sealed record UserPreferencesRequest(string? Culture, string? Theme)(ApiUserPreferenceWriter.cs:29). It is instantiated once, inline in thePutAsJsonAsynccall (ApiUserPreferenceWriter.cs:65).
- Why it's built this way: nesting it privately keeps a serialization detail from leaking into the package API, and a positional record needs no mapper.
- Where it's used: only in
ApiUserPreferenceWriter.SaveAsync(ApiUserPreferenceWriter.cs:63-66).
AbsoluteUrlAttribute
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/AbsoluteUrlAttribute.cs:26· Level 0 · class (sealed,ValidationAttribute)
- What it is: a DataAnnotations rule that a string property must be an absolute
httporhttpsURL. It is the client-side twin of the server'sAbsoluteUrlRulesin MMCA.Common.Application, so a form gives the same verdict the API would (doc comment,AbsoluteUrlAttribute.cs:6-8). - Depends on: nothing first-party. Externals:
System.ComponentModel.DataAnnotations(ValidationAttribute,ValidationResult,ValidationContext,RequiredAttribute), BCLUri. It is consumed by DataAnnotationsModelValidator, which is the thing that actually runs it on a MudBlazor field. - Concept introduced, validation parity as a security control, not just a UX nicety.
[Rubric §24, Forms, Validation & UX Safety]assesses whether the rules a form enforces match the rules the server enforces. Most parity gaps cost only a wasted round trip. This one is different, and the doc comment says why (lines 8-11): the values this rule guards get rendered straight into an imagesrcor a linkhref, so acceptingjavascript:ordata:on the client and rejecting it on the server means the only thing between a pasted script URL and the rendered page is a network hop.[Rubric §26, Front-End Security]assesses browser-side hardening. Restricting the accepted schemes to exactlyhttpandhttps(lines 44-46) is the narrow allowlist that keeps ajavascript:URL out of an anchor target in the first place.- Optionality is the caller's decision. Null, empty, and whitespace all pass (line 39). The attribute deliberately does not imply "required": a mandatory field pairs this with
[Required], which is what keeps a blank required field showing one clear message instead of two (lines 12-16). - The message is a resource key channel.
ErrorMessageis returned unchanged rather than run throughstring.Format(line 52), which is what lets a model declareErrorMessage = "Validation.AbsoluteUrl"; DataAnnotationsModelValidator resolves every message it receives against the page's localizer and passes an unknown key through untouched, so a plain-English message still renders as written (lines 17-23). See ADR-027 for the localization model.
- Walkthrough
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)](line 25): properties only, once each.- The parameterless constructor (lines 29-32) passes the default English message
"The value must be an absolute http or https URL."to theValidationAttributebase, so a model that declares noErrorMessagestill says something useful. IsValid(object?, ValidationContext)(lines 35-53) null-guards the context (line 37), then returnsValidationResult.Successfor anything that is not a non-blank string (lines 39-42). That single guard is what implements the "optional by default" contract above.- The accept path (lines 44-49):
Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)combined with an ordinal scheme comparison againstUri.UriSchemeHttpandUri.UriSchemeHttps.UriKind.Absolutealone is not enough, because plenty of non-web schemes parse as absolute URIs; the scheme equality check is the actual gate. - The reject path (lines 51-52) builds the member-name array from
validationContext.MemberNamewhen one is present and returnsnew ValidationResult(ErrorMessage, members), so MudBlazor can attribute the failure to the right field.
- Why it's built this way: expressing the rule as an attribute means it travels with the model property rather than with a page, so every form that binds that property inherits it, and the same model can be validated on the server by
Validator.TryValidateProperty. Comparing withStringComparison.Ordinalagainst the BCL scheme constants avoids the culture-sensitive comparison trap and matches howUrinormalizes schemes to lowercase. - Where it's used: applied to URL-bearing properties on shared form and request models, and executed by DataAnnotationsModelValidator through the ModelValidation bridge.
- Caveats / not-in-source: the exact set of consumer models carrying this attribute is not visible from this file; the server-side
AbsoluteUrlRulesit mirrors lives in MMCA.Common.Application and is only named in the doc comment (line 7).
BrandColors
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/BrandColors.cs:10· Level 0 · class (static)
- What it is: the single C# source of truth for the brand palette: six hex constants (a primary triad and a secondary triad) that MMCATheme reads for both its light and dark MudBlazor variants.
- Depends on: nothing first-party. It is mirrored by the CSS custom properties in
wwwroot/app.css(--mmca-primary,--mmca-primary-dark,--mmca-primary-light,--mmca-secondary,--mmca-secondary-dark, named in the doc comments atBrandColors.cs:5-8,12,15,18,22,28). - Concept introduced, a fitness-tested duplication.
[Rubric §20, Design System & Theming]assesses whether visual tokens are centralized rather than scattered as literals; here the palette lives in exactly one C# class.[Rubric §34, Architecture Governance & Documentation]assesses whether necessary duplication is monitored: C# cannot read CSS at build time, so the same colors must exist in bothBrandColorsandapp.css, andBrandColorTokenTestsin MMCA.Common.UI.Tests asserts the two stay in sync so the copy cannot silently drift (BrandColors.cs:6-8).[Rubric §21, Accessibility]lands here rather than only in the theme: theSecondaryconstant carries its own contrast math in source, Teal 700#00796Bholding about 5.3:1 on light surfaces, replacing the Teal 600#00897Bthat measured about 4.0:1 and sat under the WCAG 2.1 AA 4.5:1 floor for normal text (BrandColors.cs:21-26).
- Walkthrough: six
public const stringfields. The primary triad:Primary = "#1565C0"(line 13),PrimaryDark = "#0D47A1"(line 16),PrimaryLight = "#42A5F5"used for accents and dark-mode contrast (line 19). The secondary triad:Secondary = "#00796B"(line 26, with the contrast rationale immediately above it at lines 21-25),SecondaryDark = "#00695C"(line 29), andSecondaryLight = "#4DB6AC"(line 32). - Why it's built this way:
constrather thanstatic readonlymeans the values can appear in contexts that require compile-time constants; the governance is the fitness test, not the language keyword. Keeping the palette in one class means a rebrand touches one file plus the mirrored CSS, and the accessibility reasoning travels with the value it justifies instead of living in a review comment. - Where it's used: the MMCATheme light and dark palettes (
MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MMCATheme.cs:18-24,52-61);BrandColorTokenTests; any component that references a brand color programmatically.
IModelValidator
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/IModelValidator.cs:13· Level 0 · interface
- What it is: a one-method contract that validates a single property of a form model and returns that property's error messages. It is the pluggable rule engine behind ModelValidation
.For. - Depends on: nothing. Its in-box implementation is DataAnnotationsModelValidator (named in the doc comment, line 8); its only caller is ModelValidation.
- Concept introduced, the shape MudBlazor forces and the abstraction that exploits it. MudBlazor hands a field's
Validationdelegate two arguments: the form model and the dotted path of the member being edited. That is exactly the shape a rule engine needs, so the interface simply names it:IEnumerable<string> Validate(object model, string propertyPath)(line 27).[Rubric §1, SOLID]assesses dependency direction and interface size. This is a one-method, dependency-free interface, and the doc comment (lines 8-10) states the payoff plainly: a consumer that keeps its rules in FluentValidation supplies its own implementation, so MMCA.Common.UI never has to reference a validation library. The abstraction exists to keep a NuGet dependency out of a shipped UI package, not to satisfy a pattern.[Rubric §24, Forms, Validation & UX Safety]assesses where form rules live. By making the engine pluggable, the framework can offer a default (attributes on the model) without forcing it on a consumer whose rules already live somewhere else.- Two contract details are load-bearing and documented rather than typed. The
propertyPathis dotted and relative to the model, for example"Title"or"Address.City"(lines 19-23), which is what MudBlazor derives from a field'sForexpression. And the return value is never null (line 25): an empty sequence means valid, so no caller has to null-check.
- Walkthrough: a single member,
Validate(object model, string propertyPath)(line 27), returningIEnumerable<string>.objectrather than a generic type parameter is deliberate: MudBlazor'sValidationparameter is itself untyped at that position, so a generic interface would only add a cast at the boundary. - Why it's built this way: the smallest possible extension point that still matches the host framework's calling convention. Anything wider (a "validate the whole model" method, a result type) would be unused by the one thing that calls it.
- Where it's used: accepted by ModelValidation
.For(ModelValidation.cs:43), which wraps it in the delegate a MudBlazor field'sValidationparameter expects; implemented by DataAnnotationsModelValidator.
ApiUserPreferenceWriter
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/ApiUserPreferenceWriter.cs:22· Level 1 · class (sealed)
- What it is: the default IUserPreferenceWriter. It PUTs the
culture/theme choice to
auth/preferencesthrough the shared"APIClient", and it declines to make the call at all when the request is already known to be doomed. - Depends on: IUserPreferenceWriter (implemented),
ITokenStorageService, JwtTokenInfo and the nested
UserPreferencesRequest. Externals:
IHttpClientFactoryandSystem.Net.Http.Json. - Concept introduced, best-effort writes still have a cost.
[Rubric §13, Observability & Operability]assesses whether the system's own traffic keeps its signals meaningful; the class comment (ApiUserPreferenceWriter.cs:13-18) makes the argument explicitly. Because the caller never learns the write failed, a doomed request cannot help the user and still lands in failed-request telemetry, and at low traffic one 401 per theme or culture toggle is enough on its own to trip a failed-request alert rule. Both guards below therefore exist for the alerting story, not the user's story.[Rubric §11, Security]also touches this: the writer never inspects or forwards the token itself, it only asks whether one is usable. - Walkthrough
- The primary constructor takes
IHttpClientFactoryandITokenStorageService(ApiUserPreferenceWriter.cs:22-24).ExpirySkewis 30 seconds and is documented as matching the token-storage skew so this class agrees with the layer that does the refreshing (ApiUserPreferenceWriter.cs:26-27)._rejectedToken(ApiUserPreferenceWriter.cs:37) holds the token the API last refused, for the lifetime of this scoped writer. SaveAsync(ApiUserPreferenceWriter.cs:40) reads the access token (ApiUserPreferenceWriter.cs:42), then applies guard one:JwtTokenInfo.IsFresh(token, ExpirySkew)(ApiUserPreferenceWriter.cs:47, the helper atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/JwtTokenInfo.cs:16). The comment (ApiUserPreferenceWriter.cs:44-46) notes thatIsFreshalso covers null and unreadable tokens, which makes this the anonymous-user guard as well.- Guard two compares the current token against
_rejectedTokenwithStringComparison.Ordinal(ApiUserPreferenceWriter.cs:55); the comment (ApiUserPreferenceWriter.cs:52-54) explains why expiry alone is not enough, since a token can be unexpired and still rejected (revoked session, rotated signing key, a user the API now treats as gone). - The call itself is a
PutAsJsonAsyncto the relativeauth/preferences(ApiUserPreferenceWriter.cs:62-66), and a401 Unauthorizedlatches_rejectedToken(ApiUserPreferenceWriter.cs:68-71). - Both
HttpRequestException(ApiUserPreferenceWriter.cs:73) andTaskCanceledException(ApiUserPreferenceWriter.cs:77) are swallowed, each with a comment noting that the cookie already holds the choice for this device.
- The primary constructor takes
- Why it's built this way:
ADR-027 and
ADR-028 make the local cookie the
runtime channel and this write a roaming extra. Storing the rejected token rather than setting a
boolean latch is the deliberate detail (
ApiUserPreferenceWriter.cs:31-36): a fresh sign-in produces a different token, so writing resumes with no reset step and no staleness of its own. - Where it's used: registered with
TryAddScopedin DependencyInjection (DependencyInjection.cs:130); resolved optionally by the theme toggle (ThemeToggle.razor:23-27) and the culture switcher (CultureSwitcher.razor:38-42).
IUserPreferenceReader
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/IUserPreferenceReader.cs:9· Level 1 · interface
- What it is: the read half of cross-device preferences. It fetches the signed-in user's stored culture and theme, used at login to reapply a returning user's choices on a new device.
- Depends on: UserPreferences (its return type); implemented by ApiUserPreferenceReader. The write half is IUserPreferenceWriter.
- Concept introduced: nothing new; it mirrors the best-effort contract the writer introduced. The
doc comment (
IUserPreferenceReader.cs:3-8) pins the failure mode: implementations return an emptyUserPreferences(both fields null) for anonymous users or on any error, so a failed read never blocks login.[Rubric §19, State Management & Data Flow]applies, since this is the moment the roaming copy is reconciled against the local one. - Walkthrough
GetAsync(CancellationToken cancellationToken = default)returningTask<UserPreferences>(IUserPreferenceReader.cs:13). There is no failure channel in the signature at all, which is the contract making itself unmistakable.
- Why it's built this way:
ADR-027 and
ADR-028. A
Result<T>here would invite a caller to surface an error the user cannot act on during a login they just completed successfully. - Where it's used: injected by the login page (
Login.razor:14) and read once inApplyStoredPreferencesAndNavigateAsync(Login.razor:228-230), which applies the theme through ThemeService (Login.razor:232-235) and the culture through ICultureApplier (Login.razor:243), skipping the culture round-trip when the stored value already matches the current one (Login.razor:237-238).
ThemeService
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/ThemeService.cs:17· Level 1 · class (sealed)
- What it is: the single owner of the Day/Dark preference (ADR-028). It holds the current mode for
the circuit, persists a change through a small JS module to a cookie plus
localStorage, and raises an event so every subscriber re-renders together. - Depends on: LazyJsModule (single-flight importer for its JS module,
ThemeService.cs:20);Microsoft.JSInterop.IJSRuntime(ASP.NET Core, primary-constructor parameter at line 16); the asset it imports,MMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/theme.js, which owns the cookie andlocalStorageaccess (theme.js:5,23,33).IAsyncDisposable(BCL) is implemented so the module reference is released with the circuit. - Concept introduced, one scoped service as the theme's single source of truth.
[Rubric §20, Design System, Theming & UI Consistency]assesses whether theming is a first-class, centrally owned concern rather than per-page CSS toggling. Here exactly one scoped service holdsIsDarkMode(line 22); the toggle button, theMudThemeProviderwrapper and, on MAUI, the native chrome all read from it and all subscribe toOnChange(line 28). Nothing else stores a copy.[Rubric §19, State Management & Data Flow]is the same fact viewed as state: an event-plus-property service is the framework's pattern for cross-component UI state that is not routed, and the cost of that pattern is unsubscription discipline in every consumer. - Concept introduced, JS interop is not available during prerender.
[Rubric §18, UI Architecture & Component Design]covers the render-mode contract a Blazor component must respect (ADR-056,Website/docs-src/adr/056-blazor-render-mode-strategy.md). Reading a cookie orlocalStoragerequires a live browser, soInitializeAsynccan only run after the first interactive render; the class documents that requirement on itself (ThemeService.cs:11-14) rather than guarding it internally, and the component that owns the lifecycle calls it fromOnAfterRenderAsync(firstRender)(MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MmcaThemeProviders.razor:35). - Walkthrough
ModulePathis the_content/MMCA.Common.UI/theme.jsstatic-web-asset path (line 18), wrapped in a LazyJsModule field (line 19). Two components resolving the same scoped service therefore share one import rather than racing two.IsDarkMode(line 22) andIsInitialized(line 25) are public with a private setter: subscribers read the state, only this class writes it.OnChange(line 28) is a plainEventHandler?.InitializeAsync()(line 34) is idempotent by an early return onIsInitialized(lines 36-39). It imports the module (line 41), asksgetfor the stored value (line 42), and resolves the mode: a stored value wins by an ordinal case-insensitive compare against"dark"(lines 43-44), otherwise it falls back to the OS setting throughsystemPrefersDark(line 45), which readsprefers-color-scheme(theme.js:33-35). Only then does it setIsInitializedand raiseOnChange(lines 47-48), so the first notification carries the resolved answer, not the default.SetDarkModeAsync(bool)(line 53) writes the field first, then persists throughset(line 57), then notifies (line 58). The JS side writes a non-HttpOnly cookie with a one-yearmax-ageandsamesite=laxand mirrors it tolocalStorage, guarding the mirror in atrybecause private browsing can refuse storage (theme.js:23-31). The cookie is deliberately readable by the server, which is how SSR can paint the right theme on the first response (theme.js:1-2).ToggleAsync()(line 62) isSetDarkModeAsync(!IsDarkMode), the entire body of the app-bar toggle's click handler.DisposeAsync()(line 67) forwards to the module wrapper, which is where the guarded release of a torn-down circuit'sIJSObjectReferencelives.
- Why it's built this way: ADR-028 (
Website/docs-src/adr/028-dark-theme-mode.md) requires the preference to survive a reload and to be visible to the server for a no-flash first paint, which is why the value goes to a cookie andlocalStoragerather than to component state, and why the service holds noMudThemeof its own: it publishes a boolean and lets the theme providers decide what that means visually. Not gatingInitializeAsynconRendererInfois also deliberate and pinned: the prerender test uses thegetinvocation as proof that the first render ran at all (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Theme/MmcaThemeProvidersPrerenderTests.cs:29-33). - Where it's used: registered by
AddUISharedas scoped (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:119) and consumed byMmcaThemeProviders, which subscribes inOnInitialized, initializes on first render and unsubscribes on dispose (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MmcaThemeProviders.razor:28,35,119), byThemeToggle(MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/ThemeToggle.razor:2,7), by the MAUI head'sNativeThemeSync, which mirrors the in-app choice onto the native chrome (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Components/NativeThemeSync.razor:17,41,52), and by the login flow, which applies a returning user's stored theme (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:232-234). Hosts that compose their own DI register it directly, for example MMCA.Helpdesk (MMCA.Helpdesk/Source/Hosts/UI/MMCA.Helpdesk.UI.Web/Program.cs:23). Behavior is pinned through the two components that drive it, MmcaThemeProvidersTests and ThemeToggleTests, and end to end byDarkModeE2ETests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Layout/DarkModeE2ETests.cs:54). - Caveats: no test file in
MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Servicesis named for this type; its behavior is covered only through those components, so a change toInitializeAsyncsurfaces as a component-test failure rather than a direct one.OnChangeis a plain event with no weak-reference handling, so a subscriber that fails to unsubscribe outlives its component for the life of the circuit; both in-framework subscribers unsubscribe on dispose and both have a test asserting it.SetDarkModeAsyncsetsIsDarkModebefore the JS write completes, so a failedsetleaves the in-memory mode and the persisted mode disagreeing until the next initialize; nothing in source reconciles that.
DataAnnotationsModelValidator
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/DataAnnotationsModelValidator.cs:21· Level 1 · class (sealed)
- What it is: the in-box IModelValidator, backed by
System.ComponentModel.DataAnnotations. It validates one property against the attributes declared on the model and localizes every message it produces, so the rules a shared request or form model already carries are the only place those rules are written and markup stops repeatingRequired/MaxLengthper field. - Depends on: first-party: IModelValidator (the contract it implements, line 21), and it executes rules such as AbsoluteUrlAttribute. Externals:
System.ComponentModel.DataAnnotations(Validator,ValidationContext,ValidationResult),System.Reflection(PropertyInfo,BindingFlags,AmbiguousMatchException),Microsoft.Extensions.Localization(IStringLocalizer,LocalizedString). - Concept introduced, message-as-resource-key with pass-through fallback.
[Rubric §27, Internationalization & Localization]assesses whether user-facing text resolves per culture from one catalog. Every message this validator produces is looked up against the injectedIStringLocalizer(line 149) and returned as the raw message whenlocalized.ResourceNotFound(line 150). That single line is what makes the design safe to adopt incrementally: a model can declareErrorMessage = "Some.Resource.Key"and get a localized string, or declare plain English and get plain English, with no flag to set. See ADR-027 for the localization model this plugs into.[Rubric §24, Forms, Validation & UX Safety]assesses single-declaration rules; the model's attributes are the declaration and this class is the only executor.- Reflection that fails silently, on purpose.
TryResolveOwnerreturns false when a link in a dotted path is null or the member does not exist (lines 95-98, 106-110). The comment (lines 77-79) gives the reasoning: an unreachable path carries no rules, so it cannot fail, and a partially built model never throws mid-keystroke. A validator that threw while the user was typing would be worse than one that says nothing. - A real reflection edge case is handled rather than ignored.
FindProperty(lines 118-129) catchesAmbiguousMatchExceptionand retries withBindingFlags.DeclaredOnly, because anew-shadowed property matches twice underFlattenHierarchy; the comment (line 126) records the tie-break rule, the most-derived declaration is the one bound in markup.
- Walkthrough
PropertyLookup(lines 23-24) is the sharedBindingFlagsset:Public | Instance | FlattenHierarchy.- The constructor (lines 36-41) requires an
IStringLocalizerand null-guards it. The parameter doc (lines 31-35) tells callers to pass the page's ownIStringLocalizer<TResource>precisely so that unknown keys fall through unchanged. Validate(object model, string propertyPath)(lines 44-55) is the IModelValidator implementation: guard, resolve the owner andPropertyInfo, return an empty array when unresolvable (line 51), then validate the value the model currently holds viaproperty.GetValue(owner)(line 54).ValidateValue(object model, string propertyPath, object? value)(lines 66-74) is the sibling used when the candidate value has not been written to the model yet, which is the case for the single-field bridge ModelValidation.ForProperty.TryResolveOwner(lines 81-116) isinternal staticso ModelValidation.IsRequiredcan reuse it (ModelValidation.cs:97). It splits the path on.withRemoveEmptyEntries(line 90) and walks segment by segment, returning the last segment'sPropertyInfoplus the object that declares it (lines 100-104).[NotNullWhen(true)]on bothoutparameters (lines 84-85) is what lets callers dereference them without a null check after a true return.ValidateResolved(lines 131-140) builds aValidationContext(owner) { MemberName = property.Name }, callsValidator.TryValidateProperty(line 135), and projects the results throughLocalize, dropping empties (lines 137-139).Localize(lines 142-151) is the resource-key resolution described above.
- Why it's built this way: reusing the BCL validator rather than writing a rule interpreter means every DataAnnotations attribute (in-box or custom, such as AbsoluteUrlAttribute) works with no registration. Splitting
ValidatefromValidateValueis the difference between "check what the model holds" and "check what the user just typed", and both are needed because MudBlazor's two binding styles deliver the value at different times. - Where it's used: constructed inline on a page over that page's localizer and handed to ModelValidation
.For, exactly as NotificationSend does (NotificationSend.razor.cs:66).
ApiUserPreferenceReader
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/ApiUserPreferenceReader.cs:14· Level 2 · class (sealed)
- What it is: the default IUserPreferenceReader. It GETs
auth/preferenceswith the signed-in user's bearer token and hands back the culture and theme the user chose on some other device, or empty preferences when there is nothing to read. - Depends on: IUserPreferenceReader (the contract,
ApiUserPreferenceReader.cs:16), UserPreferences (the two-nullable-field record it returns, line 18), ITokenStorageService (primary-constructor parameter, line 15), JwtTokenInfo (the freshness check, line 31);IHttpClientFactoryandSystem.Net.Http.Json(BCL) for the named"APIClient". - Concept introduced, a best-effort read that cannot fail its caller.
[Rubric §6, CQRS & Event-Driven Design]assesses whether a secondary concern is prevented from changing the outcome of the primary operation. Applying a stored preference is a nicety attached to login; a network hiccup while reading it must not turn a successful sign-in into an error page. This class encodes that as a type-level promise:GetAsyncreturnsUserPreferencesrather than a Result, and there is no path out of it that reports a failure. That is the posture ADR-096 records for side effects generally (Website/docs-src/adr/096-best-effort-side-effects.md), applied on the read side.[Rubric §26, Front-End Security]also applies, in a small but real way: the class refuses to spend a round trip on a token it can already see is stale. - Walkthrough
- Two statics carry the whole configuration.
Emptyis a single sharednew UserPreferences(null, null)(line 18), so the failure paths allocate nothing, andExpirySkewisTimeSpan.FromSeconds(30)(line 21) with a comment stating why the value is duplicated here: it must agree with the token-storage layer that does the refreshing, or the two would disagree about when a token is still usable. GetAsync(CancellationToken)(line 24) reads the access token (line 26) and gates onJwtTokenInfo.IsFresh(token, ExpirySkew)(line 31). The comment (lines 28-30) spells out the two cases this covers: an expired or unreadable token buys a guaranteed 401, andIsFreshalso covers the anonymous (null) case. Both returnEmpty(line 33).- The request itself is three lines: resolve the named
"APIClient"(line 38), which already carries the bearer andAccept-Languagehandlers (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:105-106), thenGetFromJsonAsync<UserPreferences>against the relative URIauth/preferences(lines 39-41). - Null-coalescing on the deserialized value (line 42) means a body of literal
nullis the same as no preference. - Two catch blocks,
HttpRequestException(line 44) andTaskCanceledException(line 48), both returnEmpty. Note what is not caught: anything else, including aJsonExceptionfrom a malformed body, still escapes, so a contract break is loud while a transport failure is quiet.
- Two statics carry the whole configuration.
- Why it's built this way: ADR-027 (
Website/docs-src/adr/027-multi-locale-i18n.md) and ADR-028 make the stored culture and theme a per-user server-side value so the choice follows a user between devices, and login is the one moment where reading it is worth a round trip. Catching narrowly and returning a shared empty record is what makes the reconciliation safe to await unconditionally in the login flow. It is the read half of a pair: ApiUserPreferenceWriter is the write half, and the two are registered together (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:134-135). - Where it's used: registered by
AddUISharedwithTryAddScoped(DependencyInjection.cs:135) and injected by exactly one page, the framework's login page (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:14). ItsApplyStoredPreferencesAndNavigateAsynccallsGetAsync, applies a stored theme through ThemeService, and, when the stored culture differs from the current one, hands the rest of the navigation to ICultureApplier, which owns the head-specific switch (Login.razor:228-248). - Caveats: no test under
MMCA.Common/Testsnames this type, while its sibling writer has ApiUserPreferenceWriterTests; the reader's guard and its two catch paths are unpinned. BecauseTaskCanceledExceptionis caught unconditionally, a caller that cancels its own token receivesEmptyrather than anOperationCanceledException, which is the opposite of the convention HttpResultExecutor enforces elsewhere in this package; the single caller passes no token (Login.razor:230), so the difference is invisible in current use.
MMCATheme
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MMCATheme.cs:9· Level 2 · class (static)
What it is: the single application-wide MudBlazor
MudThemeinstance, defining the brand palette (light and dark), typography, and layout radius, applied once viaMudThemeProviderin the root layout.Depends on: BrandColors (the palette source of truth); MudBlazor (NuGet:
MudTheme,PaletteLight,PaletteDark,Typography,LayoutProperties).Concept introduced, one theme, accessibility-justified.
[Rubric §20, Design System & Theming]assesses whether an app has a single coherent theme rather than per-page overrides;MMCATheme.Instanceis that one object (line 11), and the dark palette is what drivesMudThemeProvider'sIsDarkMode(the comment at lines 50-51 says so explicitly).[Rubric §21, Accessibility]is unusually visible here, because several color choices carry inline WCAG 2.1 AA contrast math:- light
WarningContrastText = "#212121"(line 33), because MudBlazor's default white on amber#F57F17is about 2.65:1 and failed the gated admin-order-list axe scan on a "Pending Payment" chip; dark text is about 7.9:1 (lines 29-32); - dark
PrimaryContrastText = "rgba(0,0,0,0.87)"(line 58), because white on the lightened dark-mode primary#42A5F5is about 2.65:1 while dark text is about 6.6:1 (lines 55-57); - dark
WarningContrastText(line 67), white on#FFA726being about 2.0:1 against about 10.8:1 (line 66); - dark
ErrorContrastText(line 71), white on#EF5350being about 3.5:1 against about 5.5:1 (lines 69-70).
The
Secondarycontrast rationale is deliberately not repeated here: line 21 points at BrandColors, where the value and its justification live together.- light
Walkthrough: a single
static MudTheme Instance { get; }(line 11) initialized with four blocks.PaletteLight(lines 13-47) reads its primary and secondary triads straight from BrandColors (lines 18-24), sets the semantic colors (Tertiary,Info,Success,Warning,Error, lines 25-34), and then fixes app chrome: appbar#1A2035, background#FAFBFC, surface white, the drawer tones, text and divider values (lines 35-46).PaletteDark(lines 48-84) lightens the primary for contrast on dark surfaces (Primary = BrandColors.PrimaryLight, line 52), keeps the same appbar and drawer chrome so the shell reads identically in both modes (lines 72-78), and darkens the surface stack (Background = "#1A2027",Surface = "#27303A", lines 74-75) with light text and dark dividers (lines 79-83).Typography(lines 85-163).Defaultsets the font stackInter, Segoe UI, Helvetica Neue, Arial, sans-serif(line 92); the comment above it (lines 89-91) records that Inter is self-hosted by this RCL (wwwroot/fontsplus an@font-faceblock inwwwroot/app.css) and that before those faces existed the stack silently fell through to Segoe UI, so the two must stay in step.H1throughH4(lines 98-125) use display weights 800/800/700/700 with slight negative letter spacing, which the comment (lines 94-97) explains is how Inter is meant to be set at large sizes;H5andH6stay at weight 600 with no negative tracking (lines 126-137).Subtitle1/Subtitle2sit at weight 500 (lines 138-145),Body1/Body2set line heights 1.6 and 1.5 (lines 146-153), andButton(lines 157-162) setsTextTransform = "none", because MudBlazor's default uppercasing wrecks localized strings (German compounds, accented capitals) and reads dated; weight 600 keeps the label as prominent as the shouting did (comment, lines 154-156).LayoutPropertiessetsDefaultBorderRadius = "6px"(lines 164-167).
Why it's built this way: a static get-only property means the theme is constructed once and shared by every
MudThemeProvider. Sourcing the brand hues from BrandColors rather than re-typing hex is what letsBrandColorTokenTestspolice C# versus CSS drift, and the per-color contrast comments turn accessibility decisions into reviewable source rather than tribal knowledge. The button-casing override is a small but instructive case of[Rubric §27, Internationalization & Localization]reaching into theming: a purely visual default became a localization problem.Where it's used: applied in the root layout of the Blazor Web and MAUI hosts via
MudThemeProvider Theme="MMCATheme.Instance".
ModelValidation
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/ModelValidation.cs:26· Level 2 · class (static)
- What it is: the bridge that turns a form model's declared rules into the delegate MudBlazor's field
Validationparameter expects, so a page declares its rules once (on the model) instead of scatteringRequiredandMaxLengthacross the markup and re-checking them by hand. - Depends on: first-party: IModelValidator (the pluggable engine taken by
For) and DataAnnotationsModelValidator (taken concretely byForProperty, and reused via itsinternal staticTryResolveOwnerinIsRequired, line 97). Externals:System.Linq.Expressions(Expression<Func<,>>,MemberExpression,UnaryExpression,ParameterExpression),System.ComponentModel.DataAnnotations(RequiredAttribute),System.Reflection(PropertyInfo). - Concept introduced, adapting a model's rules onto a UI library's callback shape.
[Rubric §24, Forms, Validation & UX Safety]assesses whether validation is declared once and enforced consistently. MudBlazor's contract is a delegate; DataAnnotations' contract is attributes on a type. This class is the two-line adapter between them, and the usage block in the doc comment (lines 14-24) is the canonical example a page copies:_validate = ModelValidation.For(_model, new DataAnnotationsModelValidator(L))inOnInitialized, thenValidation="@_validate"on every field.- One delegate serves the whole form.
Forreturns a closure that ignores nothing and dispatches on the path MudBlazor passes (line 48), so a fifteen-field form still assigns the same single delegate to every field. The fallbackinstance ?? modelon that line is the small robustness detail: MudBlazor normally passes its ownMudForm.Modelback, and the captured instance covers the case where it does not, so a field still validates outside a form (parameter doc, lines 33-36). [Rubric §15, Best Practices & Code Quality]assesses rename safety.ForPropertynames the property by expression rather than by string (line 71), so a rename becomes a compile error instead of a silently dead rule.[Rubric §21, Accessibility]:IsRequiredexists so a field'sRequiredparameter (the asterisk and thearia-requiredaffordance) can be read off the same model that supplies the rules, rather than being typed a second time in markup where it can drift from the rule (doc, lines 83-87). It is explicitly not a second rule: MudBlazor's own required message is unused when aValidationdelegate is present, so the localized message from the model is the one shown.
- One delegate serves the whole form.
- Walkthrough
For(object model, IModelValidator validator)(lines 43-49): null-guards both arguments, then returns(instance, propertyPath) => validator.Validate(instance ?? model, propertyPath)(line 48). This is the model-wide bridge, and it is what almost every page wants.ForProperty<TModel, TValue>(TModel model, Expression<Func<TModel, TValue>> property, DataAnnotationsModelValidator validator)(lines 69-81): resolves the dotted path once at setup viaGetPropertyPath(line 79) and returnsvalue => validator.ValidateValue(model, path, value)(line 80). Note the parameter type is the concrete validator, not the interface: the doc (lines 55-58) says why, the value being validated has not necessarily been written to the model yet, so the rules must come from DataAnnotations directly rather than from an arbitrary engine reading the model's current state.IsRequired(object model, string propertyPath)(lines 92-99): resolves the property through DataAnnotationsModelValidator.TryResolveOwnerand reportsproperty.IsDefined(typeof(RequiredAttribute), inherit: true)(line 98).GetPropertyPath<TModel, TValue>(lines 109-133) renders an expression as the dotted path MudBlazor'sForwould produce. It first unwraps theConvertnode the compiler inserts whenTValueis a value type boxed to object (lines 114-116, with the comment saying so), then walksMemberExpressionlinks pushing each name onto aStack<string>(lines 118-123), which reversesm => m.Address.CityintoAddress.Cityonstring.Join(line 132). Anything that is not a chain of property accesses rooted at the lambda parameter throwsArgumentExceptionwith a message showing the expected shape (lines 125-130).
- Why it's built this way: a static class with no state, because the bridge is pure translation. Offering both a model-wide and a single-field entry point matches the two ways MudBlazor fields actually bind, and pushing the expensive part (expression parsing) into setup rather than into the per-keystroke delegate keeps validation cheap on the typing path.
- Where it's used: NotificationSend builds its
_validatedelegate withFor(NotificationSend.razor.cs:66) and reads its twoRequiredaffordances withIsRequired(NotificationSend.razor:47,58). It is public API ofMMCA.Common.UI, so consumer app forms use the same bridge.
AuthenticatedServiceBase
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:15· Level 1 · class (abstract)
- What it is: the base class every UI-side HTTP service inherits. It supplies one shared Polly
retry policy, two ways to get an
HttpClientwith a bearer token already attached, and the idempotency-key generator that makes those retries safe. - Depends on: ITokenStorageService, imported from the token namespace
MMCA.Common.UI.Services.Auth.Tokens(AuthenticatedServiceBase.cs:3); its documentation also names AuthDelegatingHandler as the thing it deliberately bypasses andAuth.Tokens.ITokenRefresher, that is ITokenRefresher, as the source of the replay token. Externals:IHttpClientFactory,PollyandPolly.Retry. - Concept introduced, why a base class and not just the handler pipeline.
[Rubric §29, Resilience & Business Continuity]assesses whether transient failures are absorbed rather than surfaced; the retry policy is that. The sharper teaching is in theCreateAuthenticatedClientAsyncdoc comment (AuthenticatedServiceBase.cs:45-50):IHttpClientFactorycreates its handlers in a separate DI scope from the Blazor circuit, so aDelegatingHandlercannot reach the circuit'sIJSRuntimeto read the in-memory access token. The base class works around that by reading the token from the circuit-scoped storage service itself and setting the header directly.[Rubric §9, API & Contract Design]covers the idempotency half: retrying a POST is only safe if the server can recognize the repeat, which is what ADR-017 provides. - Walkthrough
RetryPolicyis aprotected static readonly AsyncRetryPolicy<HttpResponseMessage>built once from the shipped backoff (AuthenticatedServiceBase.cs:25), so one policy instance serves the whole app rather than one per service instance per circuit.ApiClientNamepins the named client to"APIClient"(AuthenticatedServiceBase.cs:27), and both constructor arguments are null-checked into private fields (AuthenticatedServiceBase.cs:29-30).NewIdempotencyKey()(AuthenticatedServiceBase.cs:43) returns a compactGuid.NewGuid().ToString("N"), and its remarks (AuthenticatedServiceBase.cs:35-41) carry the load-bearing rule: the value is generated once per logical operation and reused across every retry attempt, because the server-side idempotency filter keys its cached response off it. Generating a new key per attempt would defeat the dedup entirely and let a retry create a duplicate record.CreateAuthenticatedClientAsync()(AuthenticatedServiceBase.cs:51) resolves the"APIClient"(AuthenticatedServiceBase.cs:53), reads the token (AuthenticatedServiceBase.cs:57), setsAuthorization: Bearerwhen non-blank (AuthenticatedServiceBase.cs:58-62), and catchesInvalidOperationExceptionto proceed without a token during SSR prerender, when JS interop is unavailable (AuthenticatedServiceBase.cs:64-67).CreateClientWithToken(string accessToken)(AuthenticatedServiceBase.cs:80) is the replay path. Its doc comment (AuthenticatedServiceBase.cs:72-78) explains why it exists: after the API answers401, the stored token still looks fresh by the client clock, so re-reading storage would just resend the token the server has already rejected. The caller passes the token it acquired straight from ITokenRefresher; the method rejects a blank one (AuthenticatedServiceBase.cs:82) and stamps the header unconditionally (AuthenticatedServiceBase.cs:85).IsRetryableResponse(AuthenticatedServiceBase.cs:100) is the retry predicate. It first excludes501 Not Implementedand505 HTTP Version Not Supported(AuthenticatedServiceBase.cs:102-105) because, as the remarks say (AuthenticatedServiceBase.cs:94-99), those are permanent verdicts and retrying only burns the budget and delays the error the caller needs to see. It then accepts anything>= 500plus408 Request Timeoutand429 Too Many Requests(AuthenticatedServiceBase.cs:107-108), the two codes where the server is explicitly inviting a later attempt.DefaultBackoff(AuthenticatedServiceBase.cs:114-116) is the shipped schedule:2^attemptseconds (2s, 4s, 8s) plus up to one second of random jitter, so a fleet of clients does not re-converge on the same instant. TheS2245/CA5394suppression around it (AuthenticatedServiceBase.cs:111) documents that the randomness only spaces retries and feeds no security decision.BuildRetryPolicy(Func<int, TimeSpan> backoff)(AuthenticatedServiceBase.cs:131-134) isinternaland backoff-injectable so a test can exercise the disposal contract without waiting out the real delays. ItsonRetrydisposes the retried attempt's response (AuthenticatedServiceBase.cs:134); the remarks (AuthenticatedServiceBase.cs:123-130) explain that Polly hands the caller only the final outcome, so without this every intermediate 5xx, 408 or 429 response leaks its content buffer and keeps its connection out of the handler pool until finalization, exactly under the sustained backend failure the retries exist to survive. A retriedHttpRequestExceptioncarries no result, hence the null-conditional, and the final response is not disposed here because the caller owns it.
- Why it's built this way: the DI scope mismatch is a real Blazor Server constraint, not a preference, so the workaround has to live somewhere every service shares. The retry ceiling and jitter line up with the resilience posture in ADR-009.
- Where it's used: inherited by
EntityServiceBase<TEntityDTO, TIdentifierType>
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:47), ChildEntityServiceBase (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:22) and NotificationInboxService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:34), and through them by every module-level UI service in the consumer apps. The members show up asNewIdempotencyKey()on writes (EntityServiceBase.cs:162),RetryPolicy.ExecuteAsyncaround each call (EntityServiceBase.cs:338andEntityServiceBase.cs:366) andCreateClientWithTokenon the 401 replay (NotificationInboxService.cs:148).
NotificationSendModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSendModel.cs:19· Level 1 · class (sealed)
- What it is: the two-property form model for the push-notification compose page. Its DataAnnotations are the single declaration of that form's field rules.
- Depends on: first-party: SendPushNotificationRequest (the endpoint contract, whose length constants it reuses, lines 23 and 28). Externals:
System.ComponentModel.DataAnnotations(RequiredAttribute,MaxLengthAttribute). It is consumed by NotificationSend through ModelValidation and DataAnnotationsModelValidator. - Concept introduced, one number shared by the cap, the message, and the server invariant. The lengths are not declared here.
MaxLength(SendPushNotificationRequest.TitleMaxLength)(line 23) andMaxLength(SendPushNotificationRequest.BodyMaxLength)(line 28) point at the shared request contract, which fixes them at 200 and 2000 (MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/PushNotifications/SendPushNotificationRequest.cs:15,21). The same constants drive the input cap and the character counter in the markup (NotificationSend.razor:49-50,61-62), and the server-side validator enforces the same numbers.[Rubric §24, Forms, Validation & UX Safety]assesses whether client and server agree. Here they cannot disagree, because there is one literal and everything else is a reference to it.[Rubric §9, API & Contract Design]assesses whether contract facts live with the contract; putting the length constants on the request record (the type the endpoint binds) rather than on the form model is what makes the sharing possible in the first place.[Rubric §27, Internationalization & Localization]: eachErrorMessageis a resource key ("Notif.Send.Field.Title.Required", line 22, and its three siblings), resolved by the page's localizing DataAnnotationsModelValidator per ADR-027. This is the concrete case the pass-through localization in that validator was designed for.
- Walkthrough: two mutable
stringproperties, both initialized tostring.Emptyso a fresh model binds cleanly.Title(line 24) carries[Required]with keyNotif.Send.Field.Title.Required(line 22) and[MaxLength(200)]with keyNotif.Send.Field.Title.MaxLength(line 23).Body(line 29) carries the matching pair,Notif.Send.Field.Message.Required(line 27) andNotif.Send.Field.Message.MaxLengthwith the 2000-character cap (line 28). - Why it's built this way: a settable class rather than a record with
initaccessors, because MudBlazor two-way binding (@bind-Value="_model.Title") writes back into the instance. It is a separate type from SendPushNotificationRequest because the form model is mutable and carries presentation rules, while the request record is the immutable wire contract; the page maps one to the other in a single line (NotificationSend.razor.cs:110). - Where it's used: held as
private readonly NotificationSendModel _model = new()by NotificationSend (NotificationSend.razor.cs:35) and bound by both fields in its markup.
BlazorCspPolicyProvider
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Security·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:24· Level 2 · class (internal, sealed)
- What it is: the Content-Security-Policy provider for a Blazor Web host. It computes one CSP string at construction, pinning
connect-srcto'self'plus the configured API or Gateway origin (https and its matching WebSocket origin), and hands it to the shared security-headers middleware on every request. - Depends on: first-party: ICspPolicyProvider (the contract it implements, line 24), CspPolicy (the value it returns, a policy string plus an
Enforceflag), SecurityHeadersMiddleware (its only consumer, named in the class doc at line 12), and ApiSettings (the endpoint source, injected asIOptions<ApiSettings>at line 29). Externals:Microsoft.Extensions.Options(IOptions<T>),Microsoft.AspNetCore.Hosting(IWebHostEnvironment),Microsoft.AspNetCore.Http(HttpContext), BCLUri. - Concept introduced, a computed CSP that fails closed.
[Rubric §26, Front-End Security]assesses whether the browser is told which origins may load scripts and open connections. A static CSP cannot express "this deployment's API origin", because that origin is configuration, so the policy is built rather than hard-coded. The two directives that matter for exfiltration are locked:script-src 'self' 'wasm-unsafe-eval'(line 80, where the WASM allowance is what lets the Blazor WebAssembly runtime instantiate) and the computedconnect-src(line 60). The load-bearing decision is what happens when the origin cannot be determined. The provider narrowsconnect-srcto'self', keeps the rest of the policy unchanged, and stays enforced (Enforce: true, line 54). A misconfigured endpoint therefore surfaces immediately as blocked API calls in the browser console rather than as a permissive header that protects nothing and that nobody notices, and the class doc states the reasoning outright: a security response header that quietly stops being enforced is the worse failure mode (lines 16-20).[Rubric §11, Security]assesses the wider defense posture; this class is one control in a chain that also includes the session-cookie auth design and the security-headers middleware, and it is deliberatelyinternal(line 24) so the only supported way to get it is the registration call, not a hand-wirednew.
- Walkthrough
_policy(line 27) is a singleCspPolicyfield computed once. The constructor (lines 29-34) null-guards both injected dependencies and callsBuildCsp(apiOptions.Value, environment.IsDevelopment())(line 33). Because the type is registered as a singleton, this runs exactly once per process.GetPolicy(HttpContext context)(line 37) ignores the context and returns the cached policy, so the per-request cost is a field read.BuildCsp(lines 41-72) resolves the endpoint asapi.WasmApiEndpoint ?? api.ApiEndpoint(line 43). The guard on lines 47-50 rejects a blank value, a non-absolute URI, and any scheme that is not http or https; the comment on lines 45-46 records why the scheme check is not redundant: on Linux a rooted path such as/relative/pathparses as an absolutefile://URI and would otherwise sail throughUri.TryCreate. A rejected endpoint returns the enforcedconnect-src 'self'policy (line 54).- With a valid endpoint it derives
originviaapiUri.GetLeftPart(UriPartial.Authority)(line 58,scheme://host:port), pickswssorwsto match (line 59), and composesconnect-src 'self' {origin} {wsScheme}://{authority}(line 60). The WebSocket origin is there for the SignalR notification hub, so the live push channel is allowed without openingconnect-srcto the world. - Development only (lines 66-69) appends
http://localhost:*andws://localhost:*for Visual Studio Browser Link and Hot Reload, whose ports change per run (comment, lines 62-65); the production policy is untouched. BuildPolicy(lines 78-87) assembles the directive list:default-src 'self', thescript-srcabove plus'unsafe-inline'in Development only (line 80, for the injected Hot Reload bootstrap),style-src 'self' 'unsafe-inline',img-src 'self' data: https:(line 82, deliberately open because profile pictures and content images come from arbitrary external hosts, per the comment on lines 74-77),font-src 'self', the computedconnect-src,base-uri 'self',form-action 'self', andframe-ancestors 'none'(line 87, clickjacking protection).
- Why it's built this way: computing once and caching keeps the hot path free, and returning a CspPolicy record rather than writing a header directly keeps the provider testable and lets one middleware own header emission. Registering it with
AddSingleton(notTryAdd) is what makes it replace the default static provider, which is why the ordering rule in the class doc (lines 21-22) matters: callAddCommonBlazorCsp()beforeAddCommonSecurityHeaders. - Where it's used: registered by
AddCommonBlazorCsp()in theMMCA.Common.UI.WebDependencyInjection (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:39-40); the policy it returns is emitted by SecurityHeadersMiddleware. The class doc notes it was hoisted out of the app Blazor Web hosts where it had been byte-identical (line 21). - Caveats / not-in-source: the registration method's own XML doc still describes the fallback as a "permissive Report-Only fallback on misconfiguration" (
MMCA.Common.UI.Web/DependencyInjection.cs:35). The code is the truth: the fallback is enforced and narrowed to'self'(line 54). Treat that doc line as stale.
HttpResultExecutor
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:31· Level 3 · class (static)
- What it is: the wrapper every UI service call runs inside. It converts the faults that survive
after a response has been handled, a refused connection, a DNS failure, a broken stream, a client
timeout, into failed Result values, so a service method
typed as returning a
Resultreally does return one. - Depends on: Result and its generic sibling
Result<T>, plus Error for the failure it mints (HttpResultExecutor.cs:2,127,130);System.Text.JsonandSystem.Net.Http(BCL) for the fault set it recognizes. It isstaticand holds no state, so anything can call it, including services that derive from none of this package's bases. - Concept introduced, the two halves of "a client call never throws".
[Rubric §9, API & Contract Design]assesses whether the error channel between client and server is explicit and typed. The framework's answer has two pieces and this is the second one. The first, ProblemDetailsResultReader, converts a response: it reads the server's ProblemDetails body back into errors with the original ErrorType intact. This class converts the absence of a response, and the class comment states the split outright (lines 11-16). Only with both does a page get to branch on aResultinstead of writing acatch, which is what ADR-013 asks for (Website/docs-src/adr/013-result-pattern.md) and what ADR-094 records as the client contract (Website/docs-src/adr/094-client-entity-data-access.md:82-94).[Rubric §29, Resilience, Reliability & Business Continuity]applies because this is the boundary where an infrastructure fault stops being an exception and becomes something the UI can render. - Concept introduced, cancellation is not a failure.
[Rubric §24, Forms, Validation & UX Safety]assesses whether the user-facing outcome of an interaction is honest. A page cancels its own work all the time: a disposed component, a grid fetch superseded by the next keystroke. Reporting that back as an error would paint a message for something the user never did. The class therefore distinguishes two identically typed exceptions by inspecting the token, and documents the rule on itself (lines 17-23). - Walkthrough
- Two public constants name the failures it can mint,
TransportErrorCode = "Http.TransportFailure"(line 34) andTimeoutErrorCode = "Http.Timeout"(line 37). They are public because they are the branch a page uses when it needs different wording; the messages themselves are private constants (lines 39-43). ExecuteAsync(Func<Task<Result>>, CancellationToken)(line 52) and its generic twinExecuteAsync<T>(Func<Task<Result<T>>>, CancellationToken)(line 87) are the whole public surface. Both are structurally identical, and both start withArgumentNullException.ThrowIfNull(lines 54,- and
cancellationToken.ThrowIfCancellationRequested()(lines 59, 94). The pre-check is explained in the source (lines 56-58): an already-abandoned call must never reach the network, and the propagation contract has to hold even for an operation that would complete without ever observing the token.
- and
- The
trysimply awaits the caller's operation (lines 63, 98). Everything interesting is in the three catch clauses, and their order is the design. catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)rethrows (lines 65-68, 100-103). This filter is the entire mechanism: the caller's token being cancelled is what identifies the exception as the caller's own.- The unfiltered
catch (OperationCanceledException)(lines 69, 104) is therefore the other case:HttpClientgave up on its own timeout, which raises the same exception type with the token not cancelled. That one becomes a failure carryingTimeoutError()(lines 71, 106). catch (Exception exception) when (IsTransportFault(exception))(lines 73, 108) filters rather than catching broadly, so nothing outside the recognized set is swallowed.IsTransportFault(line 121) admits exactly three types:HttpRequestException,IOExceptionandJsonException(line 122). The comment above it draws the line explicitly (lines 114-119): anything else is a programming fault and keeps travelling as an exception.TransportError(line 124) puts the exception's own text on the error'sSourcerather than itsMessage(lines 125-127), because that text is diagnostic detail: not localizable, and not safe to render verbatim.TimeoutError(line 129) carries no detail at all.
- Two public constants name the failures it can mint,
- Why it's built this way: the class comment records the third consequence of the split (lines 24-29): a transport failure never reached a server, so nothing localized it on the way back, which is why the two messages are English literals and why the codes are public. A page that needs translated wording branches on the code or supplies its own resource key rather than displaying the synthesized message. Keeping the type static and dependency-free is what lets services outside this package's hierarchy, including a consumer app's hand-written client, adopt the same contract with one call.
- Where it's used: it wraps every dispatch in this package,
EntityServiceBase<TEntityDTO, TIdentifierType>
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:332,362), ChildEntityServiceBase (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:37,53,71), AuthUIService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:182,195,216,231,244,267) and NotificationInboxService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:43,61,81,98). Outside the framework it is called directly by services that sit outside the base hierarchy: Store's cart and lookup services (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Services/Cart/CartStateService.cs:101,141,175,207,261,299,MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Services/CustomerLookupService.cs:26,47,93,MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Services/ProductVariantLookupService.cs:26,74) and Helpdesk's single API client (MMCA.Helpdesk/Source/Hosts/UI/MMCA.Helpdesk.UI.Web/Services/HelpdeskApiClient.cs:30,50,64,84,95,109,122,138,149). Its own behavior is pinned by HttpResultExecutorTests, including the code literals (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Api/HttpResultExecutorTests.cs:25-26), the timeout-versus-cancellation split (:150,164,178) and the rethrow paths (:192,206,219,230,240). - Caveats: a
JsonExceptionis classified as a transport fault, so a server that answers 200 with a body the client cannot deserialize produces the same generic "check the connection" message as a refused socket; the distinction is available in the error'sSourcebut not in its code. The two user-facing messages are hard-coded English by design, so a fully localized app still shows them untranslated unless the page branches on the code itself.
ChildEntityServiceBase
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:19· Level 4 · class (abstract)
- What it is: the two-verb service base for join entities, the many-to-many rows a UI can create
and delete but never lists or edits on their own. It offers
PostAsync(in two shapes) andDeleteByIdAsyncover the named"APIClient", and nothing else. - Depends on: AuthenticatedServiceBase (base class, supplying the
authenticated client factory,
ChildEntityServiceBase.cs:22), ITokenStorageService (constructor parameter, passed straight through, line 21), HttpResultExecutor (lines 37, 53, 71), ProblemDetailsResultReader (lines 42, 58, 77), Result and ErrorType (line 2);IHttpClientFactoryandSystem.Net.Http.Json(BCL). - Concept introduced, a base class shaped by the resource rather than by convention.
[Rubric §18, UI Architecture & Component Design]assesses whether the presentation layer talks to the backend through typed services rather than rawHttpClientcalls in components. The interesting design choice here is what is absent: a join row likeSessionSpeakerhas no list page, no edit form and no lookup, so this base deliberately does not implement IEntityService<TEntityDTO, TIdentifierType>. Giving join services the full CRUD surface would hand pages six operations of which four have no endpoint behind them.[Rubric §1, SOLID Principles]reads this as interface segregation applied at the service-base level: the smaller base cannot promise what the API does not serve. - Walkthrough
- The primary constructor takes
IHttpClientFactory,ITokenStorageServiceand astring endpoint, forwarding the first two toAuthenticatedServiceBase(lines 19-22). The endpoint is captured as a primary-constructor parameter rather than exposed as a property, so subclasses cannot rewrite it; contrast EntityServiceBase<TEntityDTO, TIdentifierType>, which surfacesprotected string Endpoint { get; }because its own methods build sub-paths from it. PostAsync<TResponse>(object request, CancellationToken)(line 36) is for an endpoint that answers with the created DTO. Inside aHttpResultExecutor.ExecuteAsyncwrapper (line 37) it creates an authenticated client (line 40), POSTs the payload as JSON to the relative endpoint (line 41), and hands the response toProblemDetailsResultReader.ReadAsync<TResponse>(line 42). Therequestparameter is typedobjecton purpose, and the doc comment explains why (lines 28-33): join payloads are anonymous objects,System.Text.Jsonserializes the runtime type for anobjectdeclaration, and a generic request parameter would force a caller to name a type it cannot spell.PostAsync(object request, CancellationToken)(line 52) is the same call for an endpoint that answers 204, returning a non-genericResultthrough the reader's body-less overload (line 58). The two overloads exist because the reader treats a missing body as a failure on the generic path.DeleteByIdAsync(string id, CancellationToken)(line 70) builds"{endpoint}/{id}"(line 75) and DELETEs it (line 76). A join row that is not there answers 404, which arrives as anErrorType.NotFoundfailure rather than a barefalse, so a caller can still separate "nothing to remove" from "the remove failed" (documented at lines 62-66).- The id parameter is a
string, not a typed identifier: subclasses format their own key before calling. ADC's four join services route it through one helper that also appends the parent key as a query parameter,ChildEntityDeletePath.For(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/ChildEntityServices.cs:76-87), which is why the whole difference between a removal that works and one the API answers 404 to sits in a single place.
- The primary constructor takes
- Why it's built this way: join endpoints sit behind
[Authorize]exactly like their parent CRUD endpoints, so they need the same bearer plumbing and the same error contract, but none of the paging, filtering or lookup machinery. Deriving from AuthenticatedServiceBase rather than from EntityServiceBase<TEntityDTO, TIdentifierType> reuses the auth path while keeping the surface honest. Two deliberate asymmetries with that sibling are recorded in ADR-094 (Website/docs-src/adr/094-client-entity-data-access.md:95-106): these calls run outsideRetryPolicy, so a join add or remove is single-attempt, andPostAsyncsends noIdempotency-Key, so a duplicate join is stopped by the domain invariant and the unique index behind it rather than by request deduplication (ADR-017,Website/docs-src/adr/017-request-idempotency.md). - Where it's used: four ADC Conference join services derive from it, all in one file,
EventSpeakerService on
eventspeakers, SessionSpeakerService onsessionspeakers, SessionCategoryItemService onsessioncategoryitemsand SpeakerCategoryItemService onspeakercategoryitems(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/ChildEntityServices.cs:22,35,48,61). Each adds a typedAddAsync/DeleteAsyncpair over the two protected methods and implements its own module interface (for example:25-29). The base is pinned by ChildEntityServiceBaseTests. - Caveats: because there is no retry, a transient 503 on a join add surfaces to the user as a
failure that the equivalent CRUD call would have retried away; that is a decision, not an oversight,
but it is invisible from the subclass. Neither
PostAsyncoverload exposes the response headers, so an endpoint answering201 Createdwith aLocationheader gives the caller no way to read it.
EntityServiceBase<TEntityDTO, TIdentifierType>
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:43· Level 4 · class (abstract)
- What it is: the CRUD workhorse of the UI layer. It implements IEntityService<TEntityDTO, TIdentifierType> against a REST endpoint by turning each operation into a URL plus a one-line HTTP lambda, and funnels every one of them through two dispatch methods that own retry, idempotency, conditional writes, error translation and deserialization. An optional read cache sits in front of the four reads.
- Depends on: AuthenticatedServiceBase (base class, line 47),
IEntityService<TEntityDTO, TIdentifierType> (implemented
interface, line 47),
IBaseDTO<TIdentifierType> (the
TEntityDTOconstraint, line 48), BaseLookup<TIdentifierType> (line 120), CollectionResult<T> and PagedCollectionResult<T> with its PaginationMetadata (lines 73, 116, 125), IUiReadCache (optional constructor parameter, line 47), IConcurrencyAware and ConcurrencyETag (lines 197-199, 394), IdempotencyHeaders (line 386), ProblemDetailsResultReader (lines 339, 367), HttpResultExecutor (lines 332, 362) and ITokenStorageService (line 46); Polly through the inheritedRetryPolicy, andSystem.Net.Http.Json(BCL). - Concept introduced, one dispatch point for every cross-cutting HTTP concern.
[Rubric §29, Resilience, Reliability & Business Continuity]assesses whether retry, auth and error handling are applied in one place instead of repeated per call: the six public methods contain only URL construction, and the twoSendRequestAsyncoverloads (lines 324 and 354) contain all of the policy.[Rubric §19, State Management & Data Flow]applies because components never touchHttpClient: they inject the typed interface and receive DTOs wrapped in aResult.[Rubric §29, Resilience, Reliability & Business Continuity]applies through the inherited three-retry exponential-backoff-with-jitter policy (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:25), whose predicate retries 5xx plus 408 and 429 but not 501 or 505 (AuthenticatedServiceBase.cs:100). - Concept introduced, retry safety for a non-idempotent verb.
[Rubric §9, API & Contract Design]assesses whether client and server share an explicit protocol for duplicate writes. A retry policy that re-issues a POST is a correctness hazard: if the first attempt reached the server and only the response was lost, the retry creates a second record.AddAsyncis the one method that passes a key (line 162), minted byAuthenticatedServiceBase.NewIdempotencyKey()as a compact GUID (AuthenticatedServiceBase.cs:43). The key is set as a default request header on the client (line 386) rather than per request, and that one client instance serves every retry attempt, so all attempts carry the identical value (the comment at lines 382-385 says exactly that). The server side is the opt-in IdempotencyFilter, and both ends read the header name from the shared IdempotencyHeaders constant. Reads, full-PUT updates and deletes send no key because they are naturally idempotent (comment at lines 156-158). - Concept introduced, the conditional write.
[Rubric §24, Forms, Validation & UX Safety]assesses whether the UI protects a user from silently overwriting somebody else's work.UpdateAsyncsends the DTO's concurrency token as anIf-Matchentity tag (line 184), and that header is the only route the token travels (remarks at lines 170-175). A DTO carrying no token sends no header, and the server answers428 Precondition Requiredrather than accepting a blind write.ConcurrencyTagOf(line- is the whole rule: a DTO that implements
IConcurrencyAware with a non-empty
RowVersionis formatted byConcurrencyETag.Format, anything else yieldsnull(lines 198-200). This is ADR-035 (Website/docs-src/adr/035-optimistic-concurrency.md) seen from the client.
- is the whole rule: a DTO that implements
IConcurrencyAware with a non-empty
- Concept introduced, a read-through cache keyed by the request URL.
[Rubric §23, Front-End Performance & Rendering]assesses whether the client avoids work it has already done.GetCachedAsync(line 241) is the client half of ADR-040 (Website/docs-src/adr/040-authenticated-output-caching-for-public-reads.md): the relative URL, path plus full query string, is the cache key, deliberately matching the server-side output cache'sQueryKeys = "*"shape, and theCA1054suppression at lines 237-240 exists to keep it a verbatim string rather than a re-encodedSystem.Uri. - Walkthrough
- The primary constructor takes
endpoint,IHttpClientFactory,ITokenStorageServiceand an optional IUiReadCache (lines 43-47); note the parameter order differs from ChildEntityServiceBase. Both type parameters are constrained,TEntityDTO : IBaseDTO<TIdentifierType>andTIdentifierType : notnull(lines 48-49).Endpointis republished as a protected property (line 51) because the read methods append sub-paths to it, andReadCachelikewise (line 58) so a derived service can invalidate a prefix its own custom write touched. With no cache registered, every read goes to the API and the class behaves exactly as it did before the cache existed (constructor docs, lines 37-42). GetAllAsync(includeFKs, includeChildren, ct)(line 61) builds a two-parameter query string (lines 66-72), goes through the cache (line 73), and maps the paged envelope down to its items (line 75). The "all" endpoint answers with the paged envelope, not a bare array.GetPagedAsync(filters, pageNumber, pageSize, sortColumn, sortDirection, includeChildren, ct)(line 79) is the one with real work. Page numbers are formatted withstring.Create(CultureInfo.InvariantCulture, ...)(lines 90-91) so a comma-decimal locale cannot corrupt the query, and every filter property, operator and value goes throughUri.EscapeDataString(lines 103-105). Filters serialize asfilters[Property].operator=plus an optionalfilters[Property].value=, and a filter whose operator is blank is skipped entirely (line 101), which is how a grid clears a column filter. It targets{Endpoint}/paged(line 110) and maps the envelope to the(Items, TotalItems)tuple a server-side data grid binds to (lines 115-116).GetAllForLookupAsync(nameProperty, ct)(line 120) hits{Endpoint}/lookup(line 124) and maps aCollectionResult<BaseLookup<TIdentifierType>>to its items (lines 125-127), the lightweight id-plus-name shape that feeds dropdowns and autocompletes.GetByIdAsync(id, includeChildren, ct)(line 131) is a plain cached GET (line 146). A missing entity is aNotFoundfailure, not a null, and the comment records both halves of why (lines 143-145): the caller can tell it apart from a transport failure via ResultUiExtensions.IsNotFound, and a failure is never cached, so a 404 is re-asked every time.AddAsync(entity, ct)(line 150) POSTs with the idempotency key (lines 159-163) and then callsInvalidateOnSuccess(line 165).UpdateAsync(entity, ct)(line 176) PUTs to{Endpoint}/{GetEntityId(entity)}with theIf-Matchtag (lines 180-185);DeleteAsync(id, ct)(line 203) DELETEs{Endpoint}/{id}(lines 207-211). All three invalidate on success.GetEntityId(entity)(line 217) isprotected virtualand returnsentity.Id, the hook a subclass overrides when the route key is not the DTO's own id.GetCachedAsync<T>(url, ct, bypassCache)(line 241) guards the url (line 246), goes straight to the network when no cache is registered or the caller asked to bypass (lines 248-251), answers from a fresh entry when there is one (lines 253-256), and otherwise fetches and stores. Only a success with a non-null value is stored (lines 262-265), because caching a failure would pin a transient outage in front of the user for the whole TTL and a cached 404 would survive the create that fixed it.bypassCacheexists for a read the user explicitly asked to be current, a refresh button or a re-poll after a push (documented at lines 231-235).InvalidateOnSuccess(line 281) drops this endpoint's cached reads by prefix, and only on success (lines 283-286): a rejected write changed nothing, so invalidating there would throw away entries that are still accurate.AsReadOnlyList(line 293) presents a deserializedItemscollection without assuming the JSON reader produced a list (lines 296-298).SendRequestAsync<T>(line 324) andSendRequestAsync(line 354) are the center of the class and are structurally identical: null-guard the lambda (lines 330, 360), wrap everything in HttpResultExecutor (lines 332, 362), build a client for this one logical operation (lines 337, 365), execute the caller's lambda throughRetryPolicywith the cancellation token threaded in so a cancelled operation does not sleep out its backoff (lines 338, 366), and hand the response to ProblemDetailsResultReader (lines 339, 367). The generic overload fails a 2xx with no body via the reader'sEmptyResponseCode, which is why the body-less overload exists at all (documented at lines 319-323). The client is kept in scope across the read on purpose (comment at lines 335-336).CreateRequestClientAsync(idempotencyKey, ifMatch)(line 376) is where both conditional headers are attached (lines 380-395), each with a comment stating the retry property it preserves: the same key on every attempt, and the same precondition on every attempt so a write that lost the race fails consistently instead of succeeding on a later attempt against a version the caller never saw.
- The primary constructor takes
- Why it's built this way: passing the HTTP call as a
Func<HttpClient, Task<HttpResponseMessage>>lets each verb stay a two-line method while every policy decision lives once. The composition inside the dispatch is the load-bearing part, and ADR-094 records it as the client contract (Website/docs-src/adr/094-client-entity-data-access.md:60-94): the executor outside, the retry policy in the middle, the reader innermost. Nothing here throws for a server answer, so a 404, a validation rejection and a 500 are all failures a page can branch on; the only exception that still escapes is the caller's own cancellation (class comment, lines 25-30). All six public methods arevirtual, so a module service overrides only the one that needs domain-specific behavior and inherits the rest. - Where it's used: it is the base of essentially every module CRUD service. ADR-094's inventory as
of 2026-08-31 counts sixteen production subclasses: nine in ADC Conference including
EventService and
SessionService, six in Store (
ProductService,CategoryService,OrderService,ShoppingCartService,InventoryItemService,CustomerService), and one inside the framework itself, PushNotificationService (Website/docs-src/adr/094-client-entity-data-access.md:107-118). ADC Identity's UserService takes the auth root directly instead. The consumer on the page side is DataGridListPageBase<TDto>, which is handed aGetPagedAsynccall as its fetch delegate. Behavior is pinned by EntityServiceBaseTests, EntityServiceBaseCachingTests and EntityServiceBaseIdempotencyRetryTests, which asserts the key is emitted on creates only and stays identical across attempts. - Caveats:
GetAllAsynchas no page-size bound in source; it asks the "all" endpoint for everything and materializes the result, which is why grids useGetPagedAsyncinstead. The read cache is keyed by URL alone and holds nothing about who fetched it, so it is only safe while IUiReadCache stays a per-circuit registration.GetPagedAsyncaccepts afiltersdictionary that the signature does not declare nullable (line 80), yet the body null-checks it (line 97): defensive against a caller the signature says cannot exist.
NotificationInbox
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationInbox.razor.cs:26· Level 4 · class (partial, page)
- What it is: the code-behind for the per-user notification inbox, routed at both
@page "/notifications/inbox"and@page "/notifications/inbox/{Id:int}"(NotificationInbox.razor:1-2). It fetches the signed-in user's notifications a page at a time, renders each as a read or unread card, lets the user mark items read individually or all at once, highlights and scrolls to a deep-linked notification, and reloads the current page when a real-time push asks for a refresh. - Depends on: first-party: INotificationInboxUIService (the typed read-side HTTP service), NotificationState (the per-circuit unread-count store and refresh signal), UserNotificationDTO (the row shape), IToastService (the toast abstraction), ResultUiExtensions (the
NotifyOnFailurehelper), and SharedResource (the resx anchor for the localizer). Externals:MudBlazor(IScrollManager,ScrollBehavior,BreadcrumbItem,Icons),Microsoft.AspNetCore.Components([Inject],[Parameter],OnInitializedAsync,OnParametersSet,OnAfterRenderAsync,InvokeAsync,StateHasChanged),Microsoft.Extensions.Localization(IStringLocalizer<T>), BCLCancellationTokenSource,IDisposable,Math.Ceiling,CultureInfo.InvariantCulture. - Concept introduced, the Blazor code-behind page pattern (
.razorplus.razor.cspartial class). The three notification pages in this unit are authored as partial classes split across two files: the.razorholds declarative MudBlazor markup and the routes, the.razor.csholds the C# (public partial class NotificationInbox, line 26), the injected services, the view state, and the handlers. The framework constructs the component, callsOnInitializedAsync(line 73) once, and re-renders when a handler mutates a field. Four habits recur across all three pages and are worth learning once here:- Disposal-safe async with a per-component
CancellationTokenSource. Areadonly CancellationTokenSource _cts(line 45) is created with the component and its token is passed to every service call.Dispose(bool)(lines 338-350) cancels and disposes it behind the classic_disposedguard (line 336). Every async handler swallowsOperationCanceledExceptionsilently (for example lines 241-244) because that is the expected outcome when the user navigates away mid-fetch. - Result-typed failures, not exceptions. The service calls return a
Result, so the page branches onTryGetValue(line 220) and routes the failure throughresult.NotifyOnFailure(Toast, L)(line 238). The comment there (lines 236-237) records the rule that makes this safe: one toast, and the list is left as it was rather than blanked, so a transient failure does not erase what is on screen. - Busy flags gate the UI.
IsLoadingandIsSaving(lines 51-52) areprotectedwith private setters; the markup shows progress while loading and setsDisabled="IsSaving"on the action controls (NotificationInbox.razor:18) so a double click cannot double-post. - Push-driven refresh via an event subscription.
OnInitializedAsyncsubscribes toNotificationState.OnRefreshRequested(line 83) andDispose(bool)unsubscribes (line 344). [Rubric §19, State Management & Data Flow]assesses where state lives; transient view state stays in private fields (_notifications,_currentPage,_totalPages, lines 54-56) while the shared unread count is written back into the scoped NotificationState (lines 289, 323) and its refresh signal is read. Local stays local, shared stays shared.[Rubric §25, Navigation, Routing & Information Architecture]assesses route structure. The second@pagedirective is a typed deep link: a push payload or an email can point straight at one notification. The class doc (lines 18-24) states the two design rules that follow from it: the:introute constraint is the validation boundary, so a malformed id never reaches the component (the router rendersNotFoundinstead), and an id that is simply not on the loaded page degrades silently to the plain inbox rather than raising an error the user can do nothing about.[Rubric §21, Accessibility]: the icon-only mark-read control carries an explicit localizedaria-label(NotificationInbox.razor:59).[Rubric §27, Internationalization & Localization]: this page holds no literal English. The injectedIStringLocalizer<SharedResource> L(line 33) resolves the title (line 47), the breadcrumbs, and every toast (L["Notif.AllMarkedRead"], line 324). The breadcrumb trail is built insideOnInitializedAsync(lines 77-81), not in a field initializer, so the injected localizer is available and labels re-resolve per circuit under the active culture (comment, lines 75-76, citing ADR-027).
- Disposal-safe async with a per-component
- Walkthrough
PageSize(line 28) isconst int 20: fixed-size server-side pagination, not infinite scroll. InjectedInboxService,NotificationState,Toast,L, and MudBlazor'sScrollManager(lines 30-34).[Parameter] public int? Id(line 43) is the deep-link route parameter. The doc (lines 36-42) explains the type choice:UserNotificationIdentifierTypeis anintalias, and a route parameter's type must be written out for the:intconstraint to bind, so the parameter is declaredint?and converted where it is used.- Deep-link state is four separate fields, and each exists for a distinct reason (lines 62-71):
_highlightedId(found on the loaded page),_pendingScrollId(a scroll the next render owes),_scrolledId(already scrolled, so a re-render or push-driven reload never scrolls twice), and_appliedId(theIdthe state was last computed for, to detect a re-navigation). OnParametersSet(lines 94-103) clears the_scrolledIdlatch when_appliedId != Idand then recomputes the target. The doc (lines 88-93) records the bug this prevents: navigating from/notifications/inbox/5to/notifications/inbox/9reuses the component instance, so without clearing the latch the second deep link would highlight but never move the viewport.OnAfterRenderAsync(lines 106-118) is the only place a scroll happens. It returns unless a scroll is pending and the component is alive, clears_pendingScrollIdand sets_scrolledIdbefore the await (comment, line 113, so a re-entrant render cannot queue the same scroll twice), and callsScrollManager.ScrollIntoViewAsync(CardSelector(id), ScrollBehavior.Smooth)(line 117).ApplyDeepLinkTarget(lines 126-140) matches the route id against what the loaded page actually holds:Id is not { } id || id <= 0 || !_notifications.Exists(n => n.Id == id)clears both the highlight and the pending scroll (lines 128-133). The doc (lines 120-125) is the "no toast" decision, a deep link the user cannot act on is not an error they caused.- The card-chrome helpers:
IsDeepLinkTarget(line 142);CardElementId(lines 144-147, formattingnotification-{id}withCultureInfo.InvariantCulturebecause it becomes a DOM id) andCardSelector(line 149);CardElevation(lines 152-160, 4 when deep-linked, else 1 for unread and 0 for read);CardClass(lines 162-168); andCardStyle(lines 175-183), which builds the unread left border and the deep-link ring from MudBlazor palette variables (var(--mud-palette-primary),var(--mud-palette-secondary)) rather than literal hex, so both themes stay legible (comment, lines 170-174).[Rubric §20, Design System & Theming]shows up here: even inline styles source their colors from the theme's CSS custom properties. - Push coalescing.
HandleRefreshRequested(lines 185-193) isEventHandler-shaped so it cannot beasync Task; it discards intoInvokeAsync(RefreshFromPushAsync)(line 192).RefreshFromPushAsync(lines 195-212) sets_refreshPending = trueand returns when a load is already in flight (lines 202-208); the comment (lines 204-205) states the invariant, never drop the push, so overlapping pushes coalesce into exactly one trailing reload instead of vanishing. LoadNotificationsAsync(lines 214-258): setsIsLoading, callsGetInboxAsync(_currentPage, PageSize, _cts.Token)(line 219), and on success materializespage.Items(line 222), computes_totalPagesfrompage.PaginationMetadata.TotalItemCountwithMath.Ceiling(line 223) clamped to a floor of 1 (lines 224-227) so an empty inbox never renders a zero-page pager, then recomputes the deep-link highlight (line 232, only a successful load can decide whether the id is present). The tail (lines 253-257) drains_refreshPendingwith one moreRefreshFromPushAsync; the comment (lines 250-252) explains why the recursion is bounded, the flag is cleared first, so a push arriving during this reload queues one more and no further.OnPageChangedAsync(int page)(lines 260-264): records the page and reloads.MarkReadAsync(UserNotificationDTO)(lines 266-300): callsMarkReadAsync(notification.Id, _cts.Token)(line 271), returns early on failure after a toast (lines 272-276), then optimistically patches local state, locating the row withFindIndex(line 279) and replacing it via arecord with-expression,notification with { IsRead = true, ReadOn = DateTime.UtcNow }(line 282). It then refetches the authoritative unread count (line 286) and pushes it intoNotificationState.SetUnreadCountonly when the count call succeeded (lines 287-290); a failed count means "unknown", so the badge keeps its value (comment, line 285).MarkAllReadAsync(lines 302-334): one service call (line 307), a loop flipping every unread row in place (lines 315-321), thenSetUnreadCount(0)(line 323) and a localized success toast (line 324).- Disposal:
_disposed(line 336),Dispose(bool)(lines 338-350) unsubscribing the refresh event and cancelling the_cts,Dispose()(lines 352-356) withGC.SuppressFinalize.
- Why it's built this way: the page is a thin view over INotificationInboxUIService, so all HTTP and JSON live in the service and the component stays testable against a stub. Patching local state after a mark-read (rather than refetching the page) keeps the interaction snappy while still reconciling the shared badge from the server, and the coalescing refresh keeps the list current when pushes arrive in bursts. The deep-link machinery is worth reading as a case study in idempotent side effects: a scroll is a one-shot action in a component model that re-renders freely, so it needs the "owed" and "already done" flags to be correct under re-render, re-navigation, and disposal.
- Where it's used: rendered at
/notifications/inbox(and/notifications/inbox/{Id:int}) for authenticated users; the route constant and nav entry come from NotificationRoutePaths and NotificationUIModule. NotificationBell reads the same NotificationState this page writes, and the layout-mountedNotificationListenerraises theOnRefreshRequestedsignal it consumes. Its admin siblings are NotificationList and NotificationSend.
ServerTokenStorageService
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:18· Level 4 · class (sealed)
- What it is: the Blazor Server implementation of ITokenStorageService: a cookie-only token store with no
localStorage. During SSR prerender it reads the access token from the HttpOnly session cookie; on the live interactive circuit it holds the access token in memory only and re-acquires it from those cookies through a same-origin refresh endpoint. The refresh token is never readable from JavaScript. - Depends on: first-party: ITokenStorageService (the contract, line 21), CookieTokenReader (reads the access and refresh cookies off the request), ISessionCookieSync (seeds and clears the HttpOnly cookies), ITokenRefresher (acquires a fresh access token from
/auth/session/token), and JwtTokenInfo (client-side freshness check). Its WASM sibling is WasmTokenStorageService (named in the class doc, line 14). Externals:Microsoft.AspNetCore.Http(IHttpContextAccessor,HttpContext), BCLLock,Task,TimeSpan. - Concept introduced, the two-world token store (SSR request versus interactive circuit). A Blazor Web page runs twice: first as a server-side prerender inside a live HTTP request, where an
HttpContextexists and JS interop does not, and then as a stateful circuit with noHttpContext. One store has to serve both worlds, and this class branches onhttpContextAccessor.HttpContext is not null(line 34) to decide which source of truth applies.- SSR prerender (lines 34-37): the request's HttpOnly cookie wins, read via
cookieTokenReader.ReadAccessToken()(line 36), because the middleware may have just refreshed it in place on this navigation (comment, lines 32-33). - Interactive circuit (lines 39-71): the token lives in the
_accessTokenfield (line 27). IfJwtTokenInfo.IsFresh(_accessToken, ExpirySkew)(line 40) says it survives the 30-second skew (line 23), it is returned as is; otherwise it is re-acquired. [Rubric §26, Front-End Security]assesses how credentials are held in the browser. This is a deliberate XSS-hardening design: the long-lived refresh token stays in an HttpOnly cookie unreachable from script, the access token exists only in circuit memory and is never persisted, and the refresh token transits JS exactly once, for the same-origin POST that seeds the cookies at login (SetTokensAsync, lines 81-87).[Rubric §11, Security]assesses the wider auth model; this store is one edge of the browser session-cookie design (ADR-022), the piece that decides where a bearer token is read on each side of the prerender boundary.[Rubric §12, Performance & Scalability]shows up in the single-flight hydrate: a Blazor circuit is genuinely multi-threaded, and several consumers (the delegating handler, the auth-state provider, the SignalR connection) can all miss the cache at once.
- SSR prerender (lines 34-37): the request's HttpOnly cookie wins, read via
- Walkthrough
- The primary constructor (lines 17-21) takes
IHttpContextAccessor, CookieTokenReader, ISessionCookieSync, and ITokenRefresher. The type issealedand carries no app-specific state, which is why it could be hoisted out of both app hosts (class doc, lines 13-15). - Fields:
ExpirySkew(line 23, astatic readonly TimeSpanof 30 seconds), theLock _hydrateSync(line 25, the .NET 9+ dedicated lock object), the in-memory_accessToken(line 27), and_hydrateInFlight(line 28), the shared acquisition task. GetAccessTokenAsync(lines 30-72): the SSR/circuit branch, then the single-flight guard._hydrateInFlight ??= HydrateAsync()executes insidelock (_hydrateSync)(lines 50-54) and the resulting task is copied to a local before the lock is released. The comment on lines 45-48 records exactly why the naive unguarded??=was not enough: two callers could each start a hydrate and the later completion would overwrite the other's token;HydrateAsyncreaches its first await immediately, so nothing slow runs under the lock. Thefinally(lines 60-71) clears_hydrateInFlightonly when it is still reference-equal to the task this caller awaited (line 66), so a newer hydrate started after this one completed is not dropped, which would split the next set of callers again.GetRefreshTokenAsync(lines 74-79): returnscookieTokenReader.ReadRefreshToken()during SSR andnullon the circuit, because the HttpOnly refresh cookie is unreadable there; it wraps the value inTask.FromResultrather than beingasync(no await needed).SetTokensAsync(lines 81-87): caches the access token in memory (line 83) and callssessionCookieSync.SyncAsync(accessToken, refreshToken)(line 86) to seed the HttpOnly cookies at login.ClearTokensAsync(lines 89-93): nulls the in-memory token and callssessionCookieSync.ClearAsync()on logout.HydrateAsync(lines 95-99): the private acquisition,_accessToken = await tokenRefresher.AcquireAccessTokenAsync()(line 97), caching and returning the new token.
- The primary constructor (lines 17-21) takes
- Why it's built this way: Blazor Server's split lifecycle breaks the naive "read a token from storage" store, which would either fail during prerender (no JS) or leak the refresh token to script if it used
localStorage. Branching onHttpContextpresence and keeping the refresh token cookie-only resolves both. The locked single-flight is the correction of a real concurrency defect in the simpler??=version, and it is worth reading as a small case study in why "good enough" atomicity on a circuit is not good enough. See ADR-022. - Where it's used: registered as the scoped ITokenStorageService by
AddCommonServerTokenStorage()in theMMCA.Common.UI.WebDependencyInjection (DependencyInjection.cs:26-30); consumer hosts call that instead of shipping their own copy. - Caveats / not-in-source: the cookie names, lifetimes, and the
/auth/session/tokenendpoint itself are not in this file; they live in theMMCA.Common.APIsession-cookie plumbing referenced by the doc comment (lines 12-15).
NotificationBell
MMCA.Common.UI ·
MMCA.Common.UI.Components.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:30· Level 6 · class (partial, component)
- What it is: the code-behind for the app-bar notification bell. It renders an unread badge from the scoped NotificationState, and exactly one instance at a time holds the single active-poller slot, so a bell placed in two layout slots never doubles the API traffic.
- Depends on: first-party: NotificationState (badge value, change and refresh events, staleness clock, and the poller slot), INotificationInboxUIService (the unread-count call), NotificationBellOptions (the two intervals), NotificationRoutePaths (the inbox route), and SharedResource (the resx anchor for the injected localizer). Externals:
Microsoft.AspNetCore.Components([Inject],NavigationManager,LocationChangedEventArgs,OnAfterRenderAsync,InvokeAsync,StateHasChanged),Microsoft.Extensions.Options(IOptions<T>),Microsoft.Extensions.Localization(IStringLocalizer<T>), BCLTimeProvider,PeriodicTimer,CancellationTokenSource,IDisposable. - Concept introduced, a poller slot with symmetric registration and handover.
[Rubric §19, State Management & Data Flow]assesses how shared UI state is coordinated. The bell is a component, so a responsive layout can legitimately render it twice at once (desktop app bar and mobile drawer). Without coordination each copy would start its own timer and its own navigation refresh, doubling the unread-count endpoint's load for no user benefit. The design is a slot held by an owner object, not a bare counter:State.TryRegisterPoller(this)(line 56) takes the slot only when it is free or already this instance's (NotificationState.cs:111-124), andState.UnregisterPoller(this)releases it only when this instance actually holds it and then raisesOnPollerSlotFreed(NotificationState.cs:133-149).- Why owner identity matters here. The class remarks (lines 22-28) name the real scenario: hosts render the bell inside
<AuthorizeView>, which tears the children down and rebuilds them on every authentication-state change, including a routine access-token refresh. Registration is therefore strictly symmetric (every instance unregisters on dispose, whether or not it was polling, line 256), and the surviving instance claims the freed slot throughOnPollerSlotFreed(line 53), so the circuit never ends up with a badge that nobody refreshes. [Rubric §23, Front-End Performance]assesses avoidable network work. Two mechanisms cut it. The slot removes an entire duplicate polling stream in dual-placement layouts. And the navigation trigger is throttled by staleness rather than firing per click:OnLocationChangedreads only whenState.IsStale(Options.Value.NavigationRefreshMaxAge)(line 155), because navigation is an ambient trigger, not evidence that the count moved (doc, lines 143-148). The defaults are 30 seconds for both the poll interval and the navigation max age (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/NotificationBellOptions.cs:22,29), and both are configuration, not constants.- The staleness policy has exactly three tiers, and they are deliberate. The first read and every periodic tick are unconditional; a navigation reads only past the configured window; a real-time push calls
State.MarkStale()first and then reads regardless (lines 171-175), because the server has just said the data changed, so the age of the number carries no information any more (doc, lines 165-169). That is the one path that must never be throttled. [Rubric §14, Testability]assesses whether time-dependent behavior can be driven deterministically. Both the timer and the age comparison run off the injectedTimeProvider Clock(line 37), and thePeriodicTimeris constructed with theTimeProvideroverload (line 92) precisely so a test drives the loop instead of waiting out a real interval (comment, lines 90-91).[Rubric §21, Accessibility]: the bell button carries an explicit localizedaria-label="@L["Notif.Bell.Aria"]"in the markup (NotificationBell.razor:10), which is what the injectedIStringLocalizer<SharedResource> L(line 35) is for.- Fire-and-forget from a synchronous event handler.
HandlePollerSlotFreed(lines 98-99),OnLocationChanged(lines 153-159), andHandleRefreshRequested(lines 171-175) are allEventHandler-shaped, so they cannot beasync Task. Rather thanasync void(which turns an unobserved exception into a process crash, VSTHRD100, per the comments at lines 96-97 and 149-152), they discard the task with_ =and rely on the callee observing its own failures.
- Why owner identity matters here. The class remarks (lines 22-28) name the real scenario: hosts render the bell inside
- Walkthrough
- Injected members (lines 32-37):
State,InboxService,NavigationManager,L,IOptions<NotificationBellOptions> Options, andTimeProvider Clock. Fields (lines 39-42): a per-componentCancellationTokenSource _cts, thePeriodicTimer? _pollTimer, and the two flags_isActivePollerand_disposed. OnAfterRenderAsync(bool firstRender)(lines 44-60) returns immediately on subsequent renders (lines 46-49), subscribes to all three state events (lines 51-53), then tries for the slot and, on success, callsBecomeActivePollerAsync()(lines 56-59).BecomeActivePollerAsync(lines 68-94) is the double-start-guarded start: it bails on_disposed || _isActivePoller(lines 70-73), latches_isActivePoller, hooksLocationChanged(line 76), does the unconditional first read (line 79), then re-checks_disposedafter that await (lines 85-88). The comment (lines 81-84) explains what that guard prevents:Disposemay have run during the read, having already disposed the then-null timer and the token source, so starting the loop now would leak aPeriodicTimernothing disposes and fault the discarded task on a disposed_cts. Only then does it create the timer fromOptions.Value.PollIntervaland the injected clock (line 92) and launchPollLoopAsyncwith an explicit discard (line 93). The method doc (lines 62-67) notes it always runs on the renderer's synchronization context, which is what makes the simple_isActivePollerguard sufficient rather than needing an interlocked operation.TryTakeOverPollingAsync(lines 105-121) is the handover path: it re-checks the guards and claims the slot (line 107), then marshals the actual start onto this component's renderer withInvokeAsync(BecomeActivePollerAsync)(line 114), because the event was raised synchronously from the disposing bell's thread (doc, lines 101-104). If that dispatch hitsObjectDisposedExceptionit hands the slot straight back (lines 116-120) so another bell can claim it.PollLoopAsync(lines 123-141) awaits_pollTimer!.WaitForNextTickAsync(_cts.Token)in a loop (line 127) and refreshes each tick. It catchesOperationCanceledException(the expected disposal exit) andObjectDisposedException(disposed between timer creation and the first wait, where reading_cts.Tokenthrows rather than cancelling, comment lines 137-139).RefreshUnreadCountAsync(lines 177-216) is the one place that touches the network: it bails when_disposed(lines 179-182), callsInboxService.GetUnreadCountAsync(_cts.Token)(line 186), and returns without touching the badge when the result is a failure (lines 187-193). That early return is load-bearing: the comment (lines 189-192) records that zeroing the badge on an unknown count is what used to erase a push increment, and that a failed read is silent by design because the bell has no surface to report it on. On success it re-checks_disposedand marshalsState.SetUnreadCount(unread)plusStateHasChanged()back onto the renderer withInvokeAsync(lines 195-202). Three catch tiers follow (lines 204-215): cancellation, disposal during the async gap, and a barecatchfor network or deserialization failures where the badge keeps its last value. That catch-all is what makes the discards above safe.HandleStateChanged(lines 218-219) discards intoRerenderSafeAsync(lines 221-236), which re-renders throughInvokeAsync(StateHasChanged)and tolerates a dispose landing between the event firing and the render dispatch.NavigateToInbox(line 238) sends the click toNotificationRoutePaths.NotificationInbox.Dispose(bool disposing)(lines 240-261) sets_disposedfirst (line 247), unsubscribes all three state events andLocationChanged(lines 248-251), then callsState.UnregisterPoller(this)unconditionally (line 256). The comment (lines 253-255) states both halves of why that is safe: a bell that claimed the slot but was torn down before it started polling still frees it, and a bell that never held it cannot evict the live poller, because the state object checks owner identity. It then disposes the timer and cancels and disposes the_cts(lines 258-260).Dispose()(lines 263-267) is the public half withGC.SuppressFinalize.
- Injected members (lines 32-37):
- Why it's built this way: a live unread badge is a genuinely useful affordance, but a naive implementation is a request amplifier (one timer per rendered copy, per circuit) and a fragile one under
<AuthorizeView>churn. Owner-identity registration plus a freed-slot event keeps the affordance, removes the amplification, and survives the teardown-rebuild cycle that a plain counter would leave stuck. Making both intervals options and both clocks injected turns the polling policy into something a host can tune and a test can drive. - Where it's used: contributed to the shell as an app-bar component by NotificationUIModule (
NotificationUIModule.cs:23); it reads the same NotificationState that NotificationInbox writes after a mark-read, so the badge stays consistent with the inbox without either component knowing about the other.
NotificationList
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationList.razor.cs:16· Level 6 · class (partial, page)
- What it is: the code-behind for the admin/organizer push-notification history page, routed at
@page "/notifications"(NotificationList.razor:1). It loads previously sent broadcasts and renders them in a status table, with a button onward to the compose page. - Depends on: first-party: IPushNotificationUIService (the send and history HTTP service), PushNotificationDTO (the row shape, carrying status and recipient count), NotificationRoutePaths (the route constants), IToastService, ResultUiExtensions (
NotifyOnFailure), and SharedResource. Externals:MudBlazor(BreadcrumbItem,Icons),Microsoft.AspNetCore.Components(NavigationManager,[Inject]),Microsoft.Extensions.Localization. - Concept reinforced, the same code-behind shape as NotificationInbox. Same
[Inject]service set (lines 18-21), samereadonly CancellationTokenSource _ctsplus dispose pattern (lines 23, 79-98), sameIsLoadinggate (line 29), same cancellation-swallowing load (lines 67-70), sameresult.NotifyOnFailure(Toast, L)failure surface (line 64). It differs only in what it loads and how much of it.[Rubric §25, Navigation, Routing & Information Architecture]assesses route structure and inter-page flow; navigation goes through NotificationRoutePaths constants (NavigateToSendtargetsNotificationRoutePaths.NotificationSend, line 77) rather than a literal URL, so a route change happens in exactly one file.[Rubric §27, Internationalization & Localization]picks up an extra trick here:DisplayStatus(string status)(lines 34-38) looks upL[$"Notif.Status.{status}"]and falls back to the raw wire value whenlocalized.ResourceNotFound(line 37). The comparison value stays the untranslated wire string while only the displayed chip text localizes, which keeps transport values and presentation separate and means a newly added server status renders (untranslated) instead of blanking (the comment on line 33 cites ADR-027). This is the same pass-through-on-unknown-key discipline that DataAnnotationsModelValidator applies to error messages.
- Walkthrough
- Injected
NotificationService,NavigationManager,Toast,L(lines 18-21);TitlereadsL["Notif.List.Title"].Value(line 25);_breadcrumbs(line 27) is built Home to Push Notifications inOnInitializedAsync(lines 43-47), with the leaf crumbdisabled: trueto mark the current page (line 46). _notificationsis anIReadOnlyCollection<PushNotificationDTO>initialized empty (line 31).OnInitializedAsync(lines 40-50) builds the breadcrumbs then awaitsLoadNotificationsAsync.LoadNotificationsAsync(lines 52-75): callsGetHistoryAsync(pageNumber: 1, pageSize: 50, _cts.Token)(line 57) and copieshistory.Itemsinto_notificationson success (line 60), otherwise raises the localized failure toast (line 64). This page fetches one fixed 50-row page and lets MudBlazor page that buffer client-side; unlike the inbox there is no server round-trip per page.NavigateToSend(line 77) sends the "send new" button to the compose page.- Disposal mirrors the family:
_disposed(line 79),Dispose(bool)cancelling the_cts(lines 81-92),Dispose()(lines 94-98).
- Injected
- Why it's built this way: broadcast history is low-volume admin data, so one 50-row fetch with client-side paging is simpler and adequate, and it avoids server-side paging plumbing that would earn nothing. Keeping HTTP behind IPushNotificationUIService mirrors the inbox and keeps the component a thin view. The shared
[Rubric §18, UI Architecture & Component Design]story is told under NotificationInbox. - Where it's used: rendered at
/notificationsfor organizer and admin roles (the nav entry from NotificationUIModule is gated on RoleNames.Organizer); it links onward to NotificationSend. - Caveats / not-in-source: the 50-row ceiling is a client-side choice in this file; what the server does when more than 50 broadcasts exist (whether the page silently truncates the history) is not visible here.
NotificationSend
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:19· Level 6 · class (partial, page)
- What it is: the code-behind for the compose-and-broadcast form, routed at
@page "/notifications/send"(NotificationSend.razor:1). It collects a title and a message into NotificationSendModel, validates them against that model's own annotations, sends one broadcast through the Notification API, reports the recipient count, and returns to the history page. - Depends on: first-party: IPushNotificationUIService (the
SendAsynccall), NotificationSendModel (the form model), ModelValidation + DataAnnotationsModelValidator (the validation bridge), INotificationScopeProvider (the targeting caption), SendPushNotificationRequest (the wire contract), PushNotificationDTO (the result carryingRecipientCount), Result, NotificationRoutePaths, ErrorMessages, IToastService, ResultUiExtensions, and SharedResource. Externals:MudBlazor(MudForm,BreadcrumbItem,Icons),Microsoft.AspNetCore.Components(NavigationManager,OnInitialized,OnInitializedAsync),Microsoft.Extensions.Localization. - Concept introduced,
MudFormvalidation driven entirely by the model. This is the family's form page, and it is the worked example of the validation stack in this unit. The markup declares<MudForm @ref="_form" Model="_model">with two fields that setFor,Validation="@_validate", and read their affordances off the same model (NotificationSend.razor:43-66). No rule is declared in markup: the comment above the form (lines 38-42 of the markup) spells out the division,Requireddrives the asterisk andaria-requiredwhile its message still comes from the model,MaxLengthcaps the input, andCountershows the budget from the shared request contract so the numbers cannot drift from the server. The C# holds the form by reference (MudForm? _form, line 36) and explicitly drives validation before sending:await _form.ValidateAsync()then a guard on_form.IsValid(lines 98-105). MudForm has noOnValidSubmit, so that explicit pass is the gate (comment, lines 96-97).[Rubric §24, Forms, Validation & UX Safety]assesses input validation, double-submit protection, and feedback. All four are present: model-declared rules run through the shared delegate, anIsSavingflag (line 33) bound toDisabledon both buttons (NotificationSend.razor:73,80) so the send cannot be fired twice, a warning toastErrorMessages.ValidationErroron a failed gate (line 103), and a success toast naming the recipient count (line 116).- Two failure surfaces, deliberately not one.
_sendResult(line 42) holds the last attempt's outcome and is rendered inline by the sharedErrorSummarycomponent (NotificationSend.razor:36), while the toast stays the transient cue. The markup comment (lines 31-35) records the design constraint: the summary is deliberately not fedMudForm.Errors, because every field already renders its own message inline and MudBlazor contributes a generic "Required" of its own that would stack on top of the model's wording._sendResultis nulled at the start of every attempt (line 94), so the summary never shows a stale failure. [Rubric §21, Accessibility]and[Rubric §18, UI Architecture & Component Design]meet in theErrorSummary: a failure that only appeared as a toast would time out on a long form and be unrecoverable for a screen-reader user who missed it (comment, lines 121-122).[Rubric §27, Internationalization & Localization]: every string resolves throughIStringLocalizer<SharedResource> L(line 24), including the success messageL["Notif.Send.SentTo", sent.RecipientCount](line 116), which passes the count as a format argument so pluralization and word order stay in the resource file rather than being concatenated in C#. As with its siblings the breadcrumb trail is built in an initialization hook, here the synchronousOnInitialized(lines 55-67, comment citing ADR-027).
- Walkthrough
- Injected
NotificationService,NavigationManager,Toast,L, andScopeProvider(lines 21-25);_cts(line 27);Title(line 29);_breadcrumbs(line 31) built Home to Push Notifications to Send (lines 58-63), where the middle crumb is a real link viaNotificationRoutePaths.Notifications(line 61) and the leaf isdisabled: true(line 62). _model(line 35) is areadonly NotificationSendModelcreated with the component;_validate(line 46) is the singleFunc<object, string, IEnumerable<string>>MudBlazor calls with(model, member path), wired inOnInitializedasModelValidation.For(_model, new DataAnnotationsModelValidator(L))(line 66). One delegate serves both fields, and no rule is written twice (comment, lines 44-45).OnInitializedAsync(lines 69-87) resolves the optional scope caption:ScopeProvider.GetCurrentScopeDisplayNameAsync(_cts.Token)(line 77), and only when the name is non-blank does it build_scopeCaption(lines 78-81). The field doc (lines 48-53) and the async doc (lines 71-74) give the reasoning: a scoped application applies its scope to the send automatically, so without a caption the operator would be composing a broadcast with no visible statement of who receives it, and when there is no scope the page renders no caption at all rather than an empty line.[Rubric §24, Forms, Validation & UX Safety]again: making an implicit targeting decision visible is part of a safe destructive-ish action.SendNotificationAsync(lines 89-134): null-guards_form(lines 91-92), clears_sendResult(line 94), validates and warns on failure (lines 98-105); then underIsSavingbuildsnew SendPushNotificationRequest(_model.Title, _model.Body)(line 110) and awaitsSendAsync(request, _cts.Token)(line 111), storing the result for the summary (line 112). On a non-null PushNotificationDTO it raises the success toast withsent.RecipientCount(line 116) and navigates back to the list (line 117); otherwiseresult.NotifyOnFailure(Toast, L)(line 123). The cancellation catch (lines 126-129) additionally names theInteractiveAutorender-mode transition, the case where the WebAssembly runtime takes over mid-call;IsSavingis cleared infinally(lines 130-133).NavigateToList(line 136) is the Cancel button's handler, back toNotificationRoutePaths.Notifications.- Disposal mirrors the family:
_disposed(line 138),Dispose(bool)(lines 140-151),Dispose()(lines 153-157).
- Injected
- Why it's built this way: a deliberately small form that still demonstrates the full pattern. There is no unsaved-changes guard because the page is create-only and one-shot; the rules live on NotificationSendModel so the client cap, the client message, and the server invariant all read the same constants; and HTTP stays behind IPushNotificationUIService so the component is unit-testable. The send is fire-and-confirm: the server fans out to recipients through the push pipeline (see Group 10) and returns only the aggregate count.
- Where it's used: rendered at
/notifications/sendfor organizer and admin roles, reached from the button on NotificationList. The server-side validator for SendPushNotificationRequest enforces the same rules a second time, so client validation is a UX affordance rather than the security boundary.
NotificationUIModule
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:15· Level 7 · class (sealed)
- What it is: the notification feature's IUIModule descriptor. It declares the two nav entries (user inbox, admin push notifications), the app-bar component, the layout component, and the assembly to scan for routable pages.
- Depends on: first-party: IUIModule (the contract, line 15), NavItem and NavSection (the nav item shape and its placement enum), NotificationRoutePaths (the two routes), SharedResource (the resx type each nav label resolves against), RoleNames (the
Organizergate), plus the NotificationBell andNotificationListenercomponents in the same package (lines 23, 25). Externals:MudBlazor(Icons.Material.Filled.*),System.Reflection(Assembly). - Concept introduced, the UI module pattern (the client-side counterpart to IModule).
[Rubric §25, Navigation, Routing & Information Architecture]assesses how navigation is composed and how routes are discovered. Server modules declare their registrations and dependencies throughIModule; UI features do the same for the shell. Nothing here calls into a layout: the module declares nav items and component types as data, and the host discovers every registered IUIModule and assembles the menu, app bar, and layout from those declarations. Adding a feature therefore never edits a sharedMainLayout.razoror a central menu file.[Rubric §18, UI Architecture & Component Design]applies to the two component collections:AppBarComponentTypesandLayoutComponentTypesareTypehandles, so the shell renders them dynamically without a compile-time reference to the feature.[Rubric §11, Security]applies to the role gate: the admin entry carriesRoleNames.Organizer(line 20) on the nav item itself, so the authorization fact lives next to the thing it protects rather than in a layoutif.[Rubric §27, Internationalization & Localization]: the nav labels are resource keys plus a resource type,"Nav.NotificationInbox"and"Nav.PushNotifications"withtypeof(SharedResource)(lines 19-20), not literal English. A descriptor is a singleton built once at startup, so it cannot hold a localized string; carrying the key and the resx anchor instead is what lets the shell resolve the label per circuit under the active culture.
- Walkthrough
NavItems(lines 17-21) is an immutableIReadOnlyList<NavItem>with two entries: the inbox keyNav.NotificationInboxtoNotificationRoutePaths.NotificationInboxwith theInboxicon inNavSection.User(line 19, no role, so any authenticated user sees it), andNav.PushNotificationstoNotificationRoutePaths.Notificationswith theNotificationsActiveicon, gated onRoleNames.Organizer, inNavSection.Adminand grouped under"Notifications"(line 20).AppBarComponentTypes(line 23) is[typeof(NotificationBell)], the badge the shell injects into the top bar.LayoutComponentTypes(line 25) is[typeof(NotificationListener)], mounted once per layout so the SignalR callback wiring has exactly one owner.Assembly(line 27) returnstypeof(NotificationUIModule).Assembly, which the host adds to the Blazor router's additional assemblies so the pages in this package become routable in the consumer app.
- Why it's built this way: expressing contributions as data (collections of records and
Types) keeps the shell open for extension and closed for modification, and it is what allows a package to ship a complete feature (routes, nav, app-bar widget, background listener) that a host enables with one DI call. The class issealedand every member is a get-only auto-property initialized inline, so the descriptor is safely shared as a singleton. - Where it's used: registered as a singleton IUIModule by
AddNotificationUI()in the notifications DependencyInjection (Notifications/DependencyInjection.cs:39); enumerated by the host shell at startup to build navigation and to discover this package's routable components.
DependencyInjection
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:14· Level 5 · class (static)
- What it is: the registration extensions for the server-side Blazor Web host pieces this package ships: three
IServiceCollectionmethods a host calls fromProgram.csinstead of registering app-local copies of the token store, the CSP provider, and the form factor. - Depends on: first-party: ServerTokenStorageService + ITokenStorageService, BlazorCspPolicyProvider + ICspPolicyProvider, and WebFormFactor + IFormFactor. Externals:
Microsoft.Extensions.DependencyInjection(IServiceCollection,AddScoped,AddSingleton,AddHttpContextAccessor). - Concept: the same
extension(IServiceCollection services)block idiom used package-wide (line 16, see primer). What is worth studying here is that the XML docs carry operational rules the compiler cannot enforce, and they are the only place those rules are written down next to the code.[Rubric §15, Best Practices & Code Quality]assesses idiom consistency; everyMMCA.Common.*package registers services through the same extension shape, so a reader who has seen one registrar has seen them all.[Rubric §26, Front-End Security]assesses browser hardening wiring;AddCommonBlazorCsp()is what actually puts BlazorCspPolicyProvider in front of the default static provider, and its doc (lines 35-37) encodes the ordering rule: call it beforeAddCommonSecurityHeaders, because the default is registered withTryAddand would otherwise win.
- Walkthrough
AddCommonServerTokenStorage()(lines 26-30): callsservices.AddHttpContextAccessor()(line 28), the accessor ServerTokenStorageService needs to tell SSR from circuit, then registers it as the scoped ITokenStorageService (line 29). Scoped is the right lifetime: a circuit is a DI scope, so the in-memory access token is per-session state. The doc (lines 22-24) names the two companions this registration assumes,AddServerAuthSessionCookieandUseCookieSessionRefreshfromMMCA.Common.API, plus a registered ITokenRefresher.AddCommonBlazorCsp()(lines 39-40): registers BlazorCspPolicyProvider as a singleton ICspPolicyProvider, matching the provider's compute-once constructor.AddSingleton(notTryAdd) is what makes the replacement deterministic.AddCommonWebFormFactor()(lines 47-48): registers WebFormFactor as a singleton IFormFactor, which reports "Web" plus the server OS description; the doc (lines 44-45) notes the WASM client registersAddWasmFormFactor()fromMMCA.Common.UIinstead, so the same abstraction resolves differently per host kind.
- Why it's built this way: all three pieces are host-level infrastructure that carried no app-specific state, so they were hoisted into
MMCA.Common.UI.Weband exposed as one-line registrations (class doc, lines 11-12). That keeps every consumer'sProgram.csfree of duplicated token-store, CSP, and form-factor wiring, which is the reusable-building-blocks charter of this group. See ADR-022 for the session design the first method plugs into. - Where it's used: called from the
Program.csof the server-interactive Blazor Web hosts in the consumer apps (MMCA.ADC, MMCA.Store). - Caveats / not-in-source: the
AddCommonBlazorCsp()doc (line 35) describes a "permissive Report-Only fallback on misconfiguration"; the provider it registers now fails closed and stays enforced (BlazorCspPolicyProvider.cs:52-54). The code is authoritative.
DependencyInjection
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:12· Level 8 · class (static)
- What it is: the notification-UI registration entry point, a single
AddNotificationUI()extension onIServiceCollectionthat wires the two typed HTTP services, the shared per-circuit state, the SignalR client, the scope provider, and the IUIModule descriptor. - Depends on: first-party: INotificationScopeProvider + NullNotificationScopeProvider, IPushNotificationUIService + PushNotificationService, INotificationInboxUIService + NotificationInboxService, NotificationState, NotificationHubService, and IUIModule + NotificationUIModule. Externals:
Microsoft.Extensions.DependencyInjection(IServiceCollection,AddScoped,AddSingleton) andMicrosoft.Extensions.DependencyInjection.Extensions(TryAddScoped). - Concept: the C#
extension(IServiceCollection services)registration idiom used package-wide (see primer); the block opens at line 14 and the method inside it is an ordinary extension method in the new form. Note this is one of severalDependencyInjectionclasses in the UI packages: this one is specifically the Notifications registrar, the sibling of theMMCA.Common.UI.Webhost registrar above.[Rubric §33, Developer Experience & Inner Loop]assesses how much a consumer must know to switch a feature on; the answer here is one call.[Rubric §3, Clean Architecture]assesses where composition lives; the feature owns its own DI, so nothing about notifications leaks into a host'sProgram.csbeyond the single line.TryAddversusAddis a deliberate signal. The scope provider is registered withTryAddScoped(line 24) precisely so an app that registers its own INotificationScopeProvider wins regardless of the order the two registration calls run in (the comment on lines 22-23 says so); everything else uses plainAddScoped/AddSingletonbecause this package owns those contracts.
- Walkthrough: inside the extension block (line 14),
AddNotificationUI()(line 20) registers, in order, INotificationScopeProvider to NullNotificationScopeProvider viaTryAddScoped(line 24, the default no-op scope consumed by both HTTP services and read for the caption on NotificationSend), IPushNotificationUIService to PushNotificationService (scoped, line 27), INotificationInboxUIService to NotificationInboxService (scoped, line 30), NotificationState as a concrete scoped type (line 33, one unread-count owner per Blazor circuit), NotificationHubService (scoped SignalR client, line 36), and finally NotificationUIModule as a singleton IUIModule (line 39), because the descriptor is immutable shell metadata rather than per-circuit state. It returnsservicesfor chaining (line 41). - Why it's built this way: the scoped-versus-singleton split is the load-bearing part. HTTP services, state, and the hub connection are per-circuit (a Blazor circuit is a DI scope, and the unread count belongs to one user's session), while the nav and shell descriptor is process-wide and immutable. Bundling all six behind one extension keeps host startup honest and makes the feature's dependency surface reviewable in one screen.
- Where it's used: called from the
Program.csof each consuming host (Blazor Web and MAUI) that opts into the notification UI; it complements the mainMMCA.Common.UIregistration rather than replacing it. - Caveats / not-in-source: this method does not register NotificationBellOptions. The bell injects
IOptions<NotificationBellOptions>(NotificationBell.razor.cs:36) and the options type supplies its own defaults (NotificationBellOptions.cs:22,29), so the unconfigured case works, but where a host binds theNotificationBellconfiguration section is not visible from this file.
⬅ Module System, Composition & Configuration • Index • Aspire Orchestration & Service Defaults ➡