Skip to content

Getting Started with Pragmatic.Internationalization

This guide covers setting up the i18n system: culture context, Money type, Currency, and formatting.

Package Description
Pragmatic.Internationalization Core: culture context, Money, Currency, Formatters, Humanizers, Plural Rules
Pragmatic.Internationalization.AspNetCore Culture middleware, JSON converters, validation attributes, translation endpoint, DI integration
Pragmatic.Internationalization.EFCore Value converters for LocalizedString (JSON column) and CurrencyCode (varchar(3))
Pragmatic.SourceGenerator Generates the T class with compile-time-checked translation keys

The T class is generated by the unified Pragmatic.SourceGenerator package (analyzer reference). Pragmatic.Internationalization.SourceGenerator is an internal generator that produces the ISO Country/Currency/Language data inside the module itself; consumers never reference it.

Terminal window
dotnet add package Pragmatic.Internationalization
dotnet add package Pragmatic.Internationalization.AspNetCore # for web apps
builder.Services.AddPragmaticInternationalization(options =>
{
options.DefaultUICulture = CultureCode.EnglishUS;
options.SupportedCultures = [CultureCode.EnglishUS, CultureCode.German, CultureCode.Italian, CultureCode.French];
options.QueryStringKey = "culture"; // ?culture=de-DE (default: "culture")
});
var app = builder.Build();
app.UsePragmaticInternationalization();

Options can also be bound from configuration (section "I18N" by default):

builder.Services.AddPragmaticInternationalization(builder.Configuration);
// appsettings.json: { "I18N": { "DefaultUICulture": "it-IT", "SupportedCultures": ["it-IT", "en-US"] } }

Culture resolution order per request:

  1. Query string: ?culture=de-DE (must be a supported culture)
  2. Accept-Language header (best match against supported cultures, quality-ordered)
  3. Configuration providers (II18NConfigProvider chain, highest Priority wins)
  4. Default culture from options

In a Pragmatic.Composition host use the fluent builder instead: the middleware is auto-wired by InternationalizationStep (Order 95, after authentication):

app.UseI18N(i18n =>
{
i18n.DefaultCulture(CultureCode.EnglishUS)
.Support(CultureCode.EnglishUS, CultureCode.Italian);
i18n.AddJsonTranslations("localization", watchForChanges: true);
i18n.LocalizeProblemDetails();
});

See Integration for the full I18NBuilder surface (providers, translation endpoint, localized ProblemDetails).

I18NContext stores the current culture using AsyncLocal<I18NContext?>, making it thread-safe and compatible with async/await:

using Pragmatic.Internationalization;
// Set ambient culture
I18NContext.SetCulture("de-DE");
// Get current culture
var culture = I18N.Culture; // CultureInfo for de-DE

If no context has been set, I18NContext.Current falls back to CultureInfo.CurrentCulture.

For temporary culture changes:

// Synchronous scope
I18NContext.WithCulture("it-IT", () =>
{
var formatted = 1234.56m.FormatNumber(); // "1.234,56"
});
// Original culture restored here
// Async scope
await I18NContext.WithCultureAsync("fr-FR", async () =>
{
await ProcessOrderAsync(); // Runs with fr-FR culture
});
// With return value
var result = I18NContext.WithCulture<string>("it-IT", () =>
{
return T.Welcome.Value; // "Benvenuto nella nostra app!"
});

The context supports separate cultures for different purposes:

Scope Purpose Example
UICulture Display: labels, messages it-IT
DataCulture Storage: API responses, exports en-US
Custom scopes Specific needs: invoicing, reporting de-DE for invoicing
I18NContext.SetCulture("it-IT");
I18NContext.SetDataCulture(CultureCode.FromString("en-US"));
var uiCulture = I18N.UI; // it-IT
var dataCulture = I18N.Data; // en-US
// Custom scope
I18N.SetScope("invoicing", CultureCode.FromString("de-DE"));
var invoiceCulture = I18N.Scope["invoicing"]; // de-DE
using Pragmatic.Internationalization;
var price = Money.From(99.99m, CurrencyCode.USD);
var price2 = Money.From(99.99m, "EUR"); // from string code
var zero = Money.Zero(CurrencyCode.USD);
// Safe parsing
if (Money.TryFrom(99.99m, "XYZ", out var money))
Console.WriteLine(money);

