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 point | Input | Current semantics |
|---|---|---|
GetString(key) | Ambient language/tenant/office | Complete single-key fallback |
GetString(key, language, ...) | Explicit context | Complete single-key fallback |
GetStringsAsync(keys, ...) | A key collection | Requested-language DB only; missing becomes final key segment |
GET /api/i18n/resources | Language + prefix | Calls 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
// 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.
// 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.
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-0The 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.
{ "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
RemoveByTagAsync("i18n")clears the current instance;- LocalResourceSync
Translationasks other instances to clear the same tag; i18n.translations.changedtells 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
// 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
| Scenario | Must prove |
|---|---|
| DB/JSON/fallback/key layers | Single and batch return the same value |
| Empty-key bundle | Returns a complete merged set |
| Prefix | Applies consistently to every source |
| Database failure | Degradation is observable and does not poison cache |
| JSON damage/conflict | Deterministic policy and alert |
| Platform/Tenant/Office edit | Evicts exactly the affected audience |
| Lost/out-of-order/duplicate sync | Version or TTL converges |
| Frequent edits | No stampede; bounded P99 |
| Concurrent synchronous L() | Thread-pool and tail-latency budget passes |
| Formatting/HTML | Placeholder safety and output encoding |
16. Inspection commands
# 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