ASP.NET Core Globalization and a custom RequestCultureProvider [Updated]

In this post, I'm going to write about how to enable and use Globalization in ASP.NET Core. Since you don't can change the culture depending on route values by default, I show you how to create and register a custom RequestCultureProvider that does this job.

UPDATE:

Hisham Bin Ateya pointed me to the [fact via Twitter](TWITTER STATUS) that there already is a RequestCultureProvider that can change the culture depending on route values in ASP.NET Core. Because of that, please see the last section in this blog post just as an example about how to create a custom RequestCultureProvider.

I also restructured the post a little bit. to separate general information about Globalization and RequestCultureProvider. If you are familiar with Globalization, just skip the fist sections and jump to the second last section.

About GLobalization

Resources Files

Like in the old time of the .NET Framework, the resources (strings, images, icons, etc.) for different languages are stored in so-called resource files that end with resx stored in a folder called Resources by default.

Unlike in the good old time of the .NET Framework, the right resource files will be fetched automatically by the implementation of the specific Localizer if you follow some naming conventions.

  • If you inject the Localizer into a controller, the localizer should be named like Controllers.ControllerClassName.[Culture].resx or put to a subfolder called Controllers and named like ControllerClassName.[Culture].resx .
  • If you are injecting the Localizer into a view, it is almost the same as for the controllers. The difference is just to have a view name in the resource path instead of a controller name: Views.ControllerName.ViewName.[culture].resx or Views/ControllerName/ViewName.[culture].resx.

It is up to you to decide how you like to structure your resource files. Personally, I prefer the folder option. Also, an autogenerated code file as you might know from the past is no longer needed since you need to use a localizer to access the resources.

Unfortunately there is now way yet to add a resource file via the .NET CLI. Maybe there will be a template in the future. I created the resource file with the Visual Studio 2022 and copied it to create the other files needed.

Localizers

You no longer need to use the resource manager to read the actual localized strings from the resource files. You can now use an IStringLocalizer or an IHtmlLocalizer. The latter doesn't HTML-encode the strings that are stored in the resource files and can be used to localize strings that contain HTML code if needed.:

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;

namespace Internationalization.Controllers;

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

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

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

The resource key named "About Title" doesn't need to exist or even the resource file doesn't need to exist. If the Localizer doesn't find the key, the key itself gets returned as a string. You can use any kind of string as a key. This can help you to develop the application without having the resource files in place.

You can even inject a localizer in the Razor View like this:

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

@model HomeIndexViewModel
@{
    ViewData["Title"] = Localizer["Title"];
}

<h1>@ViewData["Title"]</h1>

In this case, it is an HtmlLocalizer the key can also contain HTML that doesn't get encoded when writing it out to the view. Even if it's not recommended to save HTML in resource files it might be needed in some cases. You shouldn't do that because HTML should be part of the frontend templates like Razor, Blazor, etc.

Instead of using the ViewLocalizer in the Razor Templates, you can also localize the entire View. Therefore you need to suffix the view name with the needed culture or put the view in a subfolder called like the culture. How localized views are handled needs to be configured when enabling Localization and Globalization.

Enabling Globalization in ASP.NET Core

As usual, you need to add the required services to enable localization:

builder.Services.AddLocalization(options =>
{
    options.ResourcesPath = "Resources";
});
builder.Services.AddControllersWithViews()
    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
    .AddDataAnnotationsLocalization();

The first line adds general localization to be used in the C# code, like controllers, etc. setting the ResourcePath in the options is optionally and just added to the snippets, to show you that you can change the path where the resources are stored.

After that, the ViewLocalization, as well as the DataAnnotationLocalization, was added to the Service Collection. The LanguageViewLocationExpanderFormat tells the View localizer that in the case of localized views, the culture was added as a suffix to the filename instead of being part of the folder structure.

After adding the needed services to the service collection the required middleware needs to be added as well:

app.UseRequestLocalization(options =>
{
    var culture = new List<CultureInfo> {
        new CultureInfo("en-en"),
        new CultureInfo("fr-fr"),
        new CultureInfo("de-de")
    };
    options.DefaultRequestCulture = new RequestCulture("en-en");
    options.SupportedCultures = culture;
    options.SupportedUICultures = culture;
});