All arithmetic enforces same-currency operations:

var a = Money.From(100m, CurrencyCode.USD);
var b = Money.From(25m, CurrencyCode.USD);
var sum = a + b; // $125.00
var diff = a - b; // $75.00
var scaled = a * 1.5m; // $150.00
var divided = a / 4; // $25.00
var negated = -a; // -$100.00
// Currency mismatch throws InvalidOperationException
var usd = Money.From(100m, CurrencyCode.USD);
var eur = Money.From(100m, CurrencyCode.EUR);
// var mixed = usd + eur; // InvalidOperationException!
var price = Money.From(99.999m, CurrencyCode.USD);
var rounded = price.Round(2); // $100.00
var currencyRounded = price.RoundToMinorUnit(); // $100.00 (USD has 2 minor units)
// JPY has 0 minor units
var yen = Money.From(999.5m, CurrencyCode.JPY);
var yenRounded = yen.RoundToMinorUnit(); // 1000

Both use banker’s rounding (MidpointRounding.ToEven) by default.

var money = Money.From(-50m, CurrencyCode.EUR);
money.Amount; // -50m
money.Currency; // CurrencyCode.EUR
money.IsZero; // false
money.IsPositive; // false
money.IsNegative; // true
money.Abs(); // 50.00 EUR (absolute value, same currency)
// Deconstruct into amount + currency
var (amount, currency) = money;
I18NContext.SetCulture("en-US");
total.Format(); // "$107.49"
I18NContext.SetCulture("de-DE");
total.Format(); // "107,49 $"
total.Format(CultureCode.German); // format for a specific culture, ignoring context

Ergonomic shortcuts on decimal/int and nullable Money:

19.95m.ToMoney(); // Money in I18N.Currency (the ambient currency)
19.95m.ToMoney(CurrencyCode.USD); // Money in an explicit currency
42.ToMoney(CurrencyCode.JPY); // int overloads too
Money? maybe = null;
maybe.FormatOrDefault(); // "-" (custom fallback: FormatOrDefault("n/a"))

All 180+ ISO 4217 currencies are 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)
var usd = CurrencyCode.USD;
usd.Code; // "USD"
usd.Name; // "US Dollar"
usd.Symbol; // "$"
usd.MinorUnits; // 2
var eur = CurrencyCode.FromCode("EUR");
if (CurrencyCode.TryFromCode("XYZ", out var currency)) { ... }
bool valid = CurrencyCode.IsValid("USD"); // true
foreach (var c in CurrencyCode.All) { ... }

The same source-generated ISO catalogs exist for countries (ISO 3166-1) and languages (ISO 639-1):

var italy = CountryCode.Italy; // or CountryCode.FromCode("IT")
italy.Name; // "Italy"
italy.NativeName; // "Italia"
italy.DefaultLanguage; // LanguageCode.Italian
italy.DefaultCurrency; // CurrencyCode.EUR
italy.DateFormat; // "dd/MM/yyyy"
var arabic = LanguageCode.FromCode("ar");
arabic.IsRightToLeft; // true
arabic.PluralFamily; // PluralRuleFamily.Arabic

CultureCode composes the two (Language + optional Country) and is the type used across the whole module (options, context, LocalizedString lookups).

1234.56m.FormatNumber(); // "1,234.56" (en-US) / "1.234,56" (de-DE)
1234.56m.FormatNumber(3); // With 3 decimal places
0.156m.FormatPercent(); // "15.60%" (2 decimals by default)
1_500_000L.FormatFileSize(); // "1.4 MB"

Extension methods on DateTimeOffset, DateTime, DateOnly and TimeOnly, driven by the ambient culture:

