


Localizing ASP.NET Core 5 APIs (Statically Typed!)
Localization has been around since the early days of .NET and has evolved greatly with the release of ASP.NET Core. In this post we’ll explore the mechanisms and tools the ASP.NET Core Framework provides developers for localizing ASP.NET Core 5 APIs.
General Request Localization
To be able to return localized messages to your caller, you’ll need to enable request localization. This enables your API clients to retrieve messages in their desired language. ASP.NET Core made request localization much easier: no more assembly namespace guessing, messing around with embedded resources, folder name convention magic or pulling your hair out if you want to put localization resources in a satellite assembly.
Startup
The localization configuration is found in the Startup
file of your ASP.NET Core 5 web application. We’ll be altering both the ConfigureServices
method and the Configure
method.
Configure Services
In our ConfigureServices
method, we’ll need to add the required services, set the supported cultures and default culture. This requires that the Microsoft.Extensions.Localization
NuGet is already installed (which is the case with most recent ASP.NET Core 5 Visual Studio Project-Templates).
public void ConfigureServices(IServiceCollection services) { services.AddLocalization(); services.AddRequestLocalization(x => { x.DefaultRequestCulture = new RequestCulture("en"); x.ApplyCurrentCultureToResponseHeaders = true; x.SupportedCultures = new List<CultureInfo> {new("de"), new("en")}; x.SupportedUICultures = new List<CultureInfo> {new("de"), new("en")}; }); //... }
Configure
The Configure
method allows us to register the RequestLocalizationMiddleware
with the ASP.NET pipeline using the registration method provided by ASP.NET Core.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "My Demo API v1")); } app.UseRequestLocalization(); app.UseHttpsRedirection(); app.UseRouting(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }
Defining your localized resources
While we still need resource files, it doesn’t matter whether we place them in a satellite assembly or our executing assembly. The only convention here is that we create a companion .cs
file for each type of resources you want to localize. For example, if we want to group our localized strings in a resource named „Strings“ and want English and German translations, we’ll need to add Strings.en.resx
, Strings.de.resx
and the empty companion class Strings in a Strings.cs
file.



Finally, we add the strings we want to translate to our resource files and are good to go. We also need to make sure that we’ve selected No code generation in the Access Modifier dropdown.



Using localized resources
All we need to do is requesting an IStringLocalizer<T>
from the ASP.NET Core dependency injection system, where T
is the type of your resource group (i.e. „Strings„)
[ApiController] [Route("[controller]")] public class TestController : ControllerBase { private readonly IStringLocalizer<Strings> _localizer; public TestController(IStringLocalizer<Strings> localizer) { _localizer = localizer; } [HttpGet(Name = nameof(GetLocalizedString))] [ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)] public async Task<IActionResult> GetLocalizedString() { string msg = _localizer.GetString("Exception_ProcessingFailed"); return Ok(msg); } }
Depending on the query string, accept language header or cookie (read here for more details) our example controller method returns the translated message:
Data Annotation Localization
We need Data Annotation Localization wherever we are validating our model using a ValidationAttribute
(i.e RequiredAttribute
, MaxLengthAttribute
) and want to return localized validation errors. Unfortunately, there is no way to just override any default localization messages, which means we have to modify our existing Attributes to at least contain a resource key.
Preparing the resources
We need to prepare our resources in the same way we did f we need to create the .resx
files (DataAnnotations.de.resx
, DataAnnotations.en.resx
) for the languages we desire to support and a companion .cs
file (DataAnnotations.cs
), containing an empty class (DataAnnotations
):






