Pragmatic.Internationalization
Comprehensive internationalization (i18n) and localization (l10n) for .NET 10. Culture-aware formatting, translations with plural support, and humanizers.
The Problem
Section titled “The Problem”.NET provides low-level globalization primitives (CultureInfo, NumberFormatInfo, .resx files), but building a real multilingual application exposes several gaps:
- Thread culture is fragile:
CultureInfo.CurrentCultureis thread-local. After anawait, your continuation may run on a different thread with a different culture. SettingDefaultThreadCurrentCultureis process-wide — a multi-tenant disaster. - No multi-scope culture: A request often needs Italian for UI, English for API responses, and German for invoices. .NET gives you two slots (
CurrentCulture,CurrentUICulture). For anything more, you are on your own. - Money is not a type: Every e-commerce app reinvents a
Moneystruct. Without it, you can silently add USD to EUR, rounding rules vary by developer, and formatting is inconsistent. - Plurals are language-specific: English has two forms (1 item, 5 items). Russian has three. Arabic has six. The CLDR defines rules that cannot be reduced to
count == 1 ? singular : plural. - Resource files do not compose:
.resxscatters translations across XML files with no build-time completeness checking. You can ship a release with half your Italian translations missing.
The Solution
Section titled “The Solution”Pragmatic.Internationalization solves these with five pillars:
- I18NContext: An
AsyncLocal-based ambient context that flows acrossawaitboundaries, supports multiple culture scopes (UI, Data, custom), and syncs with .NET thread cultures. - Money & CurrencyCode: Value types enforcing same-currency arithmetic, ISO 4217 metadata, and culture-aware formatting.
- Formatting: Extension methods and injectable
GlobalizationFormatterfor numbers, dates, money, percentages, and file sizes. - Translation Keys (SG): A source generator reads JSON files at compile time and produces a static
Tclass with strongly-typedLocalizedStringproperties. Missing translations produce build warnings (PRAG1802). - Humanizers: Duration, ordinal, quantity, and relative time formatters with language-specific rules for 17+ languages.
// Middleware sets I18NContext from Accept-Language / query string / provider chain// It flows across all await boundaries automatically
var price = Money.From(99.99m, CurrencyCode.EUR);price.Format(); // "99,99 EUR" (it-IT) / "$99.99" (en-US)
// Compile-time safe translationsreturn new NotFoundError(T.Errors.OrderNotFound.Value);For the full architecture guide, see docs/concepts.md.
Packages
Section titled “Packages”| Package | Description |
|---|---|
Pragmatic.Internationalization | Core i18n: Money, Currency, Formatters, Humanizers |
Pragmatic.Internationalization.AspNetCore | Middleware, JSON converters, DI integration |
Pragmatic.Internationalization.EFCore | Value converters for Money and Currency |
Pragmatic.Internationalization.SourceGenerator | Generates T class with embedded translations |
Installation
Section titled “Installation”dotnet add package Pragmatic.Internationalizationdotnet add package Pragmatic.Internationalization.AspNetCoreFeatures
Section titled “Features”| Feature | Description |
|---|---|
| Money & Currency | Type-safe monetary values with 180+ ISO 4217 currencies |
| Culture Context | Thread-safe ambient culture via AsyncLocal |
| Formatters | Numbers, dates, money with culture-specific rules |
| Translations | Key-based localization with fallback chains |
| Strongly-Typed Keys | Source-generated T class with embedded translations |
| Plural Rules | CLDR-compliant plural support for 40+ languages |
| Humanizers | Duration, ordinals, quantities, relative time |
| Providers | In-memory, JSON files, composite with priority |
Quick Start
Section titled “Quick Start”Culture Context
Section titled “Culture Context”using Pragmatic.Internationalization;
// Set ambient cultureI18NContext.SetCulture("de-DE");
// Get current culturevar culture = I18N.Culture; // CultureInfo for de-DE
// Scoped culture changeI18N.WithCulture("it-IT", () =>{ var formatted = 1234.56m.FormatNumber(); // "1.234,56"});
// Async scopedawait I18NContext.WithCultureAsync("fr-FR", async () =>{ await ProcessOrderAsync();});Money & Currency
Section titled “Money & Currency”using Pragmatic.Internationalization;
// Create money valuesvar price = Money.From(99.99m, CurrencyCode.USD);var tax = Money.From(7.50m, CurrencyCode.USD);
// Arithmetic (same currency enforced)var total = price + tax; // $107.49var discounted = total * 0.9m; // $96.74
// Formatting (culture-aware)I18NContext.SetCulture("en-US");Console.WriteLine(total.Format()); // "$107.49"
I18NContext.SetCulture("de-DE");Console.WriteLine(total.Format()); // "107,49 $"
// Currency safetyvar usd = Money.From(100m, CurrencyCode.USD);var eur = Money.From(100m, CurrencyCode.EUR);// var mixed = usd + eur; // InvalidOperationException!Number & Date Formatting
Section titled “Number & Date Formatting”// Number formatting1234.56m.FormatNumber(); // "1,234.56" (en-US) / "1.234,56" (de-DE)0.15m.FormatPercent(); // "15%"1_500_000L.FormatFileSize(); // "1.43 MB"
// Date formattingvar now = DateTimeOffset.Now;now.FormatDate(); // "1/10/2026" (en-US) / "10.01.2026" (de-DE)now.FormatTime(); // "3:45 PM" / "15:45"now.FormatDateTime(); // "1/10/2026 3:45 PM"Money Type
Section titled “Money Type”Creating Money
Section titled “Creating Money”// From decimal + currencyvar price = Money.From(99.99m, CurrencyCode.EUR);
// From string currency codevar price2 = Money.From(99.99m, "EUR");
// Zero valuevar zero = Money.Zero(CurrencyCode.USD);
// Safe parsingif (Money.TryFrom(99.99m, "XYZ", out var money)) Console.WriteLine(money);Arithmetic Operations
Section titled “Arithmetic Operations”var a = Money.From(100m, CurrencyCode.USD);var b = Money.From(25m, CurrencyCode.USD);
var sum = a + b; // $125.00var diff = a - b; // $75.00var scaled = a * 1.5m; // $150.00var divided = a / 4; // $25.00var negated = -a; // -$100.00Rounding
Section titled “Rounding”var price = Money.From(99.999m, CurrencyCode.USD);
// Round to specific decimalsvar rounded = price.Round(2); // $100.00
// Round to currency's minor unitsvar currencyRounded = price.RoundToMinorUnit(); // $100.00 (USD has 2 minor units)
// JPY has 0 minor unitsvar yen = Money.From(999.5m, CurrencyCode.JPY);var yenRounded = yen.RoundToMinorUnit(); // 1000Properties
Section titled “Properties”var money = Money.From(-50m, CurrencyCode.EUR);
money.Amount; // -50mmoney.Currency; // CurrencyCode.EURmoney.IsZero; // falsemoney.IsPositive; // falsemoney.IsNegative; // trueCurrency Code
Section titled “Currency Code”Static Currency Properties
Section titled “Static Currency Properties”All 180+ ISO 4217 currencies available as static properties:
CurrencyCode.USD // US Dollar ($, 2 minor units)CurrencyCode.EUR // Euro (€, 2 minor units)CurrencyCode.JPY // Japanese Yen (¥, 0 minor units)CurrencyCode.GBP // British Pound (£, 2 minor units)CurrencyCode.CHF // Swiss Franc (Fr., 2 minor units)// ... 175+ moreCurrency Properties
Section titled “Currency Properties”var usd = CurrencyCode.USD;
usd.Code; // "USD"usd.Name; // "US Dollar"usd.Symbol; // "$"usd.MinorUnits; // 2Parsing
Section titled “Parsing”// Parse with exceptionvar eur = CurrencyCode.FromCode("EUR");
// Safe parseif (CurrencyCode.TryFromCode("XYZ", out var currency)) Console.WriteLine(currency.Name);
// Validationbool valid = CurrencyCode.IsValid("USD"); // truebool invalid = CurrencyCode.IsValid("XYZ"); // false
// All currenciesforeach (var c in CurrencyCode.All) Console.WriteLine($"{c.Code}: {c.Name}");Translations & Localization
Section titled “Translations & Localization”LocalizedString (Entity Data)
Section titled “LocalizedString (Entity Data)”LocalizedString stores translations per-entity in the database (as JSON column). Use for dynamic content like product names, descriptions, etc.
// Create localized string with multiple culturesvar greeting = LocalizedString.From( ("en", "Hello, {name}!"), ("de", "Hallo, {name}!"), ("it", "Ciao, {name}!"));
// Access for current cultureI18NContext.SetCulture("de-DE");var text = greeting.Value; // "Hallo, {name}!"
// Access specific culturevar italian = greeting["it"]; // "Ciao, {name}!"
// Fallback chain (de-AT → de → en)I18NContext.SetCulture("de-AT");var withFallback = greeting.Get("de-AT"); // "Hallo, {name}!" (fallback to de)Note: For application strings (UI labels, error messages), use the generated
Tclass instead. See Strongly-Typed Translation Keys.
Localization Providers
Section titled “Localization Providers”// In-memory providervar provider = new InMemoryLocalizationProvider() .AddString("en", "welcome", "Welcome!") .AddString("de", "welcome", "Willkommen!") .AddPlural("en", "items", (PluralCategory.One, "1 item"), (PluralCategory.Other, "{count} items"));
// JSON file providervar jsonProvider = new JsonLocalizationProvider("Resources/Translations", options);
// Composite (priority-based)var composite = new CompositeLocalizationProvider( new[] { jsonProvider, provider });String Localizer
Section titled “String Localizer”var localizer = new StringLocalizer(provider, options);
// Simple lookupvar welcome = localizer["welcome"]; // "Welcome!" or "Willkommen!"
// With interpolationvar greeting = localizer["hello", "John"]; // "Hello, John!"
// Plural supportvar items = localizer.Plural("items", 5); // "5 items"
// Culture switchingvar german = localizer.WithCulture("de");JSON Translation Files
Section titled “JSON Translation Files”Resources/├── en.json├── de.json└── it.json{ "welcome": "Welcome!", "items": { "one": "1 item", "other": "{count} items" }, "errors": { "notFound": "{entity} not found" }}Strongly-Typed Translation Keys
Section titled “Strongly-Typed Translation Keys”Source generator that creates a static T class with LocalizedString properties. Translations are embedded at compile-time - no runtime file loading required.
- Add translation JSON files to your project:
translations/├── en.json└── it.json- Include as AdditionalFiles in
.csproj:
<ItemGroup> <AdditionalFiles Include="translations/*.json" /></ItemGroup>- Reference the source generator:
<ItemGroup> <ProjectReference Include="..\Pragmatic.Internationalization.SourceGenerator\..." OutputItemType="Analyzer" ReferenceOutputAssembly="false" /></ItemGroup>Generated T Class
Section titled “Generated T Class”// Generated from translations/en.json + translations/it.jsonpublic static class T{ /// <summary>Key: "welcome"</summary> public static LocalizedString Welcome => LocalizedString.From( ("en", "Welcome to our app!"), ("it", "Benvenuto nella nostra app!"));
public static class Errors { public static LocalizedString NotFound => LocalizedString.From( ("en", "Resource not found"), ("it", "Risorsa non trovata")); }}using Pragmatic.Internationalization.Context;
// Current culture access (via I18NContext)I18NContext.SetCulture("en");Console.WriteLine(T.Welcome.Value); // "Welcome to our app!"
I18NContext.SetCulture("it");Console.WriteLine(T.Welcome.Value); // "Benvenuto nella nostra app!"
// Direct culture access via indexerConsole.WriteLine(T.Welcome["en"]); // "Welcome to our app!"Console.WriteLine(T.Welcome["it"]); // "Benvenuto nella nostra app!"
// Implicit string conversion (uses current culture)string message = T.Welcome; // Works!
// LocalizedString propertiesvar cultures = T.Welcome.Cultures; // ["en", "it"]var count = T.Welcome.Count; // 2var isEmpty = T.Welcome.IsEmpty; // falseFolder Structures
Section titled “Folder Structures”The source generator automatically detects three folder layouts from the first AdditionalFile path. No configuration is needed — just organize your JSON files and the generator figures out the rest.
Detection runs against the first file in the AdditionalFiles list. It checks, in order:
- Is the parent directory a culture code? (e.g.,
en,it,de-DE) —> Folder per culture - Does the filename contain a dot where the last segment is a culture code? (e.g.,
common.en.json) —> Flat with suffix - Otherwise —> Simple (filename itself is the culture code)
Culture codes match the pattern xx, xx-YY, xx_YY, or xx-Hant (2 lowercase letters + optional separator + 2-4 chars).
Structure A: Folder per culture
Section titled “Structure A: Folder per culture”Each culture has its own subdirectory. Files with the same name across cultures are matched together.
translations/├── en/│ ├── common.json│ └── errors.json└── it/ ├── common.json └── errors.json<AdditionalFiles Include="translations/**/*.json" />| Source | Culture extracted from | Base name | Generated key (ByFile=true) |
|---|---|---|---|
translations/en/common.json | Parent dir en | common | T.Common.Welcome |
translations/it/errors.json | Parent dir it | errors | T.Errors.NotFound |
Best for projects with many files per culture. Each file becomes a nested class in the generated T class.
Structure B: Flat with culture suffix
Section titled “Structure B: Flat with culture suffix”All files in one folder. The culture code is the last dot-segment before .json.
translations/├── common.en.json├── common.it.json├── errors.en.json└── errors.it.json<AdditionalFiles Include="translations/*.json" />| Source | Culture extracted from | Base name | Generated key (ByFile=true) |
|---|---|---|---|
common.en.json | Last dot-segment en | common | T.Common.Welcome |
errors.it.json | Last dot-segment it | errors | T.Errors.NotFound |
Good when you prefer a flat directory but still want to split translations by topic.
Structure C: Simple (one file per culture)
Section titled “Structure C: Simple (one file per culture)”One JSON file per culture. The filename (without .json) is the culture code. All keys live in a single file.
translations/├── en.json└── it.json<AdditionalFiles Include="translations/*.json" />| Source | Culture extracted from | Base name | Generated key |
|---|---|---|---|
en.json | Filename en | (none) | T.Welcome |
it.json | Filename it | (none) | T.Welcome |
Simplest layout. Since there is no base name, ByFile grouping has no effect — all keys are placed directly on the root T class (nested classes still appear from dotted JSON keys like errors.notFound).
Recognized root folders
Section titled “Recognized root folders”Files are picked up when they sit under any of these directory names:
| Folder name | Example |
|---|---|
translations | translations/en.json |
i18n | i18n/en/common.json |
locales | locales/common.en.json |
lang | lang/en.json |
Files matching *.translations.json or *.i18n.json are also included regardless of folder.
Configuration via Attribute
Section titled “Configuration via Attribute”[assembly: TranslationKeys( ClassName = "T", // Default: "T" Namespace = "", // Default: auto-derived from folder EmbedTranslations = true, // Default: true (LocalizedString with values) ByFile = true, // Default: true (nested classes per file) DefaultCulture = "en")] // Default: "en"ByFile Grouping
Section titled “ByFile Grouping”When ByFile = true (default), each JSON file becomes a nested class:
translations/en/common.json → T.Common.Welcometranslations/en/errors.json → T.Errors.NotFoundMetadata for Cross-Assembly Discovery
Section titled “Metadata for Cross-Assembly Discovery”When Pragmatic.Composition is referenced, the generator emits assembly metadata for discovery:
[assembly: PragmaticMetadata(MetadataCategory.Translations, "1.0.0", """{ "className": "T", "namespace": "MyApp", "totalKeys": 25, "cultures": ["en", "it", "de"], "files": ["common", "errors", "validation"]}""")]This enables host applications to discover translation metadata from referenced assemblies.
Plural Rules
Section titled “Plural Rules”CLDR-compliant plural rules for 40+ languages:
// English: one/otherPluralRules.GetCategory("en", 1); // OnePluralRules.GetCategory("en", 5); // Other
// Russian: one/few/many/otherPluralRules.GetCategory("ru", 1); // OnePluralRules.GetCategory("ru", 2); // FewPluralRules.GetCategory("ru", 5); // Many
// Arabic: zero/one/two/few/many/otherPluralRules.GetCategory("ar", 0); // ZeroPluralRules.GetCategory("ar", 1); // OnePluralRules.GetCategory("ar", 2); // TwoPlural Categories
Section titled “Plural Categories”| Category | Languages Example |
|---|---|
| Zero | Arabic, Welsh |
| One | English, German, Italian |
| Two | Arabic, Welsh |
| Few | Russian, Polish, Czech (2-4) |
| Many | Russian, Polish (5-20) |
| Other | All (default plural) |
Humanizers
Section titled “Humanizers”Duration Formatter
Section titled “Duration Formatter”var formatter = new DurationFormatter("en", DurationFormat.Short);
formatter.Format(TimeSpan.FromMinutes(150)); // "2h 30m"formatter.Format(TimeSpan.FromDays(3.5)); // "3d 12h"
// Long formatvar long = DurationFormatter.Long("en");long.Format(TimeSpan.FromHours(2.5)); // "2 hours 30 minutes"
// Compact formatvar compact = DurationFormatter.Compact("en");compact.Format(TimeSpan.FromHours(2.5)); // "2:30:00"Supported languages: en, de, fr, it, es, pt, ru, zh, ja, ko, ar, nl, pl, sv, no, da, fi
Ordinal Formatter
Section titled “Ordinal Formatter”var ordinal = new OrdinalFormatter("en");
ordinal.Format(1); // "1st"ordinal.Format(2); // "2nd"ordinal.Format(3); // "3rd"ordinal.Format(11); // "11th"ordinal.Format(21); // "21st"
// Germanvar german = OrdinalFormatter.ForCulture("de");german.Format(1); // "1."german.Format(2); // "2."
// French with gendervar feminine = new OrdinalFormatter("fr", OrdinalGender.Feminine);feminine.Format(1); // "1ère"Quantity Formatter
Section titled “Quantity Formatter”var qty = new QuantityFormatter("en");
qty.Format(1_500); // "1.5K"qty.Format(2_300_000); // "2.3M"qty.Format(1_200_000_000); // "1.2B"
// Germanvar german = QuantityFormatter.ForCulture("de");german.Format(1_500_000); // "1,5 Mio"
// Chinesevar chinese = QuantityFormatter.ForCulture("zh");chinese.Format(1_000_000); // "100万"Relative Time Formatter
Section titled “Relative Time Formatter”var relativeTime = new RelativeTimeFormatter(localizer);
relativeTime.Format(TimeSpan.FromSeconds(30)); // "30 seconds ago"relativeTime.Format(TimeSpan.FromMinutes(5)); // "5 minutes ago"relativeTime.Format(TimeSpan.FromHours(3)); // "3 hours ago"relativeTime.Format(TimeSpan.FromDays(1)); // "yesterday"relativeTime.Format(TimeSpan.FromDays(5)); // "5 days ago"relativeTime.Format(TimeSpan.FromDays(14)); // "2 weeks ago"ASP.NET Core Integration
Section titled “ASP.NET Core Integration”builder.Services.AddPragmaticInternationalization(options =>{ options.DefaultCulture = CultureInfo.GetCultureInfo("en-US"); options.SupportedCultures = ["en-US", "de-DE", "it-IT", "fr-FR"]; options.QueryStringKey = "culture"; // ?culture=de-DE});
var app = builder.Build();app.UsePragmaticInternationalization();Culture Resolution
Section titled “Culture Resolution”Order of precedence:
- Query string:
?culture=de-DE - Accept-Language header
- Default culture
JSON Serialization
Section titled “JSON Serialization”Money and Currency types serialize automatically:
{ "price": { "amount": 99.99, "currency": "EUR" }}Entity Framework Core Integration
Section titled “Entity Framework Core Integration”// DbContext configurationprotected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.ApplyPragmaticInternationalization();}
// Or with conventionsprotected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder){ configurationBuilder.ApplyPragmaticInternationalizationConventions();}Value Converters
Section titled “Value Converters”CurrencyCode→varchar(3)Money→ Configured with amount and currency columns
GlobalizationFormatter
Section titled “GlobalizationFormatter”DI-injectable formatter service:
public class OrderService(GlobalizationFormatter formatter){ public string FormatOrderTotal(Order order) { return formatter.FormatMoney(order.Total); }
public string FormatOrderDate(Order order) { return formatter.FormatDateTime(order.CreatedAt); }}Available Methods
Section titled “Available Methods”| Method | Example Output (en-US) |
|---|---|
FormatMoney(money) | ”$99.99” |
FormatNumber(decimal, decimals) | ”1,234.56” |
FormatInteger(long) | ”1,234” |
FormatPercent(decimal) | ”15%“ |
FormatFileSize(bytes) | ”1.43 MB” |
FormatDate(date) | ”1/10/2026” |
FormatTime(time) | ”3:45 PM” |
FormatDateTime(datetime) | ”1/10/2026 3:45 PM” |
FormatDateTimeLong(datetime) | ”January 10, 2026 3:45 PM” |
Extension Methods
Section titled “Extension Methods”Number Extensions
Section titled “Number Extensions”1234.56m.FormatNumber(); // Culture-aware1234.56m.FormatNumber(3); // With decimals0.156m.FormatPercent(); // As percentage1_500_000L.FormatFileSize(); // Human-readable bytesDate Extensions
Section titled “Date Extensions”DateTimeOffset.Now.FormatDate();DateTimeOffset.Now.FormatTime();DateTimeOffset.Now.FormatDateTime();DateOnly.FromDateTime(DateTime.Now).FormatDateLong();TimeOnly.FromDateTime(DateTime.Now).FormatTimeLong();Money Extensions
Section titled “Money Extensions”money.Format(); // Using current culturemoney.Format("de-DE"); // Specific culturemoney.RoundToCurrency(); // Round to minor unitsmoney.FormatOrDefault("-"); // Nullable with defaultDecision Guide: LocalizedString vs T Class
Section titled “Decision Guide: LocalizedString vs T Class”| Aspect | LocalizedString | T Class |
|---|---|---|
| Storage | Database (JSON column) | Assembly (embedded at compile-time) |
| Scope | Per-entity instance | Application-wide |
| Content | Dynamic, user-generated | Fixed, developer-controlled |
| Mutability | Add/remove translations at runtime | Recompile to change |
| Type Safety | Dictionary-like access (greeting["de"]) | Strongly-typed properties (T.Errors.NotFound) |
| Performance | Lazy loading from DB | Zero-cost (in-memory, no file I/O) |
| Fallback | Automatic culture chain via I18NContext | Same chain (returns LocalizedString internally) |
| Deployment | New translations without redeploy | Requires recompile and redeploy |
| EF Core | Value converter to JSON column | Not applicable (static class) |
When to Use LocalizedString
Section titled “When to Use LocalizedString”Use for database-driven, per-entity content where translations vary per record and may be edited at runtime:
// Each product has its own translations -- stored as JSON in the DBpublic class Product{ public LocalizedString Name { get; set; } // "Widget" / "Gerät" / "Gadget" public LocalizedString Description { get; set; } // User-generated, per product}
// Runtime flexibility: add a new language without recompilingproduct.Name.Set("ja", "ウィジェット");
// Access uses current culture automatically (via I18NContext)var name = product.Name.Value;
// Or access a specific culture directlyvar german = product.Name["de"];
// Exact access without fallbackvar exact = product.Name.GetExact("de-AT"); // null if de-AT not present
// Scope-specific access (e.g., invoicing in a different language)var invoiceName = product.Name.GetForScope("invoicing");Examples: product names, category descriptions, CMS content, invoice notes, user-generated content.
When to Use T Class
Section titled “When to Use T Class”Use for compile-time safe application strings where translations are developer-controlled and known at build time:
// translations/en.json → source-generated static T class// All translations are embedded in the assembly -- no runtime file loading
return new NotFoundError(T.Errors.ProductNotFound); // Compile-time safeConsole.WriteLine(T.Common.Save); // "Speichern" in de-DE
// The T class returns LocalizedString, so the same culture resolution appliesstring message = T.Welcome; // Implicit conversion uses current culturevar cultures = T.Welcome.Cultures; // ["en", "it"] -- all embedded culturesWhen EmbedTranslations = false, the T class generates LocalizationKey references instead. These are lightweight key strings resolved at runtime through an ILocalizationProvider, which allows swapping translation sources without recompiling.
Examples: UI labels, error messages, email subjects, validation messages, system notifications.
Hybrid Usage
Section titled “Hybrid Usage”Most applications use both approaches together:
[Endpoint(HttpVerb.Get, "/products/{id}")]public partial class GetProductEndpoint : Endpoint<ProductResponse>{ public override async Task<Result<ProductResponse>> HandleAsync(CancellationToken ct) { var product = await _repo.GetByIdAsync(Id, ct); if (product is null) return new NotFoundError(T.Errors.ProductNotFound); // T class (fixed)
return new ProductResponse { Name = product.Name.Value, // LocalizedString (per-entity, from DB) Description = product.Description.Value }; }}AsyncLocal Culture Context
Section titled “AsyncLocal Culture Context”I18NContext stores the current internationalization context using AsyncLocal<I18NContext?>, making it thread-safe and compatible with async/await. The context automatically flows across await boundaries.
How It Works
Section titled “How It Works”The context is an immutable object stored in AsyncLocal:
// Internal storage (from I18NContext.cs)private static readonly AsyncLocal<I18NContext?> SCurrent = new();When you set a culture, a new immutable I18NContext instance is created and assigned to the AsyncLocal. The previous instance is never mutated. This guarantees thread safety without locks.
// Setting culture creates a new context instanceI18NContext.SetCulture("de-DE");
// The context is available anywhere in the same async flowvar culture = I18NContext.Current.UICulture; // de-DEFallback Behavior
Section titled “Fallback Behavior”If no context has been set explicitly, I18NContext.Current falls back to CultureInfo.CurrentCulture from the current thread. This means the system works out of the box in console applications and tests without any setup.
Thread Culture Synchronization
Section titled “Thread Culture Synchronization”When you set a culture via I18NContext.SetCulture(), the context also syncs with .NET’s built-in thread cultures:
UICulturesyncs withThread.CurrentThread.CurrentUICultureDataCulturesyncs withThread.CurrentThread.CurrentCulture
This ensures third-party libraries that read Thread.CurrentThread.CurrentCulture (e.g., ToString() formatting) behave consistently with the I18NContext.
Multi-Scope Culture
Section titled “Multi-Scope Culture”The context supports separate cultures for different purposes within the same request:
| Scope | Purpose | Example |
|---|---|---|
| UICulture | Display: labels, messages, user-facing content | it-IT |
| DataCulture | Storage: API responses, exports, database | en-US |
| Custom scopes | Specific needs like invoicing, reporting | de-DE for invoicing |
// UI in Italian, Data in EnglishI18NContext.SetCulture("it-IT");I18NContext.SetDataCulture(CultureCode.FromString("en-US"));
// Access via I18N shortcutsvar uiCulture = I18N.UI; // it-ITvar dataCulture = I18N.Data; // en-US
// Custom scope for invoicingI18N.SetScope("invoicing", CultureCode.FromString("de-DE"));var invoiceCulture = I18N.Scope["invoicing"]; // de-DEWhen SyncScopes is true (set via I18NConfig), changing the UI culture automatically updates the Data culture to match.
Scoped Execution
Section titled “Scoped Execution”For temporary culture changes, use the WithCulture / WithCultureAsync methods. These save the current context, execute the delegate with the new culture, and restore the original context in a finally block:
// Synchronous scopeI18NContext.WithCulture("de-DE", () =>{ var formatted = 1234.56m.FormatNumber(); // "1.234,56"});// Original culture is restored here
// Async scopeawait I18NContext.WithCultureAsync("fr-FR", async () =>{ await ProcessOrderAsync(); // Runs with fr-FR culture});
// Scoped with return valuevar result = I18NContext.WithCulture<string>("it-IT", () =>{ return T.Welcome.Value; // "Benvenuto nella nostra app!"});
// Data culture scope (independent of UI)I18NContext.WithDataCulture(CultureCode.FromString("en-US"), () =>{ var apiDate = DateTime.Now.ToString("O"); // ISO 8601 format});
// Custom scope executionI18NContext.WithScope("invoicing", CultureCode.FromString("de-DE"), () =>{ var invoiceTitle = product.Name.GetForScope("invoicing");});Async/Await Considerations
Section titled “Async/Await Considerations”AsyncLocal flows automatically with the ExecutionContext, so the culture is preserved across await points:
I18NContext.SetCulture("de-DE");
await Task.Delay(100);// Still de-DE here -- AsyncLocal flows across await
await Task.Run(() =>{ // Also de-DE -- AsyncLocal flows into Task.Run var culture = I18NContext.Current.UICulture;});Limitation with ConfigureAwait(false): The WithCultureAsync methods use ConfigureAwait(false) internally. This means the finally block that restores the previous context may run on a different thread. However, because AsyncLocal is tied to the ExecutionContext (not the thread), the context restore still works correctly. The thread culture properties (Thread.CurrentThread.CurrentUICulture) are restored in the finally block explicitly, ensuring proper cleanup regardless of which thread the continuation runs on.
Limitation with unsynchronized parallel work: If you spawn fire-and-forget tasks (e.g., _ = Task.Run(...)) inside a WithCultureAsync scope, those tasks capture the AsyncLocal value at spawn time. Changing the culture inside the spawned task does not affect the parent scope, and vice versa. This is standard AsyncLocal copy-on-write behavior.
Test Isolation
Section titled “Test Isolation”For tests, use Capture() and Restore() to ensure isolation between test cases:
// Capture before testvar snapshot = I18NContext.Capture();try{ I18NContext.SetCulture("de-DE"); // ... test code ...}finally{ // Restore original state (AsyncLocal + thread cultures) I18NContext.Restore(snapshot);}The I18NContextSnapshot record captures three things: the AsyncLocal context, Thread.CurrentThread.CurrentUICulture, and Thread.CurrentThread.CurrentCulture. Restoring the snapshot resets all three.
Plural Rules (Detailed)
Section titled “Plural Rules (Detailed)”PluralRules implements CLDR-compliant plural category selection for 40+ languages. The rules are organized by language family in partial classes:
PluralRules.cs— Core dispatch logic and language code extractionPluralRules.Germanic.cs— English, German, Dutch, Swedish, etc.PluralRules.Romance.cs— French, Italian, Spanish, Romanian, etc.PluralRules.Slavic.cs— Russian, Polish, Czech, Slovenian, etc.PluralRules.Other.cs— Arabic, Welsh, Irish, Baltic, Hebrew, Indic, etc.
Language vs Region Mapping
Section titled “Language vs Region Mapping”PluralRules.GetCategory(culture, count) extracts the language code from the culture string before applying rules. A culture like "de-AT" (Austrian German) is normalized to "de", so it uses the same plural rules as "de-DE" or plain "de". Region-specific cultures always inherit the base language’s plural behavior:
// All map to "en" → Germanic rulesPluralRules.GetCategory("en", 1); // OnePluralRules.GetCategory("en-US", 1); // OnePluralRules.GetCategory("en-GB", 1); // One
// All map to "ru" → East Slavic rulesPluralRules.GetCategory("ru", 5); // ManyPluralRules.GetCategory("ru-RU", 5); // ManyCLDR Plural Categories
Section titled “CLDR Plural Categories”All six CLDR categories are defined in the PluralCategory enum. Not every language uses all categories:
| Category | Description | Example Languages |
|---|---|---|
Zero | Explicit zero form | Arabic (ar), Welsh (cy), Latvian (lv) |
One | Singular form | English, German, Russian, Polish, most languages |
Two | Dual form | Arabic (ar), Welsh (cy), Hebrew (he), Slovenian (sl) |
Few | Paucal / small number form | Russian (2-4), Polish (2-4), Czech (2-4), Arabic (3-10), Irish (3-6) |
Many | Large number form | Russian (5-20), Arabic (11-99), Welsh (6), Irish (7-10) |
Other | Default / general plural | All languages (always present as fallback) |
Language Family Rules
Section titled “Language Family Rules”Germanic (en, de, nl, sv, da, no, nb, nn, fi, et, hu, el): simple one/other split.
PluralRules.GetCategory("en", 1); // One -- "1 item"PluralRules.GetCategory("en", 0); // Other -- "0 items"PluralRules.GetCategory("en", 5); // Other -- "5 items"Romance (fr, it, es, pt, ca): treat both 0 and 1 as singular.
PluralRules.GetCategory("fr", 0); // One -- "0 élément"PluralRules.GetCategory("fr", 1); // One -- "1 élément"PluralRules.GetCategory("fr", 2); // Other -- "2 éléments"East Slavic (ru, uk, be): one/few/many based on last digits.
PluralRules.GetCategory("ru", 1); // One -- "1 файл" (ends in 1, not 11)PluralRules.GetCategory("ru", 21); // One -- "21 файл"PluralRules.GetCategory("ru", 2); // Few -- "2 файла" (ends in 2-4, not 12-14)PluralRules.GetCategory("ru", 5); // Many -- "5 файлов"PluralRules.GetCategory("ru", 11); // Many -- "11 файлов" (special case: 11-14)PluralRules.GetCategory("ru", 101); // One -- ends in 1, not 11PluralRules.GetCategory("ru", 111); // Many -- ends in 11 (special case)Polish (pl): similar to East Slavic but 1 is the only One value.
PluralRules.GetCategory("pl", 1); // One -- "1 plik"PluralRules.GetCategory("pl", 2); // Few -- "2 pliki" (ends in 2-4, not 12-14)PluralRules.GetCategory("pl", 5); // Many -- "5 plików"PluralRules.GetCategory("pl", 22); // Few -- "22 pliki"Czech/Slovak (cs, sk): one/few/other.
PluralRules.GetCategory("cs", 1); // One -- "1 soubor"PluralRules.GetCategory("cs", 3); // Few -- "3 soubory" (2-4)PluralRules.GetCategory("cs", 5); // Other -- "5 souborů"Arabic (ar): uses all six CLDR categories.
PluralRules.GetCategory("ar", 0); // Zero -- special form for 0PluralRules.GetCategory("ar", 1); // One -- singularPluralRules.GetCategory("ar", 2); // Two -- dual formPluralRules.GetCategory("ar", 5); // Few -- 3-10 (mod 100)PluralRules.GetCategory("ar", 15); // Many -- 11-99 (mod 100)PluralRules.GetCategory("ar", 100); // Other -- 100, 200, etc.East Asian (ja, zh, ko, vi, th, id, ms) and Turkish-family (tr, az, ka, ky, kk, uz): no plural forms, always Other.
PluralRules.GetCategory("ja", 1); // Other -- alwaysPluralRules.GetCategory("zh", 100); // Other -- alwaysPluralRules.GetCategory("tr", 1); // Other -- alwaysUsing Plural Rules with Translations
Section titled “Using Plural Rules with Translations”Plural rules integrate with PluralString and StringLocalizer:
// Define plural forms in JSON// en.json: { "items": { "one": "1 item", "other": "{count} items" } }
// Via StringLocalizervar localizer = new StringLocalizer(provider, options);localizer.Plural("items", 1); // "1 item"localizer.Plural("items", 5); // "5 items"
// Via InMemoryLocalizationProvidervar provider = new InMemoryLocalizationProvider() .AddPlural("en", "files", (PluralCategory.One, "1 file"), (PluralCategory.Other, "{count} files")) .AddPlural("ru", "files", (PluralCategory.One, "{count} файл"), (PluralCategory.Few, "{count} файла"), (PluralCategory.Many, "{count} файлов"));When a specific plural category is not defined in the translation data, PluralString falls back to the Other category. If Other is also missing, it returns an empty string.
Testing
Section titled “Testing”[Fact]public void LocalizedString_ReturnsCorrectTranslation(){ var str = new LocalizedString(new Dictionary<string, string> { ["en"] = "Hello", ["it"] = "Ciao" });
CultureContext.Current = new CultureInfo("it"); Assert.Equal("Ciao", str.Value);
CultureContext.Current = new CultureInfo("en"); Assert.Equal("Hello", str.Value);}
[Fact]public void PluralString_SelectsCorrectForm(){ var plural = new PluralString( (PluralCategory.One, "{count} item"), (PluralCategory.Other, "{count} items"));
Assert.Equal("1 item", plural.Format(1)); Assert.Equal("5 items", plural.Format(5));}Diagnostics
Section titled “Diagnostics”The source generator emits the following diagnostics:
| ID | Severity | Description | Solution |
|---|---|---|---|
| PRAG1800 | Error | Translation JSON file could not be parsed | Fix JSON syntax errors in the translation file |
| PRAG1801 | Warning | Duplicate translation key detected for same culture | Consolidate duplicate keys into a single file per culture |
| PRAG1802 | Warning | Translation key exists in default culture but is missing in another culture | Add the missing key to the incomplete translation file |
| PRAG1803 | Info | Translation file contains no valid translation keys | Add string key-value pairs to the file, or remove the empty file |
Documentation
Section titled “Documentation”- Architecture and Core Concepts — Why the module exists, how the pieces fit together
- Getting Started — Install and configure in 5 minutes
- Translation Keys — SG-generated
Tclass, folder structures, providers - Common Mistakes — Wrong/right patterns with explanations
- Troubleshooting — Diagnostics reference, FAQ, problem/solution guide
Requirements
Section titled “Requirements”- .NET 10.0 or later
License
Section titled “License”MIT