Skip to content
Internationalization Architecture for Global .NET Applications | ICU, Localization, and Cultural Formatting

Internationalization Architecture for Global .NET Applications | ICU, Localization, and Cultural Formatting

1 The Modern .NET Internationalization Strategy

Global .NET applications now operate across dozens of locales, currency systems, writing systems, and cultural conventions. The problem space is no longer limited to translating UI strings. Teams must handle plural rules, date and currency volatility, culture-aware sorting, global search behavior, measurement systems, and layouts that respond to text direction and expansion. These concerns cut across runtime behavior, data storage, UI composition, and deployment workflows. This section establishes the architectural baseline for internationalization in modern .NET applications and frames internationalization as a first-class system concern rather than a UI afterthought.

1.1 The ICU Pivot: Why .NET 5+ Shifted Away from NLS

Before .NET 5, globalization behavior on Windows depended on National Language Support (NLS). NLS delegated formatting, sorting, and cultural rules to the operating system. This design worked reasonably well for Windows-only deployments but broke down as .NET became a cross-platform runtime. The same code produced different results depending on whether it ran on Windows Server, Linux containers, or macOS.

The move to ICU (International Components for Unicode) fundamentally changed this model. ICU is a mature, cross-platform globalization library used by Android, iOS, Java, Chrome, Firefox, and most modern operating systems. By embedding ICU directly into the .NET runtime, Microsoft removed OS-specific behavior from the globalization pipeline.

At an architectural level, this change delivers three key benefits:

  1. Deterministic behavior across platforms Formatting, sorting, and pluralization rules behave identically in Windows services, Linux containers, and cloud-native environments.

  2. Faster alignment with global standards ICU updates track Unicode CLDR releases, which means new currencies, language rules, and cultural changes arrive faster than OS-level updates.

  3. Clear ownership boundaries Globalization behavior is now owned by the runtime and application, not the underlying host OS.

The runtime stack now looks like this:

flowchart TB
    App[.NET Application Code]
    SG[System.Globalization APIs]
    ICU[ICU Libraries]
    OS[Operating System]

    App --> SG
    SG --> ICU
    ICU --> OS

Your application calls System.Globalization. Those APIs delegate to ICU for culture data, collation, plural rules, calendars, and number formatting. The OS is no longer the source of truth for cultural behavior.

You can verify that ICU is active at runtime:

Console.WriteLine(GlobalizationMode.UseNls); // false means ICU is in use

For any global .NET system today, ICU is not optional. It is the baseline assumption.

1.2 Architecture-First i18n: i18n, L10n, and Localizability

One of the most common causes of fragile localization systems is treating internationalization as a translation problem. In reality, three separate concerns are involved, and they must be handled independently.

Internationalization (i18n)

Internationalization is the system’s ability to operate correctly across cultures. It includes culture-aware formatting, calendars, sorting rules, time zones, numeric behavior, and data models that can represent global concepts without ambiguity. i18n is infrastructure. It belongs in runtime configuration, middleware, and shared libraries.

Localization (L10n)

Localization is the process of supplying culture-specific content. This includes translated strings, localized imagery, region-specific terminology, and market-specific legal text. Localization depends on translation workflows, review processes, and external tools such as TMS platforms.

Localizability

Localizability is a design discipline. It ensures the system can accept localized content without breaking. This includes avoiding hardcoded strings, allowing variable reordering, supporting text expansion, mirroring layouts for RTL languages, and treating fonts and icons as replaceable assets.

A robust .NET application treats these as complementary layers. Internationalization defines how the system behaves. Localization provides the data. Localizability ensures the UI and data model can absorb that data safely.

1.3 The .NET 10 Runtime Context: Performance, Sorting, and Culture Fallbacks

.NET 10 continues to refine globalization performance and correctness. Most improvements are not new APIs but better execution characteristics and clearer cultural semantics.

Runtime performance improvements

Culture creation, string comparison, normalization, and collation have all been optimized. In high-throughput systems, repeated culture-aware comparisons now allocate less memory and execute faster. This matters in sorting-heavy workloads such as product catalogs, search results, and reporting pipelines.

Numeric-aware sorting

Many users expect numbers embedded in strings to sort numerically rather than lexically. For example, "File2" should come before "File10". ICU-backed comparisons support this directly:

var culture = CultureInfo.GetCultureInfo("en-US");
var comparer = culture.CompareInfo;

var result = comparer.Compare(
    "File2",
    "File10",
    CompareOptions.NumericSort);

This behavior is critical in domains like document management, inventory systems, and e-commerce SKUs. Without numeric ordering, sorted lists appear broken to end users.

Culture fallback chains and their implications

ICU defines a deterministic fallback chain when an exact culture match is not available. This chain matters because formatting rules, calendars, and pluralization may change at each level.

Consider zh-Hant-TW (Traditional Chinese, Taiwan):

Fallback order:

  1. zh-Hant-TW (language + script + region)
  2. zh-Hant (language + script)
  3. zh (language)
  4. invariant culture

Architecturally, this means:

  • Script takes precedence over region
  • Formatting stays consistent for Traditional Chinese across regions
  • Applications behave the same across web, mobile, and API layers

Another example:

fr-CA (Canadian French):

  1. fr-CA
  2. fr
  3. invariant

If you store formatting assumptions or localized resources at the wrong level, you may accidentally override correct fallback behavior. The takeaway is simple: always align resource storage and formatting logic with ICU’s fallback hierarchy, not with ad-hoc region rules.

1.4 Culture Resolution Strategies: Where Culture Comes From

Before deciding how to store localized data, an application must decide how culture is resolved per request. This is a core architectural decision and affects caching, routing, and API behavior.

Common strategies include:

  1. User preference (stored profile) Preferred for authenticated systems. Culture is explicit and stable.

  2. URL-based resolution Examples: fr.example.com, /de/products. Good for SEO and bookmarking.

  3. Accept-Language header Useful for first-time visitors but unreliable as a long-term signal.

  4. Cookie-based persistence Bridges anonymous and authenticated experiences.

Most production systems combine these using a precedence order:

User Profile → URL → Cookie → Accept-Language → Default

In ASP.NET Core, this often appears as custom request culture middleware layered on top of UseRequestLocalization. Getting this order wrong leads to subtle bugs, such as users seeing mixed-language content or incorrect formatting in shared caches.

1.5 Identifying the Source of Truth: RESX, JSON, or Database

Once culture resolution is defined, the next decision is where localized content lives. This choice affects deployment frequency, team workflows, and runtime performance. The trade-offs are easier to evaluate when compared directly.

