Skip to main content

09 - i18n

i18n

  • Globalization (G11N): The process of making an application support different languages and regions.
  • Localization (L10N): The process of customizing an application for a given language and region.
  • Internationalization (I18N): Describes both globalization and localization.
  • Culture: It is a language and, optionally, a region.
  • Locale: A locale is the same as a culture.
  • Neutral culture: A culture that has a specified language, but not a region. (e.g. "en", "es")
  • Specific culture: A culture that has a specified language and region. (e.g. "en-US", "en-GB", "es-CL")

ASP.NET Core Globalization and Localization

Cultures

LanguageCountryCodeLocale
English0x0009en
EnglishAmerican Samoa0x1000en-AS
EnglishAnguilla0x1000en-AI
EnglishAntigua and Barbuda0x1000en-AG
EnglishAustralia0x0c09en-AU
EnglishAustria0x1000en-AT
EnglishBahamas0x1000en-BS
EnglishBarbados0x1000en-BB
EnglishBelgium0x1000en-BE
EnglishBelize0x2809en-BZ
EnglishBermuda0x1000en-BM
EnglishBotswana0x1000en-BW
EnglishBritish Indian Ocean Territory0x1000en-IO
............
Estonian0x0025et
EstonianEstonia0x0425et-EE
Russian0x0019ru
RussianRussia0x0419ru-RU
Finnish0x000bfi
FinnishFinland0x040bfi-FI

i18n example

Note: CultureInfo.CurrentCulture is the preferred property in .NET Core+, but Thread.CurrentThread.CurrentCulture still works.

int value = 5600;
 
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("es-CL");
Console.WriteLine(DateTime.Now.ToShortDateString());
Console.WriteLine(value.ToString("c"));
 
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("es-MX");
Console.WriteLine(DateTime.Now.ToShortDateString());
Console.WriteLine(value.ToString("c"));
// Output
26-07-2011 // Date in es-CL, Spanish (Chile)
$5.600,00 // Currency in es-CL, Spanish (Chile)
 
26/07/2011 // Date in es-MX, Spanish (Mexico)
$5,600.00 // Currency in es-MX, Spanish (Mexico)

Culture and UICulture

ASP.NET Core keeps track in each thread:

  • Culture determines formatting for dates, numbers, currency, etc.
  • UICulture determines which resources (translated strings) are loaded for the page

Culture switching 1

Add required services and configuration (before .Build()).

var supportedCultures = builder.Configuration
.GetSection("SupportedCultures")
.GetChildren()
.Select(x => new CultureInfo(x.Value!))
.ToArray();

builder.Services.Configure<RequestLocalizationOptions>(options =>
{
// datetime and currency support
options.SupportedCultures = supportedCultures;
// UI translated strings
options.SupportedUICultures = supportedCultures;
// if nothing is found, use this
options.DefaultRequestCulture =
new RequestCulture(builder.Configuration["DefaultCulture"], builder.Configuration["DefaultCulture"]);
options.SetDefaultCulture(builder.Configuration["DefaultCulture"]);

options.RequestCultureProviders = new List<IRequestCultureProvider>
{
// Order matters - providers are evaluated in this sequence
// add support for ?culture=ru-RU
new QueryStringRequestCultureProvider(),
new CookieRequestCultureProvider()
};
});

Culture switching 2

Add localization middleware to the request pipeline. Must be placed before any middleware that reads culture (e.g., MVC endpoints).

app.UseRouting();
app.UseRequestLocalization(options: app.Services.GetService<IOptions<RequestLocalizationOptions>>()!.Value!);

Culture switching 3

Define settings in appsettings.json.

{
"SupportedCultures": [
"en",
"et"
],
"DefaultCulture": "et-EE",
"LangStrDefaultCulture": "et"
}

Resources

Add new class library for language resources.
Add resource file - Resources (.resx).
Use subdirectories, mimicking your solution projects.

When a new .resx file is added, change the .csproj file manually (ResXFileCodeGenerator => PublicResXFileCodeGenerator).

    <ItemGroup>
<EmbeddedResource Update="App.Domain\Foo.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Foo.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>

In the resource editor, add cultures using the first + button, and add translation keys using the second + button.

Rider

Using resource based translations

  • To get MVC to automatically display translated field names – use [Display] attribute
