A request language is more than a preference string. It influences resource selection, model-validation messages, date and number formatting, cache partitions, response semantics, and diagnostics. The current middleware synchronizes those contexts, but it does not separate syntactic validity, runtime-culture availability, and tenant enablement.
1. Middleware position
UseRequestLocalization() runs first. Authentication, delegation, tenant resolution, and tenant impersonation then run before LanguageResolutionMiddleware, so the custom middleware can consume tenant, user, and office context and can overwrite .NET Culture.
The earlier summary comment in the composition root omits LanguageResolution. Executable order is authoritative. A reordering must test proxy, authentication, tenant, and exception responses as well as successful endpoints.
2. Resolution precedence
The implemented order is header, cookie, then query. If none yields a value, the middleware reads platform.i18n.defaultLanguage; setting failure or an empty value eventually resolves to zh-CN.
GET /api/i18n/languages?lang=en-US HTTP/1.1Host: api.example.testAccept-Language: zh-CN,en-US;q=0.9Cookie: bitzorcas.lang=en-US
# The current result is zh-CN because only the header's first item is used.A UI language selector that changes only cookie or query can therefore appear ineffective. The product contract should explicitly choose whether a persisted user preference or every-request browser header has priority.
3. Accept-Language is only partially parsed
The code splits on commas, picks the first item, and removes the q parameter. It does not rank quality values, recognize *, reject q=0, perform zh-Hans-CN → zh-CN → zh matching, or intersect candidates with the enabled catalog.
GET /api/i18n/resources HTTP/1.1Accept-Language: fr-FR;q=0.1, en-US;q=1.0
# Current resolution is fr-FR.If an intermediary caches responses by language, the application also needs a correct Vary contract or an equivalent language cache key. The middleware sets Content-Language but not Vary: Accept-Language.
4. Language-tag regular expression
The regex accepts a two- or three-letter primary tag and one optional two-to-four-character alphanumeric subtag. It rejects control characters and path-like input, but it is not complete BCP-47. Scripts, extensions, private use, and multiple subtags cannot be represented, while plausible but nonexistent cultures can pass.
// ① Syntax: reject control characters and unsupported tag shapes.var parsed = LanguageTag.TryParse(requestedTag);if (parsed.IsFailure) return Errors.InvalidLanguageTag(requestedTag);
// ② Runtime: require a culture supported by the platform runtime.var culture = CultureInfo.GetCultureInfo(parsed.Value.CanonicalName);
// ③ Product policy: require a language enabled for this tenant.if (!await languageCatalog.IsEnabledAsync(tenantId, culture.Name, cancellationToken)) return Errors.LanguageNotEnabled(culture.Name);This is a target pattern. The current code performs only a simplified form of the first check, suppresses CultureNotFoundException, and never performs the third.
5. Casing and canonicalization
Input is not canonicalized through CultureInfo.Name or a BCP-47 value type. ZH-cn may create a valid CultureInfo, while ILanguageContext, cache keys, and database queries still retain the original spelling. Whether the database matches it depends on provider and collation.
Every entry point should establish one canonical tag before settings, cache, storage, and response headers. Migration must also reconcile existing zh-cn, ZH-CN, and equivalent rows before uniqueness is tightened.
6. Default-language setting
platform.i18n.defaultLanguage is registered with Global scope and default zh-CN. The middleware supplies the current TenantId when reading it. Whether tenant override is truly allowed depends on SettingsManager scope rules, not on this call alone.
Setting failure becomes an empty value and silently falls back to zh-CN. Operations needs structured counters for setting failure, invalid configured culture, not-enabled culture, and fallback reason without logging unbounded raw headers or cookies.
7. Four synchronized contexts
After resolution, the middleware updates:
- scoped
ILanguageContext.CurrentLanguage; CultureInfo.CurrentUICulture;CultureInfo.CurrentCulture;- the static
L.SetContext(language, tenant, office, localizer)AsyncLocal.
CurrentUICulture selects resources, while CurrentCulture also changes date, number, and currency parsing and formatting. A product may need English UI with Chinese regional formatting, so UI language and formatting culture should not automatically remain one setting forever.
8. Global L() lifetime
L.SetContext enables synchronous L("Error.Key") calls downstream. A finally block invokes L.ClearContext() at request completion, reducing cross-request leakage through pooled threads.
// Domain and Application retain a stable machine-readable code.var result = await sender.Send(command, cancellationToken);if (result.IsFailure){ // Human-readable text is selected only at the API or UI mapping boundary. var message = L(result.Error.Code); return Results.Problem(title: message, extensions: new { result.Error.Code });}Background jobs, detached work, and tests must not assume this AsyncLocal exists. Cross-message contracts should carry an explicit canonical language rather than relying on ambient HTTP state.
9. Response header behavior
An OnStarting callback sets Content-Language unless downstream code has already set it. When CultureInfo creation fails, the response can still report the syntactically accepted but unsupported input.
Whether authentication failures, early short circuits, and exception responses execute this callback depends on pipeline position. Test 400, 401, 403, 404, 429, and 500 responses through the real host.
10. Input security and cache pollution
The regex bounds characters and length, which reduces direct cache-key injection. An attacker can still generate many syntactically valid tags, and Localizer can create a 30-minute entry for each language/tenant/office combination.
GA should intersect with an enabled-language allowlist before cache access, normalize invalid choices into a bounded fallback, limit languages per tenant, and monitor cache-key cardinality. Total header length also needs a host or gateway bound.
11. Target negotiation algorithm
The result should expose Requested, Resolved, FallbackReason, and FormattingCulture. Response headers, metrics, and resource lookup should all consume that single result.
12. Test matrix
| Scenario | Required assertion |
|---|---|
| Header/cookie/query conflict | Exact published precedence |
| q weights, wildcard, q=0 | Standards behavior or explicit limitation |
| Case and aliases | One canonical tag |
| Regex-valid unknown culture | Validation or explicit fallback, never dirty echo |
| Disabled language | It never enters the Localizer cache |
| Setting failure/invalid value | Fallback reason and metric |
| UI versus formatting culture | Product policy can separate them |
| Parallel requests | AsyncLocal does not cross tenant/language |
| 401/403/429/500 | Consistent Content-Language contract |
| High-cardinality input | Bounded cache cardinality |
13. Inspection commands
# Precedence, regex, Culture, and AsyncLocal lifetime.rg -n "ResolveFromHeader|ResolveFromCookie|ResolveFromQuery|LanguageCodePattern|L\.SetContext|L\.ClearContext" \ src/Hosts/BitzOrcas.Api/Middleware/LanguageResolutionMiddleware.cs
# Full q negotiation, catalog enablement, and Vary should currently have no matches.rg -n "Quality|q=|GetEnabledAsync|Vary|ResolvedLanguage" \ src/Hosts/BitzOrcas.Api/Middleware/LanguageResolutionMiddleware.csI18n overview · Translation scopes and persistence · Testing and GA