ApproachContent VolatilityDeployment ImpactTeam OwnershipCaching ComplexityTooling Support
RESX / Satellite AssembliesLowRequires rebuild and redeployEngineeringLowExcellent (.NET native)
JSON-based ProvidersMediumNo redeploy requiredEngineering + ContentMediumGood (custom / OSS)
Database-driven LocalizationHighRuntime updatesProduct / ContentHighExternal tooling

When RESX makes sense

RESX works well for stable UI copy owned by engineering teams. It integrates cleanly with ASP.NET Core and provides compile-time safety. Its main drawback is that every translation update requires a new build.

services.AddLocalization(options => options.ResourcesPath = "Resources");

When JSON providers are a better fit

JSON-based localization is well suited for teams working with headless CMS platforms or CDNs. It allows hot updates without redeployments and fits modern content workflows. The trade-off is the lack of strong typing and the need for custom loaders.

When database-driven localization is unavoidable

Multi-tenant SaaS platforms and systems with user-generated content require runtime updates and per-tenant overrides. Databases become the source of truth, with aggressive caching layered on top. This model offers flexibility at the cost of complexity and requires disciplined observability.

The key architectural rule is consistency. Pick one primary source of truth, align it with your culture resolution strategy, and design caching and fallback behavior explicitly rather than implicitly.


2 Beyond Strings: Mastery of Cultural Data Formatting

Cultural formatting governs how dates, times, numbers, currencies, and units are represented to users. These details are not cosmetic. Incorrect formatting erodes trust, causes financial errors, and introduces subtle data corruption. In global systems, formatting must be deterministic, traceable, and intentionally placed within the architecture. This section focuses on how modern .NET applications should model, transport, and format culturally sensitive data without mixing responsibilities across layers.

2.1 Date and Time Resilience with NodaTime

Relying on System.DateTime in global applications leads to ambiguity and data loss. DateTime merges multiple concepts—an instant in time, a local clock reading, and an implicit time zone—into a single type. It does not retain the originating time zone and behaves unpredictably during daylight saving transitions.

NodaTime addresses these problems by separating concerns into explicit types:

  • Instant represents a precise moment on the global timeline.
  • LocalDate, LocalTime, and LocalDateTime represent human calendar values without time zone assumptions.
  • ZonedDateTime combines a local date/time with a specific time zone.

Why DateTime breaks down in global systems

  1. No time zone identity A value like 2026-03-29T02:15 is meaningless without knowing which time zone it belongs to.

  2. DST ambiguity and gaps Some local times never occur, while others occur twice. DateTime silently adjusts these values, often without warning.

  3. Serialization inconsistency APIs frequently assume UTC, but DateTime.Kind is easy to misuse and hard to enforce across services.

Resolving DST boundaries correctly with NodaTime

Consider the DST transition in Germany, where clocks jump from 02:00 to 03:00:

var tz = DateTimeZoneProviders.Tzdb["Europe/Berlin"];
var local = new LocalDateTime(2026, 3, 29, 2, 30);

// Lenient resolution shifts forward to the next valid instant
var resolved = tz.AtLeniently(local);

If your domain requires strict handling, use AtStrictly and handle the exception explicitly:

try
{
    var resolved = tz.AtStrictly(local);
}
catch (SkippedTimeException)
{
    // Handle invalid local time according to business rules
}

The key architectural point is that ambiguity is handled deliberately, not implicitly.

Where date and time formatting belongs

APIs should not format dates for presentation. APIs should return raw, culture-neutral values such as Instant or DateTimeOffset. Formatting belongs in the presentation layer, where culture context is known.

API contract:

public record OrderDto(Instant CreatedAt);

UI formatting:

var zoned = order.CreatedAt.InZone(userTimeZone);
var display = zoned.ToString("yyyy-MM-dd HH:mm", userCulture);

This separation prevents APIs from baking in assumptions about language, region, or display format.

2.2 Precision Currency Handling

Currency handling combines numeric precision, currency identity, and cultural formatting. Treating currency as “just a number with a symbol” is one of the fastest ways to introduce financial defects.

Currency as a first-class domain concept

A robust model separates amount from currency identity:

public record Money(decimal Amount, string CurrencyCode);

This makes currency explicit, prevents accidental mixing, and supports multi-currency systems naturally.

The three layers of currency correctness

  1. Numeric precision Monetary values should use decimal to avoid floating-point rounding errors.

  2. Currency identity ISO 4217 codes (e.g., USD, EUR, CHF) are data, not formatting hints. They must be preserved end-to-end.

  3. Cultural presentation Formatting depends on culture: symbol placement, separators, spacing, and negative patterns.

Formatting example:

var money = new Money(1234.56m, "EUR");
var culture = CultureInfo.GetCultureInfo("fr-FR");

var format = (NumberFormatInfo)culture.NumberFormat.Clone();
format.CurrencySymbol = culture.NumberFormat.CurrencySymbol;

Console.WriteLine(money.Amount.ToString("C", format));
// 1 234,56 €

Hardcoding symbols or assuming prefix placement fails immediately outside English-speaking markets.

Formatting currency that differs from culture

Some systems display prices in a merchant currency while respecting the user’s cultural conventions:

var money = new Money(99.95m, "CHF");
var culture = CultureInfo.GetCultureInfo("de-DE");

var format = (NumberFormatInfo)culture.NumberFormat.Clone();
format.CurrencySymbol = "CHF";
format.CurrencyDecimalDigits = 2;

Console.WriteLine(money.Amount.ToString("C", format));

The architecture rule remains the same: APIs return structured money values; presentation layers apply culture-aware formatting.

2.3 ICU Message Skeletons with Consistent Temporal Models

ICU message skeletons allow translators to control formatting and word order without requiring code changes. They are especially important when date, number, and currency formatting must adapt per language.

A skeleton example:

{created, date, ::yyyyMMdd}
{price, number, ::currency/EUR}

To remain consistent with the time model established earlier, examples should use NodaTime rather than DateTime.

var instant = SystemClock.Instance.GetCurrentInstant();
var zoned = instant.InZone(DateTimeZoneProviders.Tzdb["Europe/Paris"]);

var result = Smart.Format(
    CultureInfo.GetCultureInfo("fr-FR"),
    "Commande créée le {created:date:yyyy-MM-dd} pour {price:C}.",
    new
    {
        created = zoned.ToDateTimeUnspecified(),
        price = 42.50m
    });

The formatter handles ordering and formatting. Translators decide how values appear.

English:

Order created on {created} costing {price}.

German:

Bestellung vom {created} zum Preis von {price}.

The application code remains unchanged. This decoupling is essential for sustainable localization at scale.

2.4 Unit and Measurement Conversion Architecture

Units of measurement are another common source of silent errors. Length, weight, temperature, and volume vary by region, and users expect values in familiar systems. Hardcoding conversions or formatting units directly in APIs leads to brittle contracts.