[Display(
Name = nameof(StringValue) + "Name",
ShortName = nameof(StringValue) + "ShortName",
Prompt = nameof(StringValue) + "Prompt",
Description = nameof(StringValue) + "Description",
ResourceType = typeof(Resources.Common)
)]
[StringLength(
maximumLength: 255,
MinimumLength = 1,
ErrorMessageResourceType = typeof(Base.Resources.Common),
ErrorMessageResourceName = "ErrorMessage_StringLengthMinMax"
)]

public string StringValue { get; set; }
  • MVC uses Name (label value) and Prompt (for input placeholder) fields. WPF (desktop) also uses the other attributes. It is also possible to read these attribute values in code.

IStringLocalizer and IViewLocalizer

ASP.NET Core provides DI-based services for accessing translations in controllers and views.

In controllers, inject IStringLocalizer<T>:

public class HomeController : Controller
{
private readonly IStringLocalizer<HomeController> _localizer;

public HomeController(IStringLocalizer<HomeController> localizer)
{
_localizer = localizer;
}

public IActionResult Index()
{
ViewData["Title"] = _localizer["Welcome"];
return View();
}
}

In Razor views, inject IViewLocalizer:

@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer

<h1>@Localizer["Welcome"]</h1>

Register localization services and data annotations localization in Program.cs:

builder.Services.AddLocalization();

builder.Services.AddControllersWithViews()
.AddDataAnnotationsLocalization();

Static vs. DB translations - architectural decision

Resource files (.resx) work well for static UI text - labels, validation messages, navigation, error messages. This content is part of the codebase and changes with deployments.

But what about translatable content stored in the database? Product names, category labels, descriptions - content that is managed by administrators or users at runtime. For this, we need a different approach.

  • Use .resx when: translations are known at development time and change only with new deployments
  • Use DB translations when: content is user/admin-managed and must be translatable without redeployment

DB Content translation

Several ways how to handle


  • 2 extra tables
    • one for all the possible translations
    • second to hold default value and id (1:m relationship to translations)

Keep strings in json dictionary.

DB Content translation - json

Extend dictionary to handle cultures and string values.

namespace App.Domain;

public class LangStr : Dictionary<string, string>
{
// for ef inMemory testing (or use keyless?)
public Guid Id { get; set; } = Guid.NewGuid();

// look at appsettings.json LangStrDefaultCulture value
public static string DefaultCulture { get; set; } = "en";

// s["en"] = "foo";
// var bar = s["en"];
public new string this[string key]
{
get => base[key];
set => base[key] = value;
}

public LangStr()
{
}

public LangStr(string value) : this(value, Thread.CurrentThread.CurrentUICulture.Name)
{
}

public LangStr(string value, string culture)
{
if (culture.Length < 1) throw new ApplicationException("Culture is required!");

var neutralCulture = culture.Split('-')[0];
this[neutralCulture] = value;

// check for default culture also. if not set - do so
if (!ContainsKey(DefaultCulture))
{
this[DefaultCulture] = value;
}
}

public string? Translate(string? culture = null)
{
if (Count == 0) return null;
culture = culture?.Trim() ?? Thread.CurrentThread.CurrentUICulture.Name;

if (ContainsKey(culture))
{
return this[culture];
}

var neutralCulture = culture.Split('-')[0];
if (ContainsKey(neutralCulture))
{
return this[neutralCulture];
}

if (ContainsKey(DefaultCulture))
{
return this[DefaultCulture];
}

return null;
}

public void SetTranslation(string value, string? culture = null)
{
culture = culture?.Trim() ?? Thread.CurrentThread.CurrentUICulture.Name;
var neutralCulture = culture.Split('-')[0];
this[neutralCulture] = value;
}

public override string ToString()
{
return Translate() ?? "????";
}

// string xxx = new LangStr("foo","et-EE"); xxx == "foo";
public static implicit operator string(LangStr? langStr) => langStr?.ToString() ?? "null";

// LangStr xxx = "foobar";
public static implicit operator LangStr(string value) => new LangStr(value);
}

Use it in the database. EF provider will store the dictionary as a JSON object and do automatic serialization/deserialization.

public class Foo: DomainEntityId
{
[Display(ResourceType = typeof(App.Resources.App.Domain.Foo), Name = nameof(SomeStr))]
[Column(TypeName = "jsonb")]
public LangStr SomeStr { get; set; } = new();
}
  • Use ToString() method to get translation using current culture.
  • When updating/adding translations, load existing values from the database first - otherwise you will overwrite translations for other cultures that are already stored in the JSON.

