Localization in .NET

Localization is the process of translating an application's resources into localized versions for each culture that the application will support. You should proceed to the localization step only after completing the Localizability review step to verify that the globalized application is ready for localization.

An application that is ready for localization is separated into two conceptual blocks: a block that contains all user interface elements and a block that contains executable code. The user interface block contains only localizable user-interface elements such as strings, error messages, dialog boxes, menus, embedded object resources, and so on for the neutral culture. The code block contains only the application code to be used by all supported cultures. The common language runtime supports a satellite assembly resource model that separates an application's executable code from its resources. For more information about implementing this model, see Resources in .NET.

For each localized version of your application, add a new satellite assembly that contains the localized user interface block translated into the appropriate language for the target culture. The code block for all cultures should remain the same. The combination of a localized version of the user interface block with the code block produces a localized version of your application.

In this article, you will learn how to use the IStringLocalizer<T> and IStringLocalizerFactory implementations. All of the example source code in this article relies on the Microsoft.Extensions.Localization and Microsoft.Extensions.Hosting NuGet packages. For more information on hosting, see .NET Generic Host.

Resource files

The primary mechanism for isolating localizable strings is with resource files. A resource file is an XML file with the .resx file extension. Resource files are translated prior to the execution of the consuming application — in other words, they represent translated content at rest. A resource file name most commonly contains a locale identifier, and takes on the following form:

<FullTypeName><.Locale>.resx

Where:

  • The <FullTypeName> represents localizable resources for a specific type.
  • The optional <.Locale> represents the locale of the resource file contents.

Specifying locales

The locale should define the language, at a bare minimum, but it can also define the culture (dialect), and even the country. These segments are commonly delimited by the - character. With the added specificity of a culture, the "culture fallback" rules are applied where best matches are prioritized. The locale should map to a well-known language tag. For more information, see CultureInfo.Name.

Culture fallback scenarios

Imagine that your localized app supports various Serbian locales, and has the following resource files for its MessageService:

File Dialect Country Code
MessageService.sr-Cyrl-RS.resx (Cyrillic, Serbia) RS
MessageService.sr-Cyrl.resx Cyrillic
MessageService.sr-Latn-BA.resx (Latin, Bosnia & Herzegovina) BA
MessageService.sr-Latn-ME.resx (Latin, Montenegro) ME
MessageService.sr-Latn-RS.resx (Latin, Serbia) RS
MessageService.sr-Latn.resx Latin
MessageService.sr.resx Latin
MessageService.resx

The default dialect for the language.

When your app is running with the CultureInfo.CurrentCulture set to a culture of "sr-Cyrl-RS" localization attempts to resolve files in the following order:

  1. MessageService.sr-Cyrl-RS.resx
  2. MessageService.sr-Cyrl.resx
  3. MessageService.sr.resx
  4. MessageService.resx

However, if your app was running with the CultureInfo.CurrentCulture set to a culture of "sr-Latn-BA" localization attempts to resolve files in the following order:

  1. MessageService.sr-Latn-BA.resx
  2. MessageService.sr-Latn.resx
  3. MessageService.sr.resx
  4. MessageService.resx

The "culture fallback" rule will ignore locales when there are no corresponding matches, meaning resource file number four is selected if it's unable to find a match. If the culture was set to "fr-FR", localization would end up falling to the MessageService.resx file which can be problematic. For more information, see The resource fallback process.

Resource lookup

Resource files are automatically resolved as part of a lookup routine. If your project file name is different than the root namespace of your project, the assembly name might differ. This can prevent resource lookup from being otherwise successful. To address this mismatch, use the RootNamespaceAttribute to provide a hint to the localization services. When provided, it is used during resource lookup.

The example project is named example.csproj, which creates an example.dll and example.exe — however, the Localization.Example namespace is used. Apply an assembly level attribute to correct this mismatch:

[assembly: RootNamespace("Localization.Example")]

Register localization services

To register localization services, call one of the AddLocalization extension methods during the configuration of services. This will enable dependency injection (DI) of the following types:

Configure localization options

The AddLocalization(IServiceCollection, Action<LocalizationOptions>) overload accepts a setupAction parameter of type Action<LocalizationOptions>. This allows you to configure localization options.

using IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddLocalization(options =>
        {
            options.ResourcesPath = "Resources";
        });
    });

// Omitted for brevity.

Resource files can live anywhere in a project, but there are common practices in place that have proven to be successful. More often than not, the path of least resistance is followed. The preceding C# code:

This would cause the localization services to look in the Resources directory for resource files.

Use IStringLocalizer<T> and IStringLocalizerFactory

After you've registered (and optionally configured) the localization services, you can use the following types with DI:

To create a message service that is capable of returning localized strings, consider the following MessageService:

using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Localization;

namespace Localization.Example;

public class MessageService
{
    private readonly IStringLocalizer<MessageService> _localizer = null!;

    public MessageService(IStringLocalizer<MessageService> localizer) =>
        _localizer = localizer;

    [return: NotNullIfNotNull("_localizer")]
    public string? GetGreetingMessage()
    {
        LocalizedString localizedString = _localizer["GreetingMessage"];
        return localizedString;
    }
}

In the preceding C# code:

  • A IStringLocalizer<MessageService> _localizer field is declared.
  • The constructor takes a IStringLocalizer<MessageService> parameter and assigns it to the _localizer field.
  • The GetGreetingMessage method invokes the IStringLocalizer.Item[String] passing "GreetingMessage" as an argument.

The IStringLocalizer also supports parameterized string resources, consider the following ParameterizedMessageService:

using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Localization;