Architectural rule for units

  • APIs expose canonical units (usually metric).
  • User preferences determine display units.
  • Conversion happens in the presentation layer.

API contract example

public record ShippingEstimate(decimal DistanceInKilometers);

User preference model

public enum MeasurementSystem
{
    Metric,
    Imperial
}

public record UserPreferences(MeasurementSystem Units);

Conversion and display

var distanceKm = estimate.DistanceInKilometers;

if (userPrefs.Units == MeasurementSystem.Imperial)
{
    var miles = Length.FromKilometres(distanceKm).InMiles;
    display = $"{miles:N1} mi";
}
else
{
    display = $"{distanceKm:N1} km";
}

Human-readable durations

Humanizer complements unit conversion by expressing values naturally:

TimeSpan.FromHours(2).Humanize(culture: new CultureInfo("es-ES"));
// "2 horas"

Together, structured units, explicit preferences, and late-stage formatting prevent inconsistencies across APIs, UIs, and reports.


3 Linguistic Complexity: Plurals, Gender, and ICU Message Format

Natural language does not behave like code. Words change form based on quantity, gender, grammatical role, and sentence structure. English hides much of this complexity, which is why many systems appear to work until a second language is added. ICU-based message formatting exists to move linguistic rules out of application logic and into data that translators can control. This section focuses on how to model and render linguistically correct messages in .NET without leaking grammar rules into code.

3.1 The “You have 1 message(s)” Problem

English pluralization is simple enough that developers often hardcode it. Most other languages are not. Plural rules can depend on exact numeric ranges, not just singular versus plural.

Common plural categories include:

  • One — singular form
  • Few — used in Slavic and some Semitic languages
  • Many — higher numeric ranges
  • Zero — special handling in some languages
  • Other — fallback

SmartFormat.NET supports pluralization using named branches. The correct syntax looks like this:

var result = Smart.Format(
    "You have {count:plural:one{# message}|other{# messages}}.",
    new { count = 1 });

For count = 5, the same template produces:

You have 5 messages.

This pattern scales to languages with more categories because the plural logic lives in the template, not in code.

For comparison, the equivalent ICU MessageFormat rule looks like this:

