Enhance ASP.NET Core Localization with Automated Translations via Result Filters


In my previous post on localization in a .NET Core Web API, I covered the basics of setting up localization and translating API responses into different languages based on the user’s requested language.
In that example, we localized a single string field in the API response. However, in real-world applications, we often need to localize multiple fields. In this post, we’ll extend the previous example and explore a technique to localize multiple fields dynamically—without manually applying localization logic in every controller.
Note: I’ll be repeating some steps from the previous post but won’t go into detailed explanations. If you need a deeper understanding, please check out the previous post.
Creating the Sample Application
1. Set Up the Project
First, create a new Web API project using the .NET CLI or Visual Studio:
dotnet new webapi -n WebApiLocalization
cd WebApiLocalization
2. Define the Data Models
Create a User
class and two enums (Gender
and Status
) to represent our data:
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public Gender Gender { get; set; }
public Status Status { get; set; }
}
public enum Gender
{
Male,
Female,
Other
}
public enum Status
{
Active,
Inactive,
Pending
}
3. Add Sample Data
Create a helper class to provide some test data:
public static class DataHelper
{
public static List<User> GetUsers()
{
// Sample users with raw values
return
[
new User { Id = 1, Name = "Alice", Gender = Gender.Female, Status = Status.Active },
new User { Id = 2, Name = "Bob", Gender = Gender.Male, Status = Status.Inactive },
new User { Id = 3, Name = "Gamma", Gender = Gender.Other, Status = Status.Pending }
];
}
}
Setting Up Localization
4. Set Up Resource Files
Resource files store translated text. Here’s how to set them up:
Create a
Resources
folder in your project.Add a marker class called
SharedResource
:public class SharedResource { }
Add two resource files:
SharedResource.en.resx
(for English)SharedResource.fr.resx
(for French)
Resource File Content
SharedResource.en.resx
Female → Female
Male → Male
Other → Other
Active → Active
Inactive → Inactive
Pending → Pending
SharedResource.fr.resx
Female → Femme
Male → Homme
Other → Autre
Active → Actif
Inactive → Inactif
Pending → En attente
5. Create the Localization Service
We’ll create a custom service to handle translations:
public interface IAppLocalizer
{
string Localize(string key);
}
public class AppLocalizer : IAppLocalizer
{
private readonly IStringLocalizer _localizer;
public AppLocalizer(IStringLocalizerFactory factory)
{
var type = typeof(SharedResource);
var assemblyName = new AssemblyName(type.GetTypeInfo().Assembly.FullName);
_localizer = factory.Create("SharedResource", assemblyName.Name);
}
public string Localize(string key) => _localizer[key];
}
6. Configure the Application
Update Program.cs
to enable localization:
var builder = WebApplication.CreateBuilder(args);
// Add localization services
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddScoped<IAppLocalizer, AppLocalizer>();
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// Set up supported languages
var supportedCultures = new[] { "en", "fr" };
var localizationOptions = new RequestLocalizationOptions()
.SetDefaultCulture(supportedCultures[0])
.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures);
localizationOptions.RequestCultureProviders.Insert(0, new AcceptLanguageHeaderRequestCultureProvider());
app.UseRequestLocalization(localizationOptions);
app.UseAuthorization();
app.MapControllers();
app.Run();
Building the API Endpoint
7. Create the API Response DTO
First, create a DTO to structure our response:
public class UserDto
{
public int Id { get; set; }
public string Name { get; set; }
public string Gender { get; set; }
public string Status { get; set; }
}
8. Implement the Controller
Create an API controller to return localized data:
[ApiController]
public class UserController(IAppLocalizer localizer) : ControllerBase
{
[HttpGet("users")]
public IActionResult GetAll()
{
var users = DataHelper.GetUsers().Select(user => new UserDto
{
Id = user.Id,
Gender = localizer.Localize(user.Gender.ToString()),
Name = user.Name,
Status = localizer.Localize(user.Status.ToString())
});
return Ok(users);
}
}
9. Testing the API
You can test the API using cURL
or any API testing tool:
# Request in English
curl -X GET "http://localhost:5127/users" -H "Accept-Language: en"
# Request in French
curl -X GET "http://localhost:5127/users" -H "Accept-Language: fr"
We can see that both Gender
and Status
fields are localized in the responses. This is perfectly fine but we have an opportunity to make this even better.
Right now, the controller calls localizer.Localize()
for each field that needs translation. While this approach works, it requires manually applying localization in the code where it's needed.
What if we could simply mark which fields should be localized and let a centralized logic handle it automatically? This would keep our code cleaner and reduce duplication. Let’s see how to achieve this.
Automating Localization with Attributes and Filters
10. Create a Localizable
Attribute
We’ll create a custom attribute to mark fields that require localization:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class LocalizableAttribute : Attribute
{
}
11. Apply the Localizable
Attribute
Decorate the DTO
properties that should be localized with the attribute:
public class UserDto
{
public int Id { get; set; }
public string Name { get; set; }
[Localizable]
public string Gender { get; set; }
[Localizable]
public string Status { get; set; }
}
12. Create a Result Filter for Localization
We can leverage ASP.NET Core Result Filters to apply localization dynamically.
In ASP.NET Core, a Result Filter is a type of filter that allows you to modify or inspect the result of an action method before it is sent to the client. Result filters run after the action method has executed but before the response is finalized.
We shall create a result filter that does the following
Detect all
Localizable
properties in the API response.Automatically localize these fields in the response using our
IAppLocalizer
.Work with both simple objects and collections.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
namespace WebApiLocalization;
public class LocalizableResultFilter(IAppLocalizer localizer) : IAsyncResultFilter
{
// Cache for reflected type information to improve performance
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _typePropertyCache =
new ConcurrentDictionary<Type, PropertyInfo[]>();
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
if (context.Result is ObjectResult objectResult && objectResult.Value != null)
{
try
{
objectResult.Value = LocalizeValue(objectResult.Value);
}
catch (Exception ex)
{
// Log the error or handle it appropriately
Debug.WriteLine($"Localization error: {ex.Message}");
}
}
await next();
}
private static bool IsPrimitiveOrSimpleType(Type type)
{
var simpleTypes = new HashSet<Type>
{
typeof(string),
typeof(decimal),
typeof(DateTime),
typeof(DateTimeOffset),
typeof(TimeSpan),
typeof(DateOnly),
typeof(TimeOnly)
};
return type.IsPrimitive || simpleTypes.Contains(type);
}
private List<object> LocalizeCollection(IEnumerable collection)
{
var localizedList = new List<object>();
foreach (var item in collection)
{
localizedList.Add(LocalizeValue(item));
}
return localizedList;
}
private object LocalizeObject(object obj)
{
var objType = obj.GetType();
var properties = _typePropertyCache.GetOrAdd(
objType,
type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanWrite
&& (p.PropertyType == typeof(string)
|| (!p.PropertyType.IsPrimitive
&& p.PropertyType != typeof(decimal)
&& p.PropertyType != typeof(DateTime)
&& p.PropertyType != typeof(DateTimeOffset)
&& p.PropertyType != typeof(TimeSpan)
&& p.PropertyType != typeof(DateOnly)
&& p.PropertyType != typeof(TimeOnly))))
.ToArray());
foreach (var prop in properties)
{
try
{
var propValue = prop.GetValue(obj);
// Localize string properties with LocalizableAttribute
if (prop.PropertyType == typeof(string) &&
prop.IsDefined(typeof(LocalizableAttribute), true))
{
prop.SetValue(obj, LocalizeString(propValue as string));
}
// Recursively process complex properties
else if (propValue != null)
{
prop.SetValue(obj, LocalizeValue(propValue));
}
}
catch (Exception ex)
{
// Log individual property localization failures
Debug.WriteLine($"Failed to localize property {prop.Name}: {ex.Message}");
}
}
return obj;
}
private string LocalizeString(string input)
{
return string.IsNullOrWhiteSpace(input)
? input
: localizer.Localize(input);
}
private object LocalizeValue(object value)
{
// Handle primitive types, DateTime, and other non-localizable types quickly
if (IsPrimitiveOrSimpleType(value.GetType()))
{
return value;
}
// Handle collections (exclude strings as string is an IEnumerable type)
if (value is IEnumerable collection && value is not string)
{
return LocalizeCollection(collection);
}
// Handle complex objects
return LocalizeObject(value);
}
}
13. Register the Filter in Program.cs
Enable the filter globally:
builder.Services.AddControllers(opt =>
{
opt.Filters.Add<LocalizableResultFilter>();
});
14. Update the Controller
Now, we no longer need to call localizer.Localize()
inside the controller. We simply return the DTO, and the result filter handles localization:
[ApiController]
public class UserController : ControllerBase
{
[HttpGet("users")]
public IActionResult GetAll()
{
var users = DataHelper.GetUsers().Select(user => new UserDto
{
Id = user.Id,
Gender = user.Gender.ToString(),
Name = user.Name,
Status = user.Status.ToString()
});
return Ok(users);
}
}
Final Testing
Run the API and test it again:
curl -X GET "http://localhost:5127/users" -H "Accept-Language: en"
curl -X GET "http://localhost:5127/users" -H "Accept-Language: fr"
✅ Now, localization happens automatically for all marked fields, making the controller cleaner and reducing duplicate code!
Summary
We started with basic localization in a .NET Core Web API.
Instead of manually localizing each field, we created a
Localizable
attribute.We used Result Filters to automatically apply localization to marked fields.
This approach makes the API cleaner and easier to maintain.
Now, localization is handled dynamically with minimal changes to the API code!
Subscribe to my newsletter
Read articles from Geo J Thachankary directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Geo J Thachankary
Geo J Thachankary
Hey there! I’m a software architect with a decade of experience turning ideas into solutions. My playground? Microsoft .NET technologies, with a deep focus on crafting elegant backend systems using Web APIs. PostgreSQL is my favorite database, though I won’t say no to experimenting with other tools when the occasion calls for it. At work, I dive into the complexities of event-driven microservices (yes, Kafka makes the guest list sometimes). But between you and me, I’ve got a soft spot for the simplicity and stability of a well-built monolith. I’ve dabbled with the clouds—both AWS and Azure—but I keep my feet firmly grounded in pragmatic solutions. When I’m not architecting scalable systems or untangling code spaghetti, you might find me penning down thoughts on .NET, sharing tips, tricks, and lessons learned from the trenches. Welcome to my little corner of the internet!