Add support in program.cs for [Column(TypeName = "jsonb")]

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ??
throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");


// used for older style [Column(TypeName = "jsonb")] for LangStr
#pragma warning disable CS0618 // Type or member is obsolete
NpgsqlConnection.GlobalTypeMapper.EnableDynamicJson();
#pragma warning restore CS0618 // Type or member is obsolete


LangStr.DefaultCulture = builder.Configuration.GetValue<string>("LangStrDefaultCulture") ?? "en";

Modern alternative: instead of the deprecated GlobalTypeMapper.EnableDynamicJson(), you can use an EF Core value converter:

builder.Property(e => e.SomeStr)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<LangStr>(v, (JsonSerializerOptions?)null)!
)
.HasColumnType("jsonb");

MVC support for culture switching

@using Microsoft.AspNetCore.Localization
@using Microsoft.Extensions.Options
@using Microsoft.AspNetCore.Builder
@using System.Threading
@inject IOptions<RequestLocalizationOptions> LocalizationOptions
@{
var cultureItems = LocalizationOptions.Value.SupportedUICultures!
.Select(c => new { Value = c.Name, Text = c.NativeName }).ToList();
}

<li class="nav-item dropdown">
<a class="nav-link text-dark dropdown-toggle" href="javascript:{}" id="navbarLangDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">Language (@Thread.CurrentThread.CurrentUICulture.Name)</a>
<div class="dropdown-menu" aria-labelledby="navbarLangDropdown">
@foreach (var item in cultureItems)
{
if (Thread.CurrentThread.CurrentUICulture.Name == item.Value)
{
<strong>
<a class="dropdown-item text-dark"
asp-area="" asp-controller="Home" asp-action="SetLanguage" asp-route-culture="@item.Value"
asp-route-returnUrl="@Context.Request.Path@Context.Request.QueryString">
@item.Text
</a>
</strong>
}
else
{
<a class="dropdown-item text-dark"
asp-area="" asp-controller="Home" asp-action="SetLanguage" asp-route-culture="@item.Value"
asp-route-returnUrl="@Context.Request.Path@Context.Request.QueryString">
@item.Text
</a>
}
}
</div>
</li>

Add in home controller

    public IActionResult SetLanguage(string culture, string returnUrl)
{
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
new CookieOptions()
{
Expires = DateTimeOffset.UtcNow.AddYears(1)
}
);
// LocalRedirect prevents open redirect attacks - only allows redirects to the same host
return LocalRedirect(returnUrl);
}

Add the language dropdown partial to your layout template (_Layout.cshtml)


<partial name="_LanguageSelection"/>

</ul>
<partial name="_LoginPartial"/>

</div>

Self preparation QA

Be prepared to explain topics like these:

  1. What is the difference between Culture and UICulture? — Culture determines formatting for dates, numbers, and currency. UICulture determines which translated resource strings are loaded. They can be set independently.
  2. Why use resource files (.resx) for static UI text? — Resource files provide compile-time type safety through generated designer classes, support multiple languages via culture-specific files, and are versioned with the codebase.
  3. When would you use database translations (LangStr) instead of .resx? — For user/admin-managed content that must be translatable without redeployment (product names, category labels). The LangStr pattern stores translations as a JSON dictionary in a database column.
  4. How does the LangStr pattern work? — LangStr extends Dictionary<string, string> with culture keys. EF Core stores it as a JSON column. ToString() and Translate() use the current thread's UICulture to look up the right translation, with fallback chain.
  5. Why must you load existing LangStr values before updating? — LangStr stores all translations in one JSON object. Creating a new LangStr with only the current culture's value and saving it overwrites translations for other cultures.
  6. How does culture switching work in ASP.NET Core? — Request culture providers (QueryString, Cookie) evaluate in configured order. The UseRequestLocalization middleware sets the thread culture. A controller action sets a culture cookie, and LocalRedirect prevents open redirect attacks.
  7. What is IStringLocalizer and when do you use it?IStringLocalizer<T> is a DI-based service for accessing translations in controllers. IViewLocalizer is the view equivalent. Both look up resource strings by key using the current UICulture.