Skip to content
bitzorcas
中EN

Reference

I18n localizer, cache, and frontend resources

A deep guide to LocalizerService single-key and batch algorithms, database and JSON fallback, settings, sync-over-async, cache keys/tags/TTL, warmup contributor, cross-instance eviction, and the empty GetI18nResources result.

Last updated

The runtime localizer is the most frequently consumed and most easily overstated I18n component. Its single-key, batch, and frontend-resource paths do not currently share one semantic contract.

LocalizerService is marked [BitzCacheArea(translations)]. Optional startup / Host rebuild is filled by I18nCacheWarmupContributor for enabled languages × office=0. See the cache area catalog.

1. Four entry points

Entry pointInputCurrent semantics
GetString(key)Ambient language/tenant/officeComplete single-key fallback
GetString(key, language, ...)Explicit contextComplete single-key fallback
GetStringsAsync(keys, ...)A key collectionRequested-language DB only; missing becomes final key segment
GET /api/i18n/resourcesLanguage + prefixCalls batch with no keys; currently empty

GetTranslations bypasses Localizer and returns the merged database dictionary. It does not include JSON or fallback language, but its empty-key convention correctly means “all rows.”

2. Single-key behavior

Read one user-facing label
// Use a stable hierarchical key instead of using source-language text as identity.
var title = localizer.GetString(
key: "Identity.Login.Title",
language: resolvedLanguage,
tenantId: currentTenantId,
officeId: currentOfficeId);
// When every source misses, the current result is "Title", not null or Error.
// Callers therefore cannot distinguish a real translation from the final fallback.
response.Title = title;

A database read failure becomes an empty dictionary and lookup continues through JSON, fallback language, and key text. This is availability-first degradation, but the return type has no hit source or degraded flag.

3. Fallback language

The service reads platform.i18n.fallbackLanguage through SettingsManager with the TenantId and defaults to en-US. It skips the second lookup if the value equals the requested language ignoring case.

Only one exact fallback is supported. pt-BR does not try pt, and zh-HK does not try a configured zh-Hant chain. The configured value is not checked against CultureInfo or the enabled language catalog first.

4. Final key text

ToFallbackText takes the substring after the last dot: Identity.Login.Title → Title. A key with no dot remains unchanged, as does a key ending in a dot.

This prevents a blank UI but can expose an internal error code. A commercial policy may show keys in development, a safe generic message in production, and a missing-key metric. The final segment is not translation-quality assurance.

5. Missing batch layers

GetStringsAsync loads the requested-language database dictionary and iterates only the supplied keys. It computes a fallback variable but never uses it, and it never consults JsonResourceLoader.

Executable mental model of the current batch path
// Empty keys tell the store to load the entire requested-language DB dictionary.
var databaseValues = await LoadDictionaryAsync(
requestedLanguage, tenantId, officeId, cancellationToken);
var result = new Dictionary<string, string>();
foreach (var key in keys)
{
// There is no JSON or fallback-language lookup here.
result[key] = databaseValues.TryGetValue(key, out var value)
? value
: ToFallbackText(key);
}
return result;

The same key can therefore resolve differently depending on which public API a caller chose.

6. Empty GetI18nResources response

The handler calls localizer.GetStringsAsync(Array.Empty<string>(), ...), expecting no keys to mean the full bundle. The batch result is built by looping through input keys, so it returns an empty dictionary even though the store loaded all database rows. Prefix filtering then filters that empty result.

GET /api/i18n/resources

Keys = []

Store loads full DB bundle

foreach(Keys): zero iterations

{}

Prefix filter remains {}

A correct fix must define full-bundle merging across database, JSON, and fallback sources rather than merely returning the store dictionary.

7. Database dictionary cache

Synchronous single-key lookup uses ICacheStore.GetOrCreateAsync and caches a complete language dictionary. Its key contains:

area=i18n / scope=Global / lang / language / tenant / tenant-or-0 / office / office-or-0

The policy has a 30-minute TTL and i18n area tag. Global CacheScope does not itself imply cross-tenant sharing because tenant and office are key parts. Review must inspect both scope and constructed key.

8. Sync-over-async

To keep synchronous GetString and L(), the implementation uses .GetAwaiter().GetResult() across settings, cache, and store operations. ASP.NET Core lacks the classic SynchronizationContext deadlock pattern, but blocking still consumes threads, amplifies tail latency, and may behave differently in another host.

