Skip to content
bitzorcas
中EN

Reference

I18n request-language resolution and Culture

A deep guide to LanguageResolutionMiddleware precedence, Accept-Language handling, setting fallback, CultureInfo synchronization, the AsyncLocal L() context, response headers, and the enabled-language validation gap.

Last updated

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.

RequestLocalization

Authentication

Tenant resolution / impersonation

LanguageResolutionMiddleware

Request audit

Authorization

Endpoint

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.

The header overrides cookie and query
GET /api/i18n/languages?lang=en-US HTTP/1.1
Host: api.example.test
Accept-Language: zh-CN,en-US;q=0.9
Cookie: 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.

The second item is not selected by its higher quality
GET /api/i18n/resources HTTP/1.1
Accept-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.

Separate the three validation concerns
// ① 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:

  1. scoped ILanguageContext.CurrentLanguage;
  2. CultureInfo.CurrentUICulture;
  3. CultureInfo.CurrentCulture;
  4. 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.

Resolve stable error codes at the presentation boundary
// 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

yesno

Explicit preference / cookie / header / tenant default

Parse and rank q values

Canonicalize BCP-47

Intersect tenant-enabled languages

Exact match?

ResolvedLanguage + reason

Configured parent / region fallback

Tenant default → platform default

The result should expose Requested, Resolved, FallbackReason, and FormattingCulture. Response headers, metrics, and resource lookup should all consume that single result.

12. Test matrix

ScenarioRequired assertion
Header/cookie/query conflictExact published precedence
q weights, wildcard, q=0Standards behavior or explicit limitation
Case and aliasesOne canonical tag
Regex-valid unknown cultureValidation or explicit fallback, never dirty echo
Disabled languageIt never enters the Localizer cache
Setting failure/invalid valueFallback reason and metric
UI versus formatting cultureProduct policy can separate them
Parallel requestsAsyncLocal does not cross tenant/language
401/403/429/500Consistent Content-Language contract
High-cardinality inputBounded cache cardinality

13. Inspection commands

Terminal window
# 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.cs

I18n overview · Translation scopes and persistence · Testing and GA

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%