{count, plural,
  one {You have # message}
  other {You have # messages}
}

Languages such as Polish, Russian, and Arabic introduce additional branches. Polish, for example, requires different word forms depending on whether the count is 1, 2–4, or 5 and above. Attempting to encode these rules with if statements quickly becomes unmaintainable. Plural rules must be data-driven.

3.2 Gender-Aware Messaging with Fluent.Net

Many languages require agreement between nouns, adjectives, and verbs based on grammatical gender. This is not limited to personal pronouns; it affects greetings, system messages, and status notifications.

Fluent.Net provides a clean model for handling this complexity, but it must be wired correctly.

Loading Fluent resources

Fluent messages are stored in .ftl files. A typical project structure looks like this:

Localization/
  en-US/
    messages.ftl
  fr-FR/
    messages.ftl

Initialization:

var loader = new ResourceLoader("Localization/{locale}");
var bundle = new FluentBundle("fr-FR");

var ftlText = File.ReadAllText("Localization/fr-FR/messages.ftl");
bundle.AddResource(new FluentResource(ftlText));

var formatter = new FluentFormatter(bundle);

Formatting a gender-aware message

FTL message:

welcome-user =
    { $gender ->
        [male] Bienvenu, { $username } !
        [female] Bienvenue, { $username } !
       *[other] Bienvenue, { $username } !
    }

Usage in code:

var message = formatter.Format(
    "welcome-user",
    new Dictionary<string, object>
    {
        ["gender"] = "female",
        ["username"] = "Ana"
    });

This produces:

Bienvenue, Ana !

The important architectural point is ownership. Developers define placeholders. Translators define grammatical structure. Gender logic never appears in application code.

3.3 Where Message Templates Live (Architecture Decision)

Message templates must live outside compiled code. The exact location depends on volatility and workflow.

Common patterns:

  1. Resource files (RESX or FTL) Best for stable UI messages owned by engineering teams.

  2. Database-backed templates Useful when content changes frequently or varies by tenant.

  3. TMS exports (JSON / FTL) Ideal for teams using Phrase, Lokalise, or Crowdin.

Architectural guidance:

  • Do not embed ICU templates directly in C# source files.
  • Do treat templates as content, not logic.
  • Do align storage with your culture resolution and caching strategy.

Message formatting belongs in the presentation layer. APIs should return raw values and message keys, not formatted sentences.

3.4 Complex String Interdependency

Some languages require changes that depend on more than one variable at a time. Word forms can change based on both count and gender, or on prepositions that alter grammatical case.

Examples:

German:

  • 1 Tag
  • in 1 Tag
  • vor 1 Tag

Arabic:

  • Word shape and diacritics change based on grammatical role.

These cases require nested rules rather than flat substitution.

ICU-style nested logic:

{count, plural,
  one {{
    gender, select,
      male {He has one new message}
      female {She has one new message}
      other {They have one new message}
  }}
  other {{
    gender, select,
      male {He has # new messages}
      female {She has # new messages}
      other {They have # new messages}
  }}
}

This structure mirrors how modern TMS platforms store translations. The application only supplies values; translators define how language behaves.

3.5 Error Handling and Defensive Formatting

Formatting failures are inevitable. Placeholders go missing, translators introduce typos, or templates drift out of sync with code. These failures should not crash the application.

A defensive pattern with SmartFormat:

string FormatSafe(string template, object model, string fallback)
{
    try
    {
        return Smart.Format(template, model);
    }
    catch (FormattingException)
    {
        return fallback;
    }
}

Usage:

var text = FormatSafe(
    template,
    new { count = 3 },
    "You have messages.");

This ensures users see something reasonable even when localization data is imperfect.

3.6 Performance Considerations for Message Formatting

ICU-style message parsing is not free. Parsing templates repeatedly in hot paths can become expensive, especially in high-throughput systems.

Key performance practices:

  • Cache compiled templates Parse once per culture and reuse.

  • Avoid formatting in tight loops Batch operations when possible.

  • Do not format in APIs Formatting belongs in UI layers where request volume is lower.

Example caching approach:

private static readonly ConcurrentDictionary<string, string> TemplateCache = new();

var template = TemplateCache.GetOrAdd(
    key,
    _ => LoadTemplateFromStore(key));

With caching in place, message formatting remains flexible without becoming a bottleneck.

3.7 Why ICU MessageFormat Is the Long-Term Strategy

ICU MessageFormat is widely supported across platforms and tooling. It aligns with how translators work and how TMS systems store language data.

From an architectural perspective, it provides:

  1. Clear separation of concerns Grammar belongs to language experts, not developers.

  2. Consistency across platforms The same templates work on web, mobile, and backend systems.

  3. Future resilience New plural rules and linguistic changes are handled through data, not code.

For teams building global .NET applications, adopting ICU-style message formatting is not an optimization. It is a prerequisite for correctness at scale.


4 Global UI/UX Architecture and RTL Layouts

Global-ready user interfaces require more than translated text. Layouts must respond to writing direction, handle text expansion, and render scripts that may not exist in default font stacks. These concerns affect CSS, component design, asset loading, and test strategy. For Blazor, Razor Components, and MAUI applications, the goal is predictable behavior when culture changes—without scattering conditional logic throughout the UI.

4.1 Right-to-Left (RTL) Logic: Mirroring with CSS Logical Properties

Right-to-left languages require full layout mirroring, not just reversed text flow. Hardcoded directional CSS such as margin-left, float: right, or text-align: left breaks as soon as Arabic or Hebrew is introduced. Modern CSS logical properties solve this by mapping layout intent to writing direction automatically.

A common pattern is to drive the document direction from the resolved request culture:

@inject IHttpContextAccessor HttpContextAccessor

@{
    var culture = HttpContextAccessor.HttpContext?
        .Features.Get<IRequestCultureFeature>()?
        .RequestCulture.Culture;

    var dir = culture?.TextInfo.IsRightToLeft == true ? "rtl" : "ltr";
}
<html dir="@dir">

Once dir is set on the root element, CSS logical properties handle mirroring transparently:

.card {
    padding-inline-start: 1rem;
    padding-inline-end: 1rem;
    margin-inline-start: auto;
    margin-inline-end: 0;
    text-align: start;
}

This approach eliminates the need for conditional CSS or runtime style switching. Logical properties express intent (“start” and “end”) rather than physical direction, allowing the browser to do the right thing for both LTR and RTL contexts.

Legacy browser fallback (when unavoidable)

If you must support environments without full logical property support, treat directional helpers as a fallback, not the primary strategy:

// Legacy fallback only – avoid using this in modern layouts
public static string MarginStartFallback(bool isRtl) =>
    isRtl ? "margin-right" : "margin-left";

The architectural rule remains: prefer logical properties everywhere and isolate fallbacks to legacy-only components.

4.2 Component-Level RTL Architecture in Blazor

Relying entirely on global dir state makes components fragile and hard to reuse. A better approach is to design components that consume direction as input rather than assuming global state.

A simple pattern is a direction-aware base component:

public abstract class DirectionalComponentBase : ComponentBase
{
    [CascadingParameter]
    public CultureInfo CurrentCulture { get; set; } = CultureInfo.CurrentUICulture;

    protected bool IsRtl => CurrentCulture.TextInfo.IsRightToLeft;
}

A component can then bind direction explicitly:

<div class="toolbar" dir="@(IsRtl ? "rtl" : "ltr")">
    @ChildContent
</div>

This approach has several advantages:

  • Components remain testable in isolation
  • Layout behavior is explicit and predictable
  • Nested components can override direction when required (for example, mixed-language UIs)

Global direction still exists, but components no longer depend on it implicitly.

4.3 Dynamic UI Expansion: Designing Elastic Layouts

Localized text often expands by 30–40% compared to English. German, Finnish, and Russian frequently expose layout assumptions that went unnoticed during development. Fixed widths and single-line assumptions are the most common failure points.

Elastic layout design relies on three principles:

  1. Avoid fixed widths for text containers Buttons, tabs, and labels should size to content.

  2. Use min/max constraints instead of absolute sizes This allows growth without breaking layout grids.

  3. Allow wrapping by default Truncation should be a deliberate choice, not a default.

Example of an elastic button:

button.action {
    min-width: 140px;
    max-width: 100%;
    padding: 0.5rem 1rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    white-space: normal;
}

Rather than guessing which strings will expand, teams should use pseudo-localization during development. This technique injects accented characters and padding to simulate worst-case translations and surfaces layout issues early.

Dynamic measurement can still be useful in edge cases:

var width = await JSRuntime.InvokeAsync<int>("measureTextWidth", text);
if (width > 180)
{
    CssClass = "wrap";
}

Elastic layouts reduce rework, improve accessibility, and make UI behavior predictable across languages.

4.4 Global Typography & Font Strategy: Avoiding “Tofu”

Tofu—empty square glyphs—appears when a font does not support the required script. It immediately signals to users that the application is not designed for their language. Relying on browser defaults is risky; controlled font stacks produce more consistent results.

A practical font strategy includes:

  • A primary Latin font
  • Script-specific fallback fonts
  • Conditional loading to avoid shipping unnecessary assets

Example font stack:

body {
    font-family:
        "Inter",
        "Segoe UI",
        "Noto Sans",
        "Noto Sans Arabic",
        "Noto Sans CJK",
        sans-serif;
}

Secure conditional font loading

Font loading must not allow arbitrary resource injection. Always use a whitelist:

@code {
    private static readonly HashSet<string> AllowedFonts =
        new() { "noto-arabic.css", "noto-cjk.css" };

    protected override async Task OnInitializedAsync()
    {
        var culture = CultureInfo.CurrentUICulture;
        var font = culture.TwoLetterISOLanguageName switch
        {
            "ar" => "noto-arabic.css",
            "zh" => "noto-cjk.css",
            _ => null
        };

        if (font != null && AllowedFonts.Contains(font))
        {
            await JSRuntime.InvokeVoidAsync("loadFont", font);
        }
    }
}

JavaScript:

window.loadFont = function (href) {
    const link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = `/fonts/${href}`;
    document.head.appendChild(link);
};

This ensures only approved assets are loaded and avoids XSS or CSP violations.

4.5 Iconography, Symbolism, and Asset Strategy

Icons and imagery carry cultural meaning. Symbols that feel neutral in one region may be confusing or offensive in another. A robust UI architecture treats icons as localized assets, not static decorations.

Build-time vs runtime asset decisions

Bake assets into builds when:

  • Icons are brand-critical
  • Variants change rarely
  • Performance and offline support matter

Load assets dynamically when:

  • Cultural sensitivity varies by region
  • Tenants override imagery
  • Content teams control visuals

A localized asset provider abstracts this decision:

public interface ILocalizedAssetProvider
{
    string GetIconPath(string key, CultureInfo culture);
}

JSON-backed implementation:

public class JsonAssetProvider : ILocalizedAssetProvider
{
    private readonly IDictionary<string, IDictionary<string, string>> _map;

    public JsonAssetProvider(IWebHostEnvironment env)
    {
        var json = File.ReadAllText(Path.Combine(env.ContentRootPath, "assets.json"));
        _map = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string>>>(json)!;
    }

    public string GetIconPath(string key, CultureInfo culture)
    {
        if (_map.TryGetValue(key, out var variants) &&
            variants.TryGetValue(culture.TwoLetterISOLanguageName, out var path))
            return path;

        return variants?["default"] ?? "/img/default.svg";
    }
}

Mapping example:

{
  "mail": {
    "en": "/img/icons/mail-us.svg",
    "jp": "/img/icons/mail-jp.svg",
    "default": "/img/icons/mail-generic.svg"
  }
}

This allows the UI to remain culture-agnostic while asset decisions evolve independently.

4.6 Testing Strategy for RTL and Global Layouts

RTL issues are visual by nature and often escape unit tests. Teams should rely on automated UI testing combined with visual regression.

Key practices:

  • Run UI tests under RTL locales (ar, he)
  • Capture screenshots and diff against baselines
  • Validate alignment, icon direction, and overflow

These tests are typically implemented with Playwright or Selenium and are discussed in more detail in Section 7.3. The important point here is architectural: RTL support must be testable, or it will regress silently.


5 The Global Data Tier: Sorting, Searching, and Persistence

A global UI is only as reliable as the data tier beneath it. Sorting, searching, and persistence must respect cultural rules just as much as the frontend does. If the database layer applies the wrong collation or the search engine tokenizes text incorrectly, users see inconsistent ordering, broken search results, and subtle data corruption. This section focuses on the architectural decisions required to make the data tier culture-aware without destabilizing live systems.

5.1 Database Collation Strategy

Collation defines how text is compared and sorted. Cultural expectations vary widely. In Swedish, Å sorts after Z. In English, it is treated as a variant of A. If the database collation does not match user expectations, even simple ORDER BY clauses produce results that appear wrong.

SQL Server example

CREATE TABLE Products (
    Id INT IDENTITY PRIMARY KEY,
    Name NVARCHAR(200) COLLATE Finnish_Swedish_100_CI_AI
);

Query-level override:

SELECT Name
FROM Products
ORDER BY Name COLLATE Finnish_Swedish_100_CI_AI;

PostgreSQL with ICU collations

PostgreSQL supports ICU-based collations, which align well with .NET’s runtime behavior:

CREATE COLLATION sv_swe (provider = icu, locale = 'sv-SE');

Usage:

SELECT name
FROM products
ORDER BY name COLLATE "sv_swe";

Collation migration strategy for live systems

Changing collation on an existing column is destructive and can lock large tables. In production systems, collation changes must be staged.

Recommended migration patterns:

  1. Shadow column strategy

    • Add a new column with the desired collation
    • Backfill data in batches
    • Switch reads and writes
    • Drop the old column later
  2. Query-level collation overrides

    • Use ORDER BY ... COLLATE as a transitional step
    • Avoids schema changes but may affect index usage
  3. Per-locale derived columns

    • Precompute sortable keys for specific markets
    • Useful for high-volume catalogs

Collation should be treated as a schema contract. Changing it casually in production almost always leads to outages.

5.2 Locale-Aware Search & Indexing

Search introduces a different problem space. Sorting compares characters, but search engines must tokenize, normalize, and stem text. These rules are language-specific. A German analyzer understands compound words. An Arabic analyzer handles diacritics. A Turkish analyzer treats dotted and dotless “i” correctly.

Elasticsearch analyzer configuration

Index mapping:

{
  "settings": {
    "analysis": {
      "analyzer": {
        "german_analyzer": {
          "type": "german"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "description": {
        "type": "text",
        "analyzer": "german_analyzer"
      }
    }
  }
}

.NET client integration

Using the official Elasticsearch .NET client:

var settings = new ElasticsearchClientSettings(new Uri("http://localhost:9200"))
    .DefaultIndex("products");

var client = new ElasticsearchClient(settings);

await client.Indices.CreateAsync("products", c => c
    .Settings(s => s
        .Analysis(a => a
            .Analyzers(an => an
                .German("german_analyzer")
            )
        )
    )
    .Mappings(m => m
        .Properties(p => p
            .Text(t => t
                .Name("description")
                .Analyzer("german_analyzer")
            )
        )
    )
);

Indexing data:

await client.IndexAsync(new
{
    id = 1,
    description = "größer"
});

Analyzer selection is an index-time decision. Retrofitting analyzers later requires reindexing, so language strategy must be defined early.

5.3 Storing Localized Data

How localized data is stored determines scalability, query complexity, and operational flexibility. This choice has long-term consequences.


1 One column per language (generally an anti-pattern)

CREATE TABLE Products (
  Id INT PRIMARY KEY,
  Name_en NVARCHAR(200),
  Name_fr NVARCHAR(200),
  Name_de NVARCHAR(200)
);

Avoid this pattern except when:

  • The system supports 2–3 fixed languages
  • There is no plan to add more locales
  • Localization scope is extremely limited

Why it fails at scale:

  • Schema changes for every new language
  • Complex queries and duplication
  • Impossible to generalize across locales

For most global systems, this approach becomes a dead end.


2 Translation table relation

CREATE TABLE ProductTranslations (
  ProductId INT,
  Culture NVARCHAR(10),
  Name NVARCHAR(200),
  Description NVARCHAR(2000),
  PRIMARY KEY (ProductId, Culture)
);

EF Core configuration:

builder.Entity<ProductTranslation>()
    .HasKey(t => new { t.ProductId, t.Culture });

Pros

  • Scales to many languages
  • Clear ownership boundaries
  • Supports culture fallback

Cons

  • Requires joins
  • Needs aggressive caching for read-heavy workloads

Database-level fallback pattern

If de-AT is missing, fall back to de:

SELECT Name
FROM ProductTranslations
WHERE ProductId = @id
  AND Culture IN ('de-AT', 'de')
ORDER BY CASE Culture
    WHEN 'de-AT' THEN 1
    WHEN 'de' THEN 2
END
LIMIT 1;

This logic mirrors ICU fallback behavior and avoids application-side branching.


3 JSONB document (PostgreSQL)

ALTER TABLE products ADD COLUMN translations JSONB;

Sample data:

{
  "en": { "name": "Chair", "desc": "Wooden chair" },
  "de": { "name": "Stuhl", "desc": "Holzstuhl" }
}

Access pattern:

var cultureKey = culture.TwoLetterISOLanguageName;
var name = doc.RootElement.GetProperty(cultureKey).GetProperty("name").GetString();

Indexing JSONB translations

JSONB is queryable, but without indexes it does not scale. Always add a GIN index:

CREATE INDEX idx_translations
ON products
USING GIN (translations);

Pros

  • Flexible schema
  • Easy updates
  • Good read performance with proper indexing

Cons

  • More complex queries
  • Validation must happen in application code

JSONB works best when translation keys are stable and access patterns are well understood.

5.4 Time Zone Persistence

Time zone handling spans storage, APIs, and presentation. The core rule is simple: store absolute time, display local time.

Using DateTimeOffset ensures offsets are preserved:

public class Event
{
    public int Id { get; set; }
    public DateTimeOffset Created { get; set; }
}

Converting for display:

var tz = DateTimeZoneProviders.Tzdb[user.TimeZone];
var instant = Instant.FromDateTimeOffset(entity.Created);
var zoned = instant.InZone(tz);

Formatting in the UI layer:

zoned.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture);

This approach:

  • Avoids server-local assumptions
  • Preserves audit accuracy
  • Aligns with NodaTime usage described earlier

Time zones should never be inferred implicitly. They must be explicit at the boundaries and transformed deliberately at presentation time.


6 Localization-as-a-Service (LaaS) and Workflows

Modern localization is no longer a build-time concern. Global products require the ability to correct translations, add markets, and update terminology without redeploying services. Localization-as-a-Service (LaaS) treats translations as live data, integrates translation platforms into delivery pipelines, and exposes clear operational controls for cache invalidation and observability. This section focuses on how to build localization workflows that remain safe, responsive, and auditable in production.

6.1 Moving Beyond Static Files: Dynamic Localization Providers

Dynamic localization providers load translations from a headless CMS, CDN, or localization service at runtime. This allows content teams to correct or refine translations without waiting for engineering releases. However, this flexibility comes with architectural constraints around performance and reliability.

A naïve implementation that performs synchronous HTTP calls per request can deadlock or collapse under load. Instead, dynamic providers should rely on asynchronous loading with aggressive caching.

A safe pattern is to load translations asynchronously into a cache and serve requests from memory:

public class CmsLocalizationProvider : IStringLocalizer
{
    private readonly IMemoryCache _cache;
    private readonly HttpClient _http;

    public CmsLocalizationProvider(IMemoryCache cache, HttpClient http)
    {
        _cache = cache;
        _http = http;
    }

    public LocalizedString this[string name]
    {
        get
        {
            var culture = CultureInfo.CurrentUICulture.Name;
            var dict = _cache.GetOrCreate(
                $"translations:{culture}",
                entry =>
                {
                    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
                    return LoadTranslationsAsync(culture).GetAwaiter().GetResult();
                });

            return new LocalizedString(
                name,
                dict.TryGetValue(name, out var value) ? value : name,
                resourceNotFound: !dict.ContainsKey(name));
        }
    }

    private async Task<Dictionary<string, string>> LoadTranslationsAsync(string culture)
    {
        var json = await _http.GetStringAsync($"/cms/i18n/{culture}.json");
        return JsonSerializer.Deserialize<Dictionary<string, string>>(json)!;
    }
}

The blocking call is acceptable only because it is shielded by caching and executes infrequently. Without caching, this pattern should be avoided entirely. In high-scale systems, a background warm-up service often populates the cache at startup.

6.2 Cache Invalidation and Update Propagation

Caching translations introduces a new challenge: how do running instances learn about updates? Without a strategy, changes may take minutes or hours to propagate.

Two common invalidation patterns are used in production.

Polling-based refresh

Instances periodically re-fetch translation metadata:

  • Simple to implement
  • Predictable behavior
  • Slower propagation

This approach is often acceptable for low-volatility content such as UI labels.

Webhook-driven invalidation (preferred)

Most TMS platforms (Phrase, Lokalise, Crowdin) support webhooks when translations change. Applications expose a secure endpoint that invalidates cache entries immediately.

Example webhook handler:

[ApiController]
public class TranslationWebhookController : ControllerBase
{
    private readonly IMemoryCache _cache;

    public TranslationWebhookController(IMemoryCache cache)
    {
        _cache = cache;
    }

    [HttpPost("/webhooks/translations")]
    public IActionResult OnTranslationUpdate([FromBody] TmsWebhook payload)
    {
        _cache.Remove($"translations:{payload.Locale}");
        return Ok();
    }
}

This pattern provides near real-time updates without polling and scales well across multiple application instances when paired with distributed caches such as Redis.

The architectural rule is simple: dynamic localization requires explicit invalidation paths. Relying on TTL alone leads to stale content and inconsistent user experiences.

6.3 Integrating TMS Platforms into CI/CD

While runtime updates are useful, build-time synchronization remains essential for versioned content such as emails, PDFs, or regulatory text. CI/CD pipelines remain the safest way to manage these assets.

A typical GitHub Actions step pulls translations before a build:

steps:
  - name: Download translations
    run: |
      curl -o resources.zip \
        -H "Authorization: Bearer $TMS_TOKEN" \
        https://api.phrase.com/v2/projects/$PROJECT_ID/download
      unzip resources.zip -d src/Resources

After extraction, builds regenerate strongly typed resources or package satellite assemblies:

builder.Services.AddLocalization(options =>
    options.ResourcesPath = "Resources");

The key architectural distinction:

  • Runtime providers support rapid updates
  • CI/CD integration provides versioned, auditable content

Most mature systems use both.

6.4 Human-in-the-Loop AI Translation (with Guardrails)

Machine translation accelerates early localization but introduces legal, privacy, and quality risks. AI-assisted translation should be treated as a drafting tool, not a final authority.

Example using Azure AI Translator:

var response = await client.TranslateAsync(
    to: new[] { "de" },
    text: "Your order has shipped.");
var translation = response.Value.First().Translations.First().Text;

Semantic Kernel enables context-aware prompting:

var kernel = Kernel.CreateBuilder().Build();
var translate = kernel.CreateFunctionFromPrompt(
    "Translate into {lang} while preserving product names and legal terms: {{$input}}");

var result = await kernel.InvokeAsync(
    translate,
    new() { ["input"] = text, ["lang"] = culture });

Required guardrails

Before sending text to AI services, teams must enforce:

  • PII filtering Never send names, addresses, or identifiers without explicit approval.

  • Data residency constraints Ensure the translation service complies with regional regulations (GDPR, data sovereignty).

  • Human review AI output should be reviewed and approved in the TMS before publication.

AI translation is powerful, but uncontrolled use can introduce compliance and trust issues that are difficult to unwind later.

6.5 The Pseudo-Localization Test

Pseudo-localization exposes layout issues, missing keys, and hardcoded strings early. It transforms text into longer, accented forms that mimic worst-case translations.

Example transformation:

"Submit" → "[!! Šúƀṁīť !!]"

A simple implementation uses a character substitution map:

public class PseudoLocalizer : IStringLocalizer
{
    private static readonly Dictionary<char, char> PseudoMap = new()
    {
        ['a'] = 'á', ['e'] = 'ë', ['i'] = 'ī', ['o'] = 'ø', ['u'] = 'ú',
        ['A'] = 'Á', ['E'] = 'Ë', ['I'] = 'Ī', ['O'] = 'Ø', ['U'] = 'Ú'
    };

    public LocalizedString this[string name] =>
        new(name, $"[!! {Transform(name)} !!]");

    private static string Transform(string input) =>
        string.Concat(input.Select(c => PseudoMap.TryGetValue(c, out var mapped) ? mapped : c));
}

Enable it during development:

services.AddSingleton<IStringLocalizer, PseudoLocalizer>();

Pseudo-localization is not optional for global UI work. It catches problems that translation alone cannot and should be part of every developer’s local workflow.


7 Testing, Performance, and Scalability

Global applications must behave consistently across languages, scripts, and cultural settings under real production load. That means validating layouts, formats, and translations while also keeping latency, memory usage, and startup time under control. At scale, internationalization becomes an operational concern: caching strategies, bundle size, test coverage, and telemetry all matter. This section focuses on the patterns that keep globalized .NET applications predictable and observable as the number of supported locales grows.

7.1 High-Performance Resource Loading

Large systems often support dozens of locales, each with thousands of strings. Without deliberate caching, IStringLocalizer can repeatedly hit disk, deserialize JSON, or query a database on every request. That cost adds up quickly under load.

A common approach is to load translations once per culture and cache them. In multi-tenant systems, the cache key must include tenant context to avoid collisions:

public class CachedLocalizer : IStringLocalizer
{
    private readonly IMemoryCache _cache;
    private readonly ILocalizationSource _source;
    private readonly CultureInfo _culture;
    private readonly string _tenantId;

    public CachedLocalizer(
        IMemoryCache cache,
        ILocalizationSource source,
        ITenantContext tenantContext)
    {
        _cache = cache;
        _source = source;
        _culture = CultureInfo.CurrentUICulture;
        _tenantId = tenantContext.TenantId;
    }

    public LocalizedString this[string name]
    {
        get
        {
            var key = $"i18n:{_tenantId}:{_culture.Name}";

            var values = _cache.GetOrCreate(key, entry =>
            {
                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
                return _source.LoadStrings(_tenantId, _culture);
            });

            return new LocalizedString(
                name,
                values.TryGetValue(name, out var val) ? val : name,
                resourceNotFound: !values.ContainsKey(name));
        }
    }
}

This ensures that:

  • Tenants never see each other’s translations
  • Memory usage grows only for active tenant–culture pairs
  • Cold cultures are loaded lazily rather than upfront

In distributed deployments, IDistributedCache (often backed by Redis) is preferred so that all instances share a consistent view. Cache invalidation is handled via pub/sub or webhook-driven eviction, as discussed earlier in the LaaS section.

7.2 Tree-Shaking Locales in Blazor WebAssembly and MAUI

Client-side applications pay a real cost for globalization data. ICU includes cultural rules for hundreds of locales, and shipping all of it inflates bundle size and startup time.

Blazor WebAssembly (current approach)

In modern Blazor WebAssembly, locale trimming is controlled by limiting which cultures are loaded at runtime rather than shipping all ICU data. A common pattern is to load culture data explicitly during startup:

using Microsoft.AspNetCore.Components.WebAssembly.Globalization;

await CultureInfoLoader.LoadCultureAsync("en-US");
await CultureInfoLoader.LoadCultureAsync("fr-FR");
await CultureInfoLoader.LoadCultureAsync("de-DE");

Only the required culture data is downloaded, reducing payload size significantly. This approach aligns with current .NET tooling and avoids relying on unsupported MSBuild items.

MAUI ICU trimming

For MAUI apps, trimming happens at build time:

<PropertyGroup>
  <IncludeAllIcuData>false</IncludeAllIcuData>
  <IcuDataModes>Invariant;Sharded</IcuDataModes>
</PropertyGroup>

Sharded ICU data ensures only the subsets required by configured cultures are included in the final package.

Architectural guidance:

  • Trim aggressively for consumer-facing apps
  • Validate fallback behavior when a specific locale shard is missing
  • Treat ICU data size as part of performance budgeting, not an afterthought

7.3 Automated i18n Testing with Playwright or Selenium

Internationalization bugs are often visual and contextual. Automated UI tests are the only reliable way to catch regressions across scripts, directions, and fonts.

With Playwright, you can test both behavior and layout under different locales:

using Microsoft.Playwright;
using static Microsoft.Playwright.Assertions;

[Test]
public async Task RtlLayoutRendersCorrectly()
{
    using var playwright = await Playwright.CreateAsync();
    var browser = await playwright.Chromium.LaunchAsync();

    var context = await browser.NewContextAsync(new BrowserNewContextOptions
    {
        Locale = "ar-EG"
    });

    var page = await context.NewPageAsync();
    await page.GotoAsync("https://localhost:5001");

    var direction = await page.EvalOnSelectorAsync<string>("html", "e => e.dir");
    Assert.AreEqual("rtl", direction);

    await Expect(page).ToHaveScreenshotAsync("rtl-baseline.png");
}

The snapshot assertion turns this into a visual regression test. Any unintended layout changes—misaligned icons, incorrect mirroring, text overflow—cause the test to fail.

Selenium can still be useful for simpler assertions:

var options = new ChromeOptions();
options.AddUserProfilePreference("intl.accept_languages", "ja-JP");

using var driver = new ChromeDriver(options);
driver.Navigate().GoToUrl("https://localhost:5001");

var price = driver.FindElement(By.Id("price")).Text;
Assert.IsTrue(price.Contains("¥"));

The key is consistency: every supported locale should be exercised by automation, not just English.

Load testing with many locales

Functional correctness is not enough. Teams should simulate real traffic across many cultures.

Typical approaches:

  • k6 scripts that randomize Accept-Language headers across 30–50 locales
  • JMeter parameterized tests that rotate culture cookies or URL prefixes
  • Separate scenarios for high-volume locales versus long-tail locales

This helps uncover cache pressure, cold-locale latency spikes, and memory growth patterns that do not appear in single-locale tests.

7.4 Observability: i18n-Specific Metrics in Production

Missing translation keys are only one signal. Mature global systems track a broader set of i18n metrics.

A logging decorator still provides a foundation:

public class ObservabilityLocalizer : IStringLocalizer
{
    private readonly IStringLocalizer _inner;
    private readonly ILogger<ObservabilityLocalizer> _logger;

    public ObservabilityLocalizer(
        IStringLocalizer inner,
        ILogger<ObservabilityLocalizer> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public LocalizedString this[string name]
    {
        get
        {
            var result = _inner[name];
            if (result.ResourceNotFound)
            {
                _logger.LogWarning(
                    "Missing translation key '{Key}' for culture '{Culture}'",
                    name,
                    CultureInfo.CurrentUICulture.Name);
            }
            return result;
        }
    }
}

Beyond missing keys, teams should track:

  • Locale distribution per request Which cultures are actually used in production?

  • Fallback culture usage rate How often does the system fall back from fr-CA to fr?

  • Translation coverage percentage What proportion of keys are localized per culture?

  • Cold-locale latency How long does the first request for a rarely used locale take?

Exposing these metrics to Application Insights, Prometheus, or Elasticsearch dashboards allows teams to detect gaps early and prioritize localization work based on real usage rather than assumptions.


8 Reference Implementation: A Global E-Commerce Case Study

A concrete reference implementation helps connect the architectural ideas from earlier sections. This case study describes a global e-commerce system built on .NET 10, designed to serve multiple regions with consistent cultural behavior. The goal is not to showcase every feature, but to show how culture resolution, localization, data storage, and UI rendering fit together in a real system.

8.1 The Architecture Blueprint

At a high level, the system is split into clear responsibilities. Each layer owns a specific concern and avoids leaking culture-specific formatting into places where it does not belong.

flowchart LR
    User[Browser / Mobile Client]
    Blazor[Blazor WebAssembly]
    API[ASP.NET Core Web API]
    LocSvc[Localization Service / CMS]
    DB[(PostgreSQL / SQL Server)]
    Search[(Elasticsearch)]

    User --> Blazor
    Blazor --> API
    API --> LocSvc
    API --> DB
    API --> Search

    Blazor --> LocSvc

Key responsibilities

  1. Web API (ASP.NET Core) Exposes product data, prices, timestamps, and localization keys. All time values are stored and transmitted as Instant. Monetary values are returned as structured data (amount + currency code).

  2. Blazor WebAssembly frontend Resolves culture, loads the required ICU data, formats values for display, and renders RTL layouts when needed.

  3. Localization service Acts as the source of truth for translations. Backed by a CMS or TMS and fronted by caching.

  4. Database tier Stores canonical data plus localized fields (via translation tables or JSONB) using culture-aware collations.

  5. Search pipeline Uses language-specific analyzers selected at index time.

The flow for a typical request looks like this:

  • User visits fr.example.com
  • Culture is resolved from the subdomain
  • API returns raw data and localization keys
  • Blazor formats dates, numbers, and currencies using fr-FR
  • ICU rules drive pluralization, sorting, and layout direction
  • Search queries are routed to the French analyzer

This separation keeps the system predictable and avoids duplicating formatting logic across layers.

8.2 Handling the “Request Culture” Pipeline

Culture resolution must be explicit, configurable, and consistent. Hardcoding supported cultures inside middleware quickly becomes a maintenance problem.

A better approach is to drive culture support from configuration using LocalizationOptions.

Configuration

services.Configure<RequestLocalizationOptions>(options =>
{
    options.SupportedCultures = new[]
    {
        new CultureInfo("en"),
        new CultureInfo("fr"),
        new CultureInfo("de"),
        new CultureInfo("ar")
    };

    options.SupportedUICultures = options.SupportedCultures;
});

Subdomain-based culture middleware

public class SubdomainCultureMiddleware
{
    private readonly RequestDelegate _next;
    private readonly HashSet<string> _supportedCultures;

    public SubdomainCultureMiddleware(
        RequestDelegate next,
        IOptions<RequestLocalizationOptions> options)
    {
        _next = next;
        _supportedCultures = options.Value.SupportedCultures
            .Select(c => c.Name)
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var host = context.Request.Host.Host;
        var prefix = host.Split('.').FirstOrDefault();

        if (prefix != null && _supportedCultures.Contains(prefix))
        {
            var culture = new CultureInfo(prefix);
            var requestCulture = new RequestCulture(culture);

            context.Features.Set<IRequestCultureFeature>(
                new RequestCultureFeature(requestCulture, null));
        }

        await _next(context);
    }
}

Registration order matters:

app.UseMiddleware<SubdomainCultureMiddleware>();
app.UseRequestLocalization();

This approach allows teams to add or remove supported cultures without touching middleware code and keeps culture resolution aligned with the rest of the application configuration.

8.3 Real-World Code Samples

8.3.1 Implementing an ICU-Compatible IStringLocalizer

Rather than inventing a custom message formatter API, the implementation should rely on a proven library such as SmartFormat.NET. ICU-style templates live in external resources (JSON, FTL, or TMS exports), not in code.

public class IcuStringLocalizer : IStringLocalizer
{
    private readonly IDictionary<string, string> _templates;
    private readonly CultureInfo _culture;

    public IcuStringLocalizer(
        IDictionary<string, string> templates,
        CultureInfo culture)
    {
        _templates = templates;
        _culture = culture;
    }

    public LocalizedString this[string name]
    {
        get
        {
            if (!_templates.TryGetValue(name, out var template))
                return new LocalizedString(name, name, true);

            // SmartFormat.NET usage
            var formatted = Smart.Format(_culture, template, new { });
            return new LocalizedString(name, formatted);
        }
    }
}

Important architectural notes:

  • Message templates are data, not code
  • Formatting happens in the presentation layer
  • Grammar rules are owned by translators, not developers

If your system requires more advanced constructs (gender, select rules), Fluent or a dedicated ICU-compatible formatter can be substituted without changing the architecture.

When using NodaTime with Entity Framework Core, the maintained integration package should be used instead of custom converters.

Configuration:

using NodaTime;
using NodaTime.EntityFrameworkCore;

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseNpgsql(connectionString,
        o => o.UseNodaTime());
});

Entity model:

public class Order
{
    public int Id { get; set; }
    public Instant CreatedAt { get; set; }
}

This approach ensures:

  • Correct mapping of Instant and other NodaTime types
  • Consistent behavior across providers
  • Reduced maintenance burden compared to custom converters

8.4 Architect’s Checklist: Auditing for Global Readiness

This checklist summarizes the most important decisions to validate before shipping a global .NET application.

  1. ICU-backed globalization is enabled and verified
  2. Culture resolution is explicit and configuration-driven
  3. No hardcoded strings or directional CSS
  4. Text expansion validated with pseudo-localization
  5. RTL layouts covered by automated visual tests
  6. Database collations align with target markets
  7. All timestamps are stored and transmitted as Instant
  8. Currency is modeled as amount + currency code
  9. Missing-key and fallback usage telemetry is live
  10. Fonts and visual assets load safely and per locale

A system that satisfies these points is positioned to scale to new languages, regions, and regulatory environments without major refactoring.

Advertisement