Architecture Decision Record
ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)
Status
Accepted (2026-06-27, amended 2026-07-02, 2026-07-03, and 2026-07-09). Supersedes ADR-011 (single-locale by design).
Context
ADR-011 recorded single-locale (en-US) as a deliberate, revisitable non-goal and sketched what
re-introducing i18n would entail. That revisit has now happened: the framework adds first-class
internationalization so consumers can serve en-US and Spanish (es), with the structure to add more
locales later. ADR-011's own "if multi-locale is ever required" scope is the blueprint this ADR
implements; ADR-011 is now superseded, not deleted (the history matters).
The hard part is not translation files — it is making one culture decision flow consistently through a
Blazor InteractiveAuto app (SSR prerender → InteractiveServer circuit → InteractiveWebAssembly client)
and through the cross-origin REST services behind the Gateway, without a flash of the wrong language or
a prerender/hydration mismatch. The Result pattern (ADR-013) already gives every Error a stable
machine Code, which makes server-side error localization a keyed lookup rather than a rewrite.
Decision
Supported cultures are an explicit allowlist:
en-US(default) +es. Adding a locale is adding a.es.resxsibling set and one allowlist entry, not new infrastructure.Strings are externalized to
.resx, co-located with the type that uses them, looked up byIStringLocalizer<T>.AddLocalization()is registered with noResourcesPathso a type's resource base name is its full type name and the.resxlives next to it (Login.razor→Login.resx/Login.es.resx; a*.Resources.SharedResourcemarker for cross-cutting chrome). Keys are dotted and stable (Nav.Home,Common.Button.Save). Parameterized text uses composite format keys ("Error loading {0}. {1}") consumed asL["Common.Error.Load", entity, detail]— never string concatenation. The.resxcompile to satellite assemblies that pack into the NuGet packages automatically (no.csprojchange) and flow identically vialocal.propssource mode.Backend user-facing error text is localized server-side at the HTTP edge, keyed by
Error.Code.IErrorLocalizer(MMCA.Common.API/Localization) maps an error's stableCodeto a localized string againstCurrentUICulture, falling back to the error's existing EnglishMessagewhen no resource key exists. It is applied at the single Result→ProblemDetails projection point (ErrorHttpMapping.BuildErrorsExtension, used byApiControllerBase.HandleFailureandUnhandledResultFailureFilter). Domain, handler, andResultsignatures do not change — they stay culture-agnostic; only the edge speaks a culture. Modules register their own resource sources (ErrorResourceSource) additively; Common registers its own inAddAPI. FluentValidation rules carry stable.WithErrorCode("<Area>.<Field>.<Rule>")codes so validation errors localize through the same mechanism.The ProblemDetails
titleis a machine marker and is never localized. The UI error parser (ServiceExceptionHelper) branches ontitle("Operation failed"/"Domain Exception"/"Validation Exception"); only the human-facingmessage/detailis translated.One culture cookie is the single source of truth across SSR + Server + WASM. UI hosts run
UseRequestLocalization([en-US, es])with aCookieRequestCultureProviderso SSR prerender renders in the right culture; a/culture/setendpoint writes the standard ASP.NET culture cookie and forces a full reload; the WASM client reads the same cookie on startup (MmcaCultureBootstrap.SetBrowserCultureAsync) and setsCultureInfo.DefaultThreadCurrent[UI]CulturebeforeRunAsync(), so prerender and hydration agree. The UI forwards the active culture to the API asAccept-Language(CultureDelegatingHandleron the"APIClient"), because the cross-origin Gateway does not carry the cookie to the services — that header is what makes backend errors come back localized.A user's chosen culture is persisted to the Identity profile (
User.PreferredCulture). The DB value is the cross-device source of truth; the cookie is the runtime channel. On login the cookie is set from the profile; an authenticated switch persists to both DB and cookie; anonymous users get the cookie only.Display formatting is culture-aware; machine boundaries stay invariant. UI rendering of dates / numbers uses
CurrentCulture.InvariantCultureis retained where the string is a machine contract (JWT timestamps, EF/grid filter parsing, URL/query state, claims, value-object canonical strings). Hygiene against accidental culture-less formatting is enforced as a build gate (since 2026-06-29): the Meziantou analyzerMA0076(implicit culture-sensitiveToStringin interpolation) is set toerrorseverity in.editorconfig, so a culture-less interpolation fails the build and must declare an explicitIFormatProvider(CultureInfo.InvariantCultureat machine boundaries,CurrentCulturefor UI display). This closes the prior "advisory only" follow-up.Translation completeness is a fitness gate (ADR-015).
ResourceTranslationsAreComplete(MMCA.Common.Testing.Architecture, run asLocalizationResourceTestsagainstSupportedCultures.All) fails the build if any base.resxunderSource/lacks a complete, non-empty sibling for a required culture — so a new English string cannot ship without its Spanish translation. Coverage is verified, not assumed, closing the prior "no missing-key/translation-coverage gate" follow-up. The rule is opt-in and repo-agnostic (it takes the required-culture list), so the consumer apps can adopt the same gate for their module.resx.Locale-addition governance. Adding a locale is a bounded, gated process: (a) add the culture to
SupportedCultures.All; (b) add the.<culture>.resxsibling for every base.resx; (c) the coverage fitness gate then refuses to build until every key is translated. No other infrastructure change is needed:UseRequestLocalization, the culture switcher, and the IdentityUser.PreferredCultureguard all readSupportedCultures, so they cannot drift from the allowlist.Development-only pseudo-localization. A Windows-standard pseudo-locale,
qps-Ploc(SupportedCultures.PseudoLocale), is available as a developer diagnostic and is deliberately kept out ofSupportedCultures.Allso the coverage gate never demands a.qps-Ploc.resxsibling. It is offered only when the host runs in Development:UseCommonRequestLocalizationadds it to the request-localization allowlist underIsDevelopment(), andMapCultureEndpointhonors it from the culture switcher only under the same guard. When it is the active UI culture, aPseudoStringLocalizerFactorydecorator (registered unconditionally, inert under every other culture) runtime-transforms every resolved resource string (accents, padding, and a bracket sentinel) so that hard-coded strings, truncation, and string concatenation become visible without translating anything. Outside Development it is never offered and the decorator stays inert, so it is a build-and-test aid, not a production culture.The pseudo pass is also a required CI gate (since 2026-07-03). The backend-less gallery host (test-only, never packaged) enables
qps-Plocunconditionally, andPseudoLocalizationE2ETests(in the required chromiumui-e2ejob) renders/login,/register, and/componentsunder it, asserting (a) the bracket sentinel appears (every displayed string made the resource round-trip) and (b) the page does not overflow horizontally under the ~40% expansion (the layout-tolerance criterion). A leak-guard test asserts the sentinel is absent underen-US. Production hosts are unchanged: they keepqps-PlocDevelopment-only.User-visible literals are kept out of markup and code-behind by a second fitness gate, and composed sentences are banned.
LocalizedTextConventionTestsBase(MMCA.Common.Testing.Architecture, subclassed by every repo) scansSource/**/*.razor{,.cs}and fails the build on hard-coded snackbar messages, pageTitleproperties, literal<PageTitle>markup, literal breadcrumb labels, andNavItemrows that carry noTitleResource; deliberate literals (brand names) are exempted per line with ani18n: allowmarker. Snackbar text uses whole-sentence keys in the page's own resource pair (Snackbar.Created= "Event created successfully." / "Evento creado correctamente."):ErrorMessages.Success(entity, action)is[Obsolete]because fragment composition cannot translate (Spanish gender agreement breaks), and the sharedCommon.Error.Load/Save/Deletetemplates no longer append rawex.Message(neither localizable nor safe to surface).Carve-out (2026-07-09): a
DomainInvariantViolationExceptionmessage IS shown verbatim.ErrorMessages.LoadError/SaveError/DeleteErrorreturn that exception'sMessagein place of the generic template, and the newErrorMessages.ActionError(ex, localizedFallback)does the same for pages whose fallback is a whole-sentence snackbar key. This does not weaken the raw-text rule:ServiceExceptionHelpermints that exception type exclusively from the API's Problem Details errors, whose text is curated domain wording already localized server-side to the request culture (Decision 3, carried by the Decision 5Accept-Languageforwarding), so the user sees the actual business rule ("This action is only available while the event is live.") instead of a generic failure toast. All other exception types keep the generic localized message.NavItemgained an optionalTitleResourcetype: when set, the sharedNavMenutreatsTitle/Groupas resource keys resolved per circuit at render time, so module nav menus follow the active culture. MudBlazor's own component chrome localizes throughResxMudLocalizerover theMudTranslationsresource pair (all built-in keys of the pinned MudBlazor version, en + es), registered inAddUISharedand covered by the same completeness gate.
Rationale
- Keying error localization on the existing
Error.Codeis the cheapest correct extension point. The codes are already stable and already cross the wire; localizing at the edge keeps the Result pattern pure and means an untranslated code degrades gracefully to its English message instead of throwing. - A single cookie avoids the InteractiveAuto split-brain. SSR and WASM run in different runtimes; the only state both can read before first paint is a non-HttpOnly cookie, so it is the source of truth.
- Co-located
.resxwith noResourcesPathmakes the resource base name predictable (the full type name) and packs cleanly through the lockstep NuGet pipeline (ADR-016) without per-project MSBuild tweaks.
Trade-offs
- Every view and every user-facing message is touched — a large, mostly mechanical sweep, accepted as the cost ADR-011 always named.
- WASM Spanish formatting needs ICU globalization data (not
InvariantGlobalization), a payload cost on the client bundle. - Mixed-language responses are possible during rollout: an untranslated code falls back to English by design, so coverage is incremental rather than all-or-nothing within a release.
- MudBlazor's own built-in component text may need a
MudLocalizerfor full coverage; tracked as a follow-up rather than blocking. Closed 2026-07-03:ResxMudLocalizer+ theMudTranslationsresource pair now localize the MudBlazor chrome (Decision 9); unknown keys still fall back to MudBlazor's built-in English, anden-USdeliberately keeps the built-ins.
Related
ADR-011 (superseded), ADR-013 (the Error.Code
this localizes on), ADR-015 (the i18n gates now live here: the MA0076 culture-less
formatting build gate and the ResourceTranslationsAreComplete translation-coverage fitness rule),
ADR-016 (satellite assemblies ship in the lockstep release),
ADR-022 (the SSR cookie pattern this mirrors),
ADR-028 (the theme toggle that shares this cookie/profile/bootstrap machinery).