DateTimeOffset.Now.FormatDate(); // "1/10/2026" (en-US) / "10.01.2026" (de-DE)
DateTimeOffset.Now.FormatTime(); // "3:45 PM" / "15:45"
DateTimeOffset.Now.FormatDateTime(); // "1/10/2026 3:45 PM"
// "…Long" variants use the culture's long patterns
DateTimeOffset.Now.FormatDateLong(); // "January 10, 2026"
DateTimeOffset.Now.FormatTimeLong(); // "3:45:12 PM"
DateTimeOffset.Now.FormatDateTimeLong(); // "Saturday, January 10, 2026 3:45:12 PM"
public class OrderService(GlobalizationFormatter formatter)
{
public string FormatTotal(Order order) => formatter.FormatMoney(order.Total);
public string FormatDate(Order order) => formatter.FormatDateTime(order.CreatedAt);
}

Available methods: FormatMoney, FormatNumber, FormatInteger, FormatPercent, FormatFileSize, FormatDate, FormatTime, FormatDateTime, FormatDateTimeLong.

All humanizers offer static factories so you rarely construct them by hand: X.Current (ambient culture), X.ForCulture("de"), and, for durations, .Short/.Long/.Compact.

Three formats. Construct explicitly or via the static factories:

DurationFormatter.Current.Format(TimeSpan.FromMinutes(150)); // "2h 30m" (ambient culture)
DurationFormatter.Long("en").Format(TimeSpan.FromHours(2.5)); // "2 hours 30 minutes"
DurationFormatter.Compact("en").Format(TimeSpan.FromMinutes(150)); // "2:30:00"
new DurationFormatter("en", DurationFormat.Short).FormatMilliseconds(90_000); // "1m 30s"
Format Example (en)
Short 2h 30m
Long 2 hours 30 minutes
Compact 2:30:00

Supported languages: en, de, fr, it, es, pt, ru, zh, ja, ko, ar, nl, pl, sv, no, da, fi.

var ordinal = new OrdinalFormatter("en");
ordinal.Format(1); // "1st"
ordinal.Format(2); // "2nd"
ordinal.Format(21); // "21st"
OrdinalFormatter.ForCulture("de").Format(1); // "1."
new OrdinalFormatter("fr", OrdinalGender.Feminine).Format(1); // "1ère"
QuantityFormatter.Current.Format(1_500); // "1.5K" (ambient culture)
QuantityFormatter.ForCulture("de").Format(1_500_000); // "1,5 Mio"
new QuantityFormatter("en", decimals: 2).Format(2_345_000); // "2.35M"
// Custom suffixes when the defaults don't fit
var custom = new QuantityFormatter("en", new QuantitySuffixes(" thousand", " million", " billion", " trillion"));
custom.Format(1_500); // "1.5 thousand"

Needs an IStringLocalizer because “ago”/“yesterday”/“weeks” are themselves translatable:

var relTime = new RelativeTimeFormatter(localizer);
relTime.Format(TimeSpan.FromMinutes(5)); // "5 minutes ago"
relTime.Format(TimeSpan.FromDays(1)); // "yesterday"
relTime.Format(DateTimeOffset.Now.AddDays(-14)); // "2 weeks ago"
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyPragmaticInternationalization();
}

Value converters applied automatically: CurrencyCode maps to varchar(3), LocalizedString maps to a JSON column. Money has no automatic mapping: map it as two columns (Amount decimal + Currency varchar(3)); the MoneyConfiguration class provides the recommended precision/scale constants (19, 4). See Integration.

Money and Currency types serialize automatically:

{
"price": {
"amount": 99.99,
"currency": "EUR"
}
}

Use Capture() and Restore() to ensure isolation between tests:

var snapshot = I18NContext.Capture();
try
{
I18NContext.SetCulture("de-DE");
// ... test code ...
}
finally
{
I18NContext.Restore(snapshot);
}

Or use the TestI18N / TestI18NScope helpers; see Concepts.

  • Translation Keys – [TranslationKeys], the generated T class, IStringLocalizer
  • Integration – ASP.NET Core middleware, I18NBuilder, translation endpoint, localized ProblemDetails, EF Core