High-throughput paths should prefer async resolution. If synchronous L() remains, an asynchronously prepared immutable snapshot can avoid blocking I/O. A concurrency benchmark must prove capacity.

9. JSON resource loading

The loader scans the top-level AppDomain.BaseDirectory/Resources/i18n directory for *.{lang}.json. Every file contains a flat string dictionary. When multiple files contain the same key, later enumeration overwrites earlier data; enumeration is not explicitly sorted.

Module JSON resource shape
{
"Identity.Login.Title": "Sign in",
"Identity.Login.Submit": "Continue"
}

The current source tree has no actual resource files. The class comment mentions embedded resources, but the code only uses the filesystem. Per-file parse errors are silently ignored, so missing directory, malformed JSON, and an intentionally empty language pack are indistinguishable to callers.

10. JSON cache and refresh

Each language’s merged dictionary lives in a singleton ConcurrentDictionary. Refresh() clears all languages, but no file watcher, management endpoint, LocalResourceSync consumer, or deployment hook invokes it.

Replacing files after startup does not automatically take effect. Rolling deployments can also run different bundle versions. Resources should be immutable build artifacts or use an explicit versioned, checksummed, broadcast reload protocol.

11. Three post-save notifications

  1. RemoveByTagAsync("i18n") clears the current instance;
  2. LocalResourceSync Translation asks other instances to clear the same tag;
  3. i18n.translations.changed tells a future frontend subscriber to refetch.

The sync consumer logs and suppresses non-cancellation exceptions. This avoids poisoning delivery but permits stale data until TTL. There is no resource revision or ETag to reject out-of-order events; sourceVersion is a send-time Unix millisecond.

12. Coarse eviction and stampede

One edit for one tenant and language evicts the entire i18n tag. Every tenant and office snapshot then rebuilds on demand. Frequent editing can create a cross-tenant cache stampede and database spike.

Target design can combine tenant/language/office tags, versioned snapshots, single-flight, and jittered TTL. A Platform change legitimately affects all tenants, while a Tenant or Office change should remain narrow.

13. Parameters and HTML safety

Localizer returns raw strings. There is no typed formatting contract, placeholder validation, HTML-safe value type, ICU plural/select support, or rich-text sanitizer. Ad hoc string.Format can fail on translator-edited placeholders or interpolate untrusted values into HTML.

Treat translations as plain text and encode them by default. Rich text needs a separate resource type and allowlist sanitizer. Named parameters should be schema-checked so a translation cannot change executable template semantics.

14. Target unified resolver

Target: a diagnosable resolution result
// One context object keeps all public entry points on the same fallback algorithm.
var resolved = await localizer.ResolveAsync(new LocalizationRequest(
Keys: ["Identity.Login.Title", "Identity.Login.Submit"],
Language: language,
TenantId: tenantId,
OfficeId: officeId,
IncludeFallback: true), cancellationToken);
// Keep values and diagnostics separate; record keys and sources, not sensitive text.
foreach (var item in resolved.Items)
metrics.RecordHit(item.Key, item.ResolvedLanguage, item.Source);

Single-key, batch, and bundle endpoints should delegate to one resolver. Only input cardinality and output projection should differ.

15. Test matrix

ScenarioMust prove
DB/JSON/fallback/key layersSingle and batch return the same value
Empty-key bundleReturns a complete merged set
PrefixApplies consistently to every source
Database failureDegradation is observable and does not poison cache
JSON damage/conflictDeterministic policy and alert
Platform/Tenant/Office editEvicts exactly the affected audience
Lost/out-of-order/duplicate syncVersion or TTL converges
Frequent editsNo stampede; bounded P99
Concurrent synchronous L()Thread-pool and tail-latency budget passes
Formatting/HTMLPlaceholder safety and output encoding

16. Inspection commands

Terminal window
# Single, batch, JSON, and cache implementations.
rg -n "GetString\(|GetStringsAsync|GetOrLoadDictionary|JsonResourceLoader|CacheTtl" \
src/Platform/I18n src/Framework/BitzOrcas.Application/Localization -g '*.cs'
# Empty-key bundle and three invalidation paths.
rg -n "Array.Empty<string>|RemoveByTagAsync|NotifyAsync|i18n.translations.changed" \
src/Platform/I18n -g '*.cs'

I18n overview · Request language and Culture · Testing and GA

100%

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