Startup
ConfigureServices
In our ConfigureServices
method we use the AddDataAnnotationsLocalization
extension method to register our IStringLocalizer<DataAnnotations>
.
services.AddLocalization(); services.AddRequestLocalization(x => { //... }); services.AddControllers() .AddDataAnnotationsLocalization(o => { o.DataAnnotationLocalizerProvider = (type, factory) => factory.Create(typeof(DataAnnotations)); });
Translating DataAnnotation Attributes
Because there is no way to override the default localization, we have to use the ErrorMessage
property to specify a localization key for each attribute.
public record CreateContactRequest { [Required(ErrorMessage = "ValueIsRequired")] public string FirstName { get; set; } [Required(ErrorMessage = "ValueIsRequired")] public string LastName { get; set; } [Required(ErrorMessage = "ValueIsRequired")] public string EMail { get; set; } }
Model Binding Localization
Model binding comes into play when ASP.NET Core MVC tries to map the request data to the action method parameters.
We usually see model binding errors in APIs when a caller submits an unexpected value for a route property, like when a integer value is expected but the caller submits a string (i.e. for a id).
Preparing the resource files
As in the previous localization steps, translating model binding messages requires us to create the desired .resx
files and a companion .cs
file with an empty class in it.
Hooking into the MVC Configuration
Most tutorials suggest that we call services.BuildServiceProvider().GetService<IStringLocalizerFactory>()
and build ourselves an instance of the IStringLocalizer<T>
during startup. While this is technically possible we should avoid this at all cost because this leads to a couple of unwanted side effects.
The recommended way of hooking into the MVC configuration with full DI support is via IConfigureOptions<T>
.
IConfigureOptions<T>
To hook into the MVC configuration, we implement the IConfigureOptions<MvcOptions>
interface.
public class MvcConfiguration : IConfigureOptions<MvcOptions> { private readonly IStringLocalizer<ModelBinding> _stringLocalizer; public MvcConfiguration(IStringLocalizer<ModelBinding> stringLocalizer) { _stringLocalizer = stringLocalizer; } public void Configure(MvcOptions options) { options.ModelBindingMessageProvider.SetValueIsInvalidAccessor(x => _stringLocalizer.GetString("ValueIsInvalidAccessor", x)); options.ModelBindingMessageProvider.SetValueMustBeANumberAccessor(x => _stringLocalizer.GetString("ValueMustBeANumberAccessor", x)); options.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor(x => _stringLocalizer.GetString("MissingBindRequiredValueAccessor", x)); options.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor((x,y) => _stringLocalizer.GetString("AttemptedValueIsInvalidAccessor", x,y)); options.ModelBindingMessageProvider.SetMissingKeyOrValueAccessor(() => _stringLocalizer.GetString("MissingKeyOrValueAccessor")); options.ModelBindingMessageProvider.SetUnknownValueIsInvalidAccessor(x => _stringLocalizer.GetString("UnknownValueIsInvalidAccessor", x)); options.ModelBindingMessageProvider.SetValueMustNotBeNullAccessor(x => _stringLocalizer.GetString("ValueMustNotBeNullAccessor", x)); options.ModelBindingMessageProvider.SetNonPropertyAttemptedValueIsInvalidAccessor(x => _stringLocalizer.GetString("NonPropertyAttemptedValueIsInvalidAccessor", x)); options.ModelBindingMessageProvider.SetNonPropertyUnknownValueIsInvalidAccessor(() => _stringLocalizer.GetString("UnknownValueIsInvalidAccessor")); options.ModelBindingMessageProvider.SetNonPropertyValueMustBeANumberAccessor(() => _stringLocalizer.GetString("NonPropertyValueMustBeANumberAccessor")); options.ModelBindingMessageProvider.SetMissingRequestBodyRequiredValueAccessor(() => _stringLocalizer.GetString("MissingRequestBodyRequiredValueAccessor")); } }
We can add any registered dependency to our constructor and MVC will inject it for us to use.
To setup the localized model binding messages, we have to set the accessor for each message to the corresponding message from our localized resources.
At the time of writing, there are 11 translatable messages:
AttemptedValueIsInvalidAccessor | The value ‚{0}‘ is not valid for {1}. |
MissingBindRequiredValueAccessor | A value for the ‚{0}‘ property was not provided. |
MissingKeyOrValueAccessor | A value is required. |
MissingRequestBodyRequiredValueAccessor | A non-empty request body is required. |
NonPropertyAttemptedValueIsInvalidAccessor | The value ‚{0}‘ is not valid. |
NonPropertyUnknownValueIsInvalidAccessor | The supplied value is invalid. |
NonPropertyValueMustBeANumberAccessor | The field must be a number. |
UnknownValueIsInvalidAccessor | The supplied value is invalid for {0}. |
ValueIsInvalidAccessor | The value ‚{0}‘ is invalid. |
ValueMustBeANumberAccessor | The field {0} must be a number. |
ValueMustNotBeNullAccessor | The value ‚{0}‘ is invalid. |
Startup
ConfigureServices
To register our IConfigureOptions
implementation, all we have to do is register it as a singleton in our ConfigureServices
method:
services.AddLocalization(); services.AddRequestLocalization(x => { //... }); services.AddSingleton<IConfigureOptions<MvcOptions>, MvcConfiguration>();
Localizing ASP.NET Core 5: Statically Typed ASP.NET Core Localization
ASP.NET Core allows us to retrieve our localized strings using the key names we defined in our .resx
files, while this may be okay for a small application, it get’s very complicated if you have a lot of localized strings or when some keys change.
In earlier ASP.NET Versions we used several tools like resgen.exe
, ResXFileCodeGenerator
or PublicResXFileCodeGenerator
for statically typed resource access, which allowed IDE/IntelliSense support. These tools are mostly obsolete for ASP.NET Core and would interfere with IStringLocalizer<T>
, but there’s an easy fix: a good old T4 Template.
Statically Typing Resource Keys via T4 Template
The following T4 Template goes through all .resx
files starting with „Strings“ (defined in ResourceName
), replaces any ‚.‘ with ‚_‘, stores them in a HashSet
(to avoid duplicate keys) and writes those keys as typed constants into a c# class (Strings.cs
).
<#@ template debug="true" hostspecific="true" language="C#" #> <#@ assembly name="System.Core" #> <#@ assembly name="$(TargetPath)" #> <#@ assembly name="System.Windows.Forms" #> <#@ import namespace="System.Linq" #> <#@ import namespace="System.Resources" #> <#@ import namespace="System.Collections" #> <#@ import namespace="System.IO" #> <#@ import namespace="System.Text" #> <#@ import namespace="System.Collections.Generic" #> <#@ output extension=".cs" #> using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; <# var ResourceName = "Strings"; #> namespace Signa.CRM.API.Localization { public class <#=ResourceName#> { <# var path = Path.GetDirectoryName(Host.TemplateFile); var fileNames = Directory.EnumerateFiles(path, $"{ResourceName}*.resx", SearchOption.TopDirectoryOnly).ToList(); var localizationKeys = new HashSet<String>(); foreach ( var name in fileNames ) { var localeFile = Host.ResolvePath(name); ResXResourceSet jpResxSet = new ResXResourceSet(localeFile); foreach (DictionaryEntry item in jpResxSet) { localizationKeys.Add(item.Key.ToString()); } } foreach(var key in localizationKeys.OrderBy(x => x)){ if(key.Contains(".")) { #> public const string <#=key.Replace(".", "_")#> = "<#=key#>"; <# } else { #> public const string <#=key#> = nameof(<#=key#>); <# } } #> } }
T4 Template Result
You can run your T4 via [right click] -> „Run custom tool“. Make sure you’ve set the Custom Tool in the file properties to TextTemplatingFileGenerator
.
public class Strings { public const string Exception_APIValidation_AttachmentSizeExceeded = nameof(Exception_APIValidation_AttachmentSizeExceeded); public const string Exception_APIValidation_Common = nameof(Exception_APIValidation_Common); public const string Exception_ProcessingFailed = nameof(Exception_ProcessingFailed); public const string Messages_APIError_InsufficientPermissions = nameof(Messages_APIError_InsufficientPermissions); public const string Messages_APIError_InvalidOrMissingInformation = nameof(Messages_APIError_InvalidOrMissingInformation); public const string Messages_APIError_ResourceNotFound = nameof(Messages_APIError_ResourceNotFound); public const string Messages_APIError_UnauthorizedAccess = nameof(Messages_APIError_UnauthorizedAccess); public const string Messages_APIValidation_PleaseSelectAtLeastOneItem = nameof(Messages_APIValidation_PleaseSelectAtLeastOneItem); }
Wherever we want to access a resource key we just have to use the statically typed key in our generated class like this:
string msg = _localizer.GetString(Strings.Exception_ProcessingFailed);
Note: If you have multiple T4 Templates in the same folder they end up overwriting each others generated files. We recommend a subfolder for each group of resources.
Other Resources
https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-5.0