This Middleware uses pre-configured RequestCultureProviders to set the Culture and the UICulture to the current process. With this culture set, the localizers can select the right resource files or the right localized views.

That's it with enabling Localization and Globalization. With this information, you should be able to create multilanguage applications already.

Culture vs. UI Culture

Setting the culture will set the application to a specific language and optional a region. If you also set the UI Culture, you make a distinction between translating texts and between how numbers, dates, and currencies are displayed. The UI culture is used to load resources from a corresponding resource file and the Culture is used to change the way how numbers, dates, and currencies are formatted and displayed.

In some cases, it makes sense to handle that separately. If you only like to translate your page without taking care of number and date formats, etc. you should only change the UI Culture.

Localize ViewModels

While enabling view localization, we also enabled DataAnnotationsLocalization. This helps you to translate labels for form fields in case you use the @Html.LabelFor() method. This doesn't need to specify the ResourceType anymore. Since there is no longer an autogenerated C#-File, there is also no ResourceType specified. Inside the ViewModel you just need to specify the DisplayAttribute:

public class EmployeeViewModel
{
    [Display(Name = "Number")]
    public int Number { get; set; }

    [Display(Name = "Firstname")]
    public string? Firstname { get; set; }

    [Display(Name = "Lastname")]
    public string? Lastname { get; set; }

    [Display(Name = "Department")]
    public string? Department { get; set; }

    [Display(Name = "Phone")]
    public string? Phone { get; set; }

    [Display(Name = "Email")]
    public string? Email { get; set; }

    [Display(Name = "Date of birth")]
    public DateTime DateOfBirth { get; set; }

    [Display(Name = "Size")]
    public decimal Size { get; set; }

    [Display(Name = "Salery")]
    public decimal Salery { get; set; }
}

The DataAnnotationsLocalizer will automatically use the string that is set in the Name property as a key to search for the relevant resource. This also works for the Description and the ShortName properties.

The resource file that is used to translate the display names has to be placed inside subfolders called ViewModels/ControllerName. Example: /Resources/ViewModels/Home/EmployeeModel.de-DE.resx

Creating a custom RequestCultureProvider

RequestCultureProviders

As mentioned RequestCultureProviders retrieve the culture from somewhere and prepare it for the process to work with the culture. The RequestCultureProviders return a ProviderCultureResult that has the property Culture and UICulture set. Both cultures can differ if needed. In most cases, it will be the same.

There are three preconfigured RequestCultureProviders:

  • QueryStringRequestCultureProvider This provider extracts the Culture and UICulture from query string values if there are any. This means you can switch the language by just setting the query strings. ?culture-de-DE&ui-culture=de-DE

  • CookieRequestCultureProvider This provider extracts the culture information from a specific cookie. The cookie-name is .AspNetCore.Culture and the value of the cookie might look like this: c=es-MX|uic=es-MX (c is the culture and uic is the ui-culture)

  • AcceptLanguageHeaderRequestCultureProvider That provider extracts the language information from the Accept-Language Header that gets sent by the browsers. Every browser has preferred languages configured and sends those languages to the server. With this information, you can localize your application-specific to the user's language

As you have seen in the previews section, not every language sent by the accept-language header, cookie, or query string gets accepted by your application. You need to define a list of supported cultures and a default request culture that is used if the language sent by the client isn't supported by your application.

The custom RequestCultureProvider

UPDATE:

Actually there is an existing RequestCultureProvider in ASP.NET Core that can change the culture depending on rout values. Since it isn't in the default list of registered RequestCultureProvider, I expected that there is none. That was wrong.

Since there is one already, just see the following section as an example about how to create a custom RequestCultureProvider.

What I am missing in the list of RequestCultureProviders is a RouteValueRequestCultureProvider. A provider that is getting the culture information from a route value in case it is part of the route like this /en-US/Home/Index/

Let's assume we have a route configured like this:

app.MapControllerRoute(
    name: "default",
    pattern: "{culture=en-us}/{controller=Home}/{action=Index}/{id?}");

This adds the culture as part of the route.