namespace Localization.Example;

public class ParameterizedMessageService
{
    private readonly IStringLocalizer _localizer = null!;

    public ParameterizedMessageService(IStringLocalizerFactory factory) =>
        _localizer = factory.Create(typeof(ParameterizedMessageService));

    [return: NotNullIfNotNull("_localizer")]
    public string? GetFormattedMessage(DateTime dateTime, double dinnerPrice)
    {
        LocalizedString localizedString = _localizer["DinnerPriceFormat", dateTime, dinnerPrice];
        return localizedString;
    }
}

In the preceding C# code:

  • A IStringLocalizer _localizer field is declared.
  • The constructor takes an IStringLocalizerFactory parameter, which is used to create an IStringLocalizer from the ParameterizedMessageService type, and assigns it to the _localizer field.
  • The GetFormattedMessage method invokes IStringLocalizer.Item[String, Object[]], passing "DinnerPriceFormat", a dateTime object, and dinnerPrice as arguments.

Important

The IStringLocalizerFactory isn't required. Instead, it is preferred for consuming services to require the IStringLocalizer<T>.

Both IStringLocalizer.Item[] indexers return a LocalizedString, which have implicit conversions to string?.

Put it all together

To exemplify an app using both message services, along with localization and resource files, consider the following Program.cs file:

using System.Globalization;
using Localization.Example;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using static System.Console;
using static System.Text.Encoding;

[assembly: RootNamespace("Localization.Example")]

OutputEncoding = Unicode;

if (args is { Length: 1 })
{
    CultureInfo.CurrentCulture =
        CultureInfo.CurrentUICulture =
            CultureInfo.GetCultureInfo(args[0]);
}

using IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddLocalization();
        services.AddTransient<MessageService>();
        services.AddTransient<ParameterizedMessageService>();
    })
    .ConfigureLogging(options => options.SetMinimumLevel(LogLevel.Warning))
    .Build();

IServiceProvider services = host.Services;

ILogger logger =
    services.GetRequiredService<ILoggerFactory>()
        .CreateLogger("Localization.Example");

MessageService messageService =
    services.GetRequiredService<MessageService>();
logger.LogWarning(
    messageService.GetGreetingMessage());

ParameterizedMessageService parameterizedMessageService =
    services.GetRequiredService<ParameterizedMessageService>();
logger.LogWarning(
    parameterizedMessageService.GetFormattedMessage(
        DateTime.Today.AddDays(-3), 37.63));

await host.RunAsync();

In the preceding C# code:

Each of the *MessageService classes defines a set of .resx files, each with a single entry. Here is the example content for the MessageService resource files, starting with MessageService.resx:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="GreetingMessage" xml:space="preserve">
    <value>Hi friends, the ".NET" developer community is excited to see you here!</value>
  </data>
</root>

MessageService.sr-Cyrl-RS.resx:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="GreetingMessage" xml:space="preserve">
    <value>Здраво пријатељи, ".NЕТ" девелопер заједница је узбуђена што вас види овде!</value>
  </data>
</root>

MessageService.sr-Latn.resx:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="GreetingMessage" xml:space="preserve">
    <value>Zdravo prijatelji, ".NET" developer zajednica je uzbuđena što vas vidi ovde!</value>
  </data>
</root>

Here is the example content for the ParameterizedMessageService resource files, starting with ParameterizedMessageService.resx:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="DinnerPriceFormat" xml:space="preserve">
    <value>On {0:D} my dinner cost {1:C}.</value>
  </data>
</root>

ParameterizedMessageService.sr-Cyrl-RS.resx:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="DinnerPriceFormat" xml:space="preserve">
    <value>У {0:D} моја вечера је коштала {1:C}.</value>
  </data>
</root>

ParameterizedMessageService.sr-Latn.resx:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="DinnerPriceFormat" xml:space="preserve">
    <value>U {0:D} moja večera je koštala {1:C}.</value>
  </data>
</root>

Tip

All of the resource file XML comments, schema, and <resheader> elements are intentionally omitted for brevity.

Example runs

The following example runs show the various localized outputs, given targeted locales.

Consider "sr-Latn":

dotnet run --project .\example\example.csproj sr-Latn

warn: Localization.Example[0]
      Zdravo prijatelji, ".NET" developer zajednica je uzbuđena što vas vidi ovde!
warn: Localization.Example[0]
      U utorak, 03. avgust 2021. moja večera je koštala 37,63 ¤.

When omitting an argument to the .NET CLI to run the project, the default system culture is used — in this case "en-US":

dotnet run --project .\example\example.csproj

warn: Localization.Example[0]
      Hi friends, the ".NET" developer community is excited to see you here!
warn: Localization.Example[0]
      On Tuesday, August 3, 2021 my dinner cost $37.63.

When passing "sr-Cryl-RS", the correct corresponding resource files are found and the localization applied:

dotnet run --project .\example\example.csproj sr-Cryl-RS

warn: Localization.Example[0]
      Здраво пријатељи, ".NЕТ" девелопер заједница је узбуђена што вас види овде!
warn: Localization.Example[0]
      У уторак, 03. август 2021. моја вечера је коштала 38 RSD.

The sample application does not provide resource files for "fr-CA", but when called with that culture, the non-localized resource files are used.

Warning

Since the culture is found but the correct resource files are not, when formatting is applied you end up with partial localization:

dotnet run --project .\example\example.csproj fr-CA

warn: Localization.Example[0]
     Hi friends, the ".NET" developer community is excited to see you here!
warn: Localization.Example[0]
     On mardi 3 août 2021 my dinner cost 37,63 $.

See also