Actually, I built a RouteValueRequestCultureProvider that handles the route values:

using Microsoft.AspNetCore.Localization;

namespace Internationalization.Providers;

// <summary>
/// Determines the culture information for a request via values in the route values.
/// </summary>
public class RouteValueRequestCultureProvider : RequestCultureProvider
{
    /// <summary>
    /// The key that contains the culture name.
    /// Defaults to "culture".
    /// </summary>
    public string RouteValueKey { get; set; } = "culture";

    /// <summary>
    /// The key that contains the UI culture name. If not specified or no value is found,
    /// <see cref="RouteValueKey"/> will be used.
    /// Defaults to "ui-culture".
    /// </summary>
    public string UIRouteValueKey { get; set; } = "ui-culture";

    public override Task<ProviderCultureResult?> DetermineProviderCultureResult(HttpContext httpContext)
    {
        if (httpContext == null)
        {
            throw new ArgumentNullException(nameof(httpContext));
        }

        var request = httpContext.Request;
        if (!request.RouteValues.Any())
        {
            return NullProviderCultureResult;
        }

        string? queryCulture = null;
        string? queryUICulture = null;

        if (!string.IsNullOrWhiteSpace(RouteValueKey))
        {
            queryCulture = request.RouteValues[RouteValueKey]?.ToString();
        }

        if (!string.IsNullOrWhiteSpace(UIRouteValueKey))
        {
            queryUICulture = request.RouteValues[UIRouteValueKey]?.ToString();
        }

        if (queryCulture == null && queryUICulture == null)
        {
            // No values specified 
            return NullProviderCultureResult;
        }

        if (queryCulture != null && queryUICulture == null)
        {
            // Value for culture but not for UI culture so default to culture value for both
            queryUICulture = queryCulture;
        }
        else if (queryCulture == null && queryUICulture != null)
        {
            // Value for UI culture but not for culture so default to UI culture value for both
            queryCulture = queryUICulture;
        }

        var providerResultCulture = new ProviderCultureResult(queryCulture, queryUICulture);

        return Task.FromResult<ProviderCultureResult?>(providerResultCulture);
    }
}

This RouteValueRequestCultureProvider reads the culture and the ui-culture out of the route values and returns a ProviderCultureResult that will be used by the Localizers.

The route engine handles the generation of the route URLs for us if we use the MVC mechanisms to create links and tags. We'll now have the selected language and region everywhere in the routes.

To create a language changer, we Just need to change the culture in the route value like this:

<ul class="navbar-nav flex-grow-1 justify-content-end">
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="" 
           asp-controller="@Context.GetRouteValue("Controller")" 
           asp-action="@Context.GetRouteValue("Action")" 
           asp-route-culture="en-US">EN</a>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="" 
           asp-controller="@Context.GetRouteValue("Controller")" 
           asp-action="@Context.GetRouteValue("Action")" 
           asp-route-culture="de-DE">DE</a>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="" 
           asp-controller="@Context.GetRouteValue("Controller")" 
           asp-action="@Context.GetRouteValue("Action")" 
           asp-route-culture="fr-FR">FR</a>
    </li>
</ul>

Changing the culture and the UI culture also changes the way how dates, numbers, and currencies are displayed. This means the language changer is also changing the region and will display the currency in Euro in case you change the region to a region that uses the Euro as local currency. You need to keep this in mind when working with financial data because just changing the currency doesn't make sense if you don't convert the actual numbers to the local currency as well. If you don't want to change the currency, you should hard code the way how to format and display currency. Just fixing the culture of a region and changing the UI culture would also set the numbers and dates to a fixed format which is not what we want to have.

This is the start page of the sample project in French.

French localized UI

(I apologies for wrong translations. Unfortunately it is more than 25 years in the past when I was learning French in school.)

Sample application and Conclusion

This is actually working and I created a small application to demonstrate this. This sample includes all the topics of this post. You will find the sample project in Github.

Microsoft reduces the complexity a lot. On the other hand, if you were used to work with more complex resource handling in the past, you will stumble upon small things you won't expect like me. However, adding Globalization and Localization in .NET 7 is easy and I like the way how to get it working.