Skip to content

Localization in ConnectSoft Microservice Template

Purpose & Overview

Localization (i18n) in the ConnectSoft Microservice Template enables applications to support multiple languages and cultures, providing translated content, culture-specific formatting, and region-aware behavior. The template uses ASP.NET Core's built-in localization infrastructure with .NET resource files (.resx) to provide a robust, maintainable localization system.

Localization provides:

  • Multi-Language Support: Support for multiple languages and cultures
  • Resource Files: .NET resource files (.resx) for string localization
  • Culture Detection: Automatic culture detection from HTTP headers and request context
  • Strongly-Typed Resources: Type-safe resource access via generated classes
  • IStringLocalizer Integration: Flexible string localization via dependency injection
  • Validation Message Localization: Localized validation error messages
  • Culture-Specific Formatting: Automatic formatting of dates, numbers, and currencies
  • Fallback Support: Automatic fallback to parent cultures or default culture

Localization Philosophy

Localization enables microservices to serve global audiences by providing culturally appropriate content and formatting. The template uses industry-standard .NET resource files and ASP.NET Core localization infrastructure, ensuring maintainability, type safety, and performance. All user-facing strings are localized, from validation messages to error responses.

Architecture Overview

Localization Stack

HTTP Request
Request Localization Middleware
    ├── Detects culture from Accept-Language header
    ├── Detects culture from query string (?culture=)
    ├── Detects culture from cookie
    └── Falls back to default culture
Sets Thread Culture
    ├── CultureInfo.CurrentCulture (formatting)
    └── CultureInfo.CurrentUICulture (resource lookup)
Resource Resolution
    ├── IStringLocalizer<T> (Dependency Injection)
    ├── ResourceManager (Strongly-typed resources)
    └── .resx Resource Files
    ├── ValidationMessages.resx (default)
    ├── ValidationMessages.en-us.resx (English)
    └── ValidationMessages.ru.resx (Russian)
Localized Response
    ├── Content-Language header set
    └── Localized strings in response

Localization Components

Component Purpose Location
LocalizationExtensions Service registration and middleware configuration ApplicationModel
MicroserviceLocalizationOptions Configuration options Options
Resource Files (.resx) String resources per culture Resources folder
IStringLocalizer Dependency injection for string localization ASP.NET Core
ResourceManagerStringLocalizerFactory Factory for creating localizers ASP.NET Core
RequestLocalizationMiddleware Culture detection and setting ASP.NET Core

Configuration

Service Registration

AddMicroserviceLocalization:

// LocalizationExtensions.cs
internal static IServiceCollection AddMicroserviceLocalization(this IServiceCollection services)
{
    ArgumentNullException.ThrowIfNull(services);

    services.AddLocalization(options =>
    {
        options.ResourcesPath = OptionsExtensions.MicroserviceLocalizationOptions.ResourcesPath;
    });

    // Registers ResourceManagerStringLocalizerFactory to centralize and configure 
    // localization using .resx resource files across the application.
    services.AddSingleton<IStringLocalizerFactory, ResourceManagerStringLocalizerFactory>();

    services.Configure<RequestLocalizationOptions>(options =>
    {
        options.DefaultRequestCulture = new RequestCulture(
            OptionsExtensions.MicroserviceLocalizationOptions.DefaultRequestCulture);
        options.SupportedCultures = OptionsExtensions.MicroserviceLocalizationOptions
            .SupportedCultures.Select(culture => new CultureInfo(culture)).ToList();
        options.SupportedUICultures = OptionsExtensions.MicroserviceLocalizationOptions
            .SupportedUICultures.Select(culture => new CultureInfo(culture)).ToList();
        options.ApplyCurrentCultureToResponseHeaders = 
            OptionsExtensions.MicroserviceLocalizationOptions.ApplyCurrentCultureToResponseHeaders;
        options.CultureInfoUseUserOverride = 
            OptionsExtensions.MicroserviceLocalizationOptions.CultureInfoUseUserOverride;
        options.FallBackToParentCultures = 
            OptionsExtensions.MicroserviceLocalizationOptions.FallBackToParentCultures;
        options.FallBackToParentUICultures = 
            OptionsExtensions.MicroserviceLocalizationOptions.FallBackToParentUICultures;
    });

    return services;
}

Registration in Startup:

// MicroserviceRegistrationExtensions.cs
services.AddMicroserviceLocalization();

Middleware Configuration

UseMicroserviceLocalization:

// LocalizationExtensions.cs
internal static IApplicationBuilder UseMicroserviceLocalization(this IApplicationBuilder application)
{
    ArgumentNullException.ThrowIfNull(application);

    application.UseRequestLocalization();

    return application;
}

Middleware Order:

// MicroserviceRegistrationExtensions.cs
application.UseRouting();
application.UseMicroserviceRateLimiter();
application.UseMicroserviceLocalization(); // After routing, before endpoints
application.UseMicroserviceHeaderPropagation();
application.UseEndpoints(endpoints => { /* ... */ });

Configuration Options

MicroserviceLocalizationOptions:

{
  "MicroserviceLocalization": {
    "ResourcesPath": "Resources",
    "DefaultRequestCulture": "en-US",
    "CultureInfoUseUserOverride": true,
    "FallBackToParentCultures": true,
    "FallBackToParentUICultures": true,
    "ApplyCurrentCultureToResponseHeaders": true,
    "SupportedCultures": [
      "en-US",
      "ru-RU",
      "fr-FR"
    ],
    "SupportedUICultures": [
      "en-US",
      "ru-RU",
      "fr-FR"
    ]
  }
}

Configuration Properties:

Property Type Default Description
ResourcesPath string Required Relative path under application root where resource files are located
DefaultRequestCulture string "en-US" Default culture when no culture can be determined
CultureInfoUseUserOverride bool true Whether to use user-selected culture settings
FallBackToParentCultures bool true Fallback to parent culture if specific culture not found
FallBackToParentUICultures bool true Fallback to parent UI culture if specific culture not found
ApplyCurrentCultureToResponseHeaders bool true Set Content-Language header in responses
SupportedCultures List<string> ["en-US"] List of supported cultures for formatting
SupportedUICultures List<string> ["en-US"] List of supported UI cultures for resource lookup

Resource Files

Resource File Structure

Resource files are organized by culture:

Resources/
├── ValidationMessages.resx          (Default/neutral)
├── ValidationMessages.en-us.resx    (English - United States)
├── ValidationMessages.ru.resx       (Russian)
└── ValidationMessages.fr-FR.resx    (French - France)

Creating Resource Files

1. Default Resource File (ValidationMessages.resx):

<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="ObjectIdRequired">
    <value>Object id is required.</value>
  </data>
  <data name="MicroserviceAggregateRootAlreadyExists">
    <value>MicroserviceAggregateRoot already exists.</value>
  </data>
  <data name="MicroserviceAggregateRootNotFound">
    <value>MicroserviceAggregateRoot not found.</value>
  </data>
</root>

2. Culture-Specific Resource File (ValidationMessages.ru.resx):

<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="ObjectIdRequired">
    <value>Требуется идентификатор объекта.</value>
  </data>
  <data name="MicroserviceAggregateRootAlreadyExists">
    <value>MicroserviceAggregateRoot уже существует.</value>
  </data>
  <data name="MicroserviceAggregateRootNotFound">
    <value>MicroserviceAggregateRoot не найден.</value>
  </data>
</root>

Strongly-Typed Resource Classes

Resource files automatically generate strongly-typed classes:

// ValidationMessages.Designer.cs (auto-generated)
namespace ConnectSoft.MicroserviceTemplate.Resources
{
    public class ValidationMessages
    {
        private static ResourceManager resourceMan;
        private static CultureInfo resourceCulture;

        public static ResourceManager ResourceManager
        {
            get
            {
                if (ReferenceEquals(resourceMan, null))
                {
                    resourceMan = new ResourceManager(
                        "ConnectSoft.MicroserviceTemplate.Resources.ValidationMessages",
                        typeof(ValidationMessages).Assembly);
                }
                return resourceMan;
            }
        }

        public static CultureInfo Culture
        {
            get { return resourceCulture; }
            set { resourceCulture = value; }
        }

        public static string ObjectIdRequired
        {
            get
            {
                return ResourceManager.GetString("ObjectIdRequired", resourceCulture);
            }
        }

        public static string MicroserviceAggregateRootAlreadyExists
        {
            get
            {
                return ResourceManager.GetString(
                    "MicroserviceAggregateRootAlreadyExists", 
                    resourceCulture);
            }
        }

        public static string MicroserviceAggregateRootNotFound
        {
            get
            {
                return ResourceManager.GetString(
                    "MicroserviceAggregateRootNotFound", 
                    resourceCulture);
            }
        }
    }
}

Usage:

// Direct access (uses current UI culture)
string message = ValidationMessages.ObjectIdRequired;

// Explicit culture
ValidationMessages.Culture = new CultureInfo("ru-RU");
string russianMessage = ValidationMessages.ObjectIdRequired;

Resource File Best Practices

  1. Always Include All Keys in Default Resource
  2. Default resource file should contain all keys
  3. Culture-specific files only override translations

  4. Keep Keys Consistent

  5. All culture-specific files should have the same keys
  6. Missing keys fall back to parent/default culture

  7. Use Descriptive Key Names

    <!-- ✅ GOOD - Descriptive names -->
    <data name="ObjectIdRequired">
    
    <!-- ❌ BAD - Vague names -->
    <data name="Error1">
    

  8. Include Comments in Resource Files

    <data name="ObjectIdRequired">
      <value>Object id is required.</value>
      <comment>Error message when object ID is missing</comment>
    </data>
    

Using IStringLocalizer

Dependency Injection

IStringLocalizer provides localized strings via dependency injection:

public class MyService
{
    private readonly IStringLocalizer<MyService> localizer;

    public MyService(IStringLocalizer<MyService> localizer)
    {
        this.localizer = localizer;
    }

    public string GetErrorMessage()
    {
        return this.localizer["ObjectIdRequired"];
    }
}

Resource-Scoped Localizer

For resource-specific localizers, use the resource type:

public class ValidationService
{
    private readonly IStringLocalizer<ValidationMessages> localizer;

    public ValidationService(IStringLocalizer<ValidationMessages> localizer)
    {
        this.localizer = localizer;
    }

    public string GetValidationMessage(string key)
    {
        return this.localizer[key];
    }
}

Localized Strings with Parameters

Use LocalizedString for formatted strings:

// Resource file
<data name="ItemNotFound">
  <value>Item with ID '{0}' not found.</value>
</data>

// Code
var message = this.localizer["ItemNotFound", itemId];
// Returns: "Item with ID '123' not found." (en-US)
// Returns: "Элемент с ID '123' не найден." (ru-RU)

LocalizedString Properties

var localized = this.localizer["Key"];

// Properties
localized.Name;      // "Key"
localized.Value;     // Localized string value
localized.ResourceNotFound; // true if key not found

Getting All Strings

// Get all strings for current culture
var allStrings = this.localizer.GetAllStrings();

foreach (var localizedString in allStrings)
{
    Console.WriteLine($"{localizedString.Name}: {localizedString.Value}");
}

// Get all strings including parent cultures
var allWithParents = this.localizer.GetAllStrings(includeParentCultures: true);

Request Culture Detection

Culture Providers

ASP.NET Core uses multiple culture providers in order:

  1. Query String Provider (?culture=ru-RU)
  2. Cookie Provider (CookieRequestCultureProvider)
  3. Accept-Language Header Provider (AcceptLanguageHeaderRequestCultureProvider)

Accept-Language Header

HTTP Request:

GET /api/aggregates HTTP/1.1
Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7

The middleware: 1. Parses Accept-Language header 2. Selects first supported culture (ru-RU) 3. Falls back to parent culture (ru) if ru-RU not supported 4. Falls back to default culture (en-US) if no match

Query String Culture

URL:

GET /api/aggregates?culture=ru-RU

The query string provider takes precedence over Accept-Language header.

Set Culture Cookie:

// Set culture cookie
Response.Cookies.Append(
    CookieRequestCultureProvider.DefaultCookieName,
    CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("ru-RU")),
    new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) });

Read Culture from Cookie:

The middleware automatically reads the culture cookie and sets the request culture.

Custom Culture Provider

Creating Custom Provider:

public class CustomCultureProvider : RequestCultureProvider
{
    public override Task<ProviderCultureResult?> DetermineProviderCultureResult(
        HttpContext httpContext)
    {
        // Custom logic to determine culture
        var culture = httpContext.Request.Headers["X-Custom-Culture"].FirstOrDefault();

        if (culture != null)
        {
            return Task.FromResult<ProviderCultureResult?>(
                new ProviderCultureResult(culture));
        }

        return NullProviderCultureResult;
    }
}

// Registration
services.Configure<RequestLocalizationOptions>(options =>
{
    options.RequestCultureProviders.Insert(0, new CustomCultureProvider());
});

Culture Fallback

Parent Culture Fallback

Example:

  • Requested: fr-CA (French - Canada)
  • Supported: fr-FR (French - France)
  • With FallBackToParentCultures: true: Falls back to fr (French)
  • If fr not supported: Falls back to default culture

Configuration:

{
  "MicroserviceLocalization": {
    "FallBackToParentCultures": true,
    "FallBackToParentUICultures": true
  }
}

Fallback Chain

Requested: fr-CA
Check: fr-CA (not found)
Check: fr (found) ← Uses parent culture
If not found: en-US (default)

Validation Message Localization

FluentValidation Localization

Using Localized Messages:

public class CreateAggregateValidator : AbstractValidator<CreateInput>
{
    public CreateAggregateValidator(IStringLocalizer<ValidationMessages> localizer)
    {
        RuleFor(x => x.ObjectId)
            .NotEmpty()
            .WithMessage(localizer["ObjectIdRequired"]);
    }
}

Exception Message Localization

Localized Exception Messages:

public class AggregateService
{
    private readonly IStringLocalizer<ValidationMessages> localizer;

    public AggregateService(IStringLocalizer<ValidationMessages> localizer)
    {
        this.localizer = localizer;
    }

    public void ValidateObjectId(string objectId)
    {
        if (string.IsNullOrEmpty(objectId))
        {
            throw new ObjectIdRequiredException(
                this.localizer["ObjectIdRequired"]);
        }
    }
}

gRPC Error Localization

Localized gRPC Errors:

// GrpcRichErrorInterceptor.cs
public class GrpcRichErrorInterceptor : Interceptor
{
    private readonly IStringLocalizer<GrpcRichErrorInterceptor> localizer;

    public GrpcRichErrorInterceptor(
        IStringLocalizer<GrpcRichErrorInterceptor> localizer)
    {
        this.localizer = localizer;
    }

    private RpcException CreateRpcException(ServerCallContext context, Exception exception)
    {
        switch (exception)
        {
            case ObjectIdRequiredException:
                additionalErrors = Any.Pack(
                    new BadRequest
                    {
                        FieldViolations =
                        {
                            new BadRequest.Types.FieldViolation
                            {
                                Field = "ObjectId",
                                Description = this.localizer["ObjectIdRequired"],
                            },
                        },
                    });
                break;
        }
    }
}

Culture-Specific Formatting

Date Formatting

// Uses current culture for formatting
var date = DateTime.Now;

// en-US: "12/31/2023"
// ru-RU: "31.12.2023"
// fr-FR: "31/12/2023"
string formatted = date.ToString("d");

Number Formatting

// Uses current culture for formatting
var number = 1234.56;

// en-US: "1,234.56"
// ru-RU: "1 234,56"
// fr-FR: "1 234,56"
string formatted = number.ToString("N2");

Currency Formatting

// Uses current culture for currency
var amount = 1234.56m;

// en-US: "$1,234.56"
// ru-RU: "1 234,56 ₽"
// fr-FR: "1 234,56 €"
string formatted = amount.ToString("C");

Explicit Culture Formatting

// Format with specific culture
var date = DateTime.Now;
var culture = new CultureInfo("ru-RU");
string formatted = date.ToString("d", culture); // "31.12.2023"

Response Headers

Content-Language Header

When ApplyCurrentCultureToResponseHeaders is true, the middleware automatically sets the Content-Language header:

HTTP/1.1 200 OK
Content-Language: ru-RU
Content-Type: application/json

Configuration:

{
  "MicroserviceLocalization": {
    "ApplyCurrentCultureToResponseHeaders": true
  }
}

Testing

Unit Testing with Culture

Setting Culture in Tests:

[TestMethod]
public void LocalizedMessage_ShouldReturnRussian_WhenCultureIsRuRu()
{
    // Arrange
    var originalCulture = CultureInfo.CurrentUICulture;
    try
    {
        CultureInfo.CurrentUICulture = new CultureInfo("ru-RU");

        // Act
        var message = ValidationMessages.ObjectIdRequired;

        // Assert
        Assert.AreEqual("Требуется идентификатор объекта.", message);
    }
    finally
    {
        CultureInfo.CurrentUICulture = originalCulture;
    }
}

Helper Method:

private static void WithUiCulture(string cultureName, Action action)
{
    var originalUi = CultureInfo.CurrentUICulture;
    var original = CultureInfo.CurrentCulture;

    try
    {
        var ci = cultureName is null 
            ? CultureInfo.InvariantCulture 
            : new CultureInfo(cultureName);
        CultureInfo.CurrentUICulture = ci;
        CultureInfo.CurrentCulture = ci;
        action();
    }
    finally
    {
        CultureInfo.CurrentUICulture = originalUi;
        CultureInfo.CurrentCulture = original;
    }
}

// Usage
[TestMethod]
public void TestWithRussianCulture()
{
    WithUiCulture("ru-RU", () =>
    {
        Assert.AreEqual(
            "Требуется идентификатор объекта.",
            ValidationMessages.ObjectIdRequired);
    });
}

Testing IStringLocalizer

Mock Localizer:

[TestMethod]
public void Service_ShouldUseLocalizedMessage()
{
    // Arrange
    var mockLocalizer = new Mock<IStringLocalizer<MyService>>();
    mockLocalizer
        .Setup(l => l["Key"])
        .Returns(new LocalizedString("Key", "Localized Value"));

    var service = new MyService(mockLocalizer.Object);

    // Act
    var result = service.GetMessage();

    // Assert
    Assert.AreEqual("Localized Value", result);
}

Integration Testing

Testing with Different Cultures:

[TestMethod]
public async Task Api_ShouldReturnLocalizedError_WhenCultureIsRuRu()
{
    // Arrange
    var client = factory.CreateClient();
    client.DefaultRequestHeaders.Add("Accept-Language", "ru-RU");

    // Act
    var response = await client.PostAsync("/api/aggregates", content);

    // Assert
    var error = await response.Content.ReadAsStringAsync();
    Assert.IsTrue(error.Contains("Требуется идентификатор объекта."));
}

Best Practices

Do's

  1. Use Resource Files for All User-Facing Strings

    // ✅ GOOD - Localized
    throw new ValidationException(localizer["ObjectIdRequired"]);
    
    // ❌ BAD - Hard-coded
    throw new ValidationException("Object id is required.");
    

  2. Keep Resource Keys Consistent Across Cultures

    <!-- ✅ GOOD - All files have same keys -->
    <!-- ValidationMessages.resx -->
    <data name="ObjectIdRequired"><value>...</value></data>
    
    <!-- ValidationMessages.ru.resx -->
    <data name="ObjectIdRequired"><value>...</value></data>
    

  3. Use Descriptive Resource Key Names

    <!-- ✅ GOOD - Descriptive -->
    <data name="ObjectIdRequired">
    
    <!-- ❌ BAD - Vague -->
    <data name="Error1">
    

  4. Test All Cultures

    // ✅ GOOD - Test all supported cultures
    [TestMethod]
    public void TestEnUsCulture() { /* ... */ }
    
    [TestMethod]
    public void TestRuRuCulture() { /* ... */ }
    

  5. Use IStringLocalizer for Dependency Injection

    // ✅ GOOD - Injectable
    public MyService(IStringLocalizer<MyService> localizer)
    {
        this.localizer = localizer;
    }
    

Don'ts

  1. Don't Hard-Code Strings

    // ❌ BAD - Hard-coded
    return "Object id is required.";
    
    // ✅ GOOD - Localized
    return this.localizer["ObjectIdRequired"];
    

  2. Don't Mix Localized and Non-Localized Strings

    // ❌ BAD - Mixed
    return $"Error: {localizer["ObjectIdRequired"]}";
    
    // ✅ GOOD - All localized
    return localizer["ObjectIdRequired"];
    

  3. Don't Forget to Add Keys to All Cultures

    <!-- ❌ BAD - Missing key in ru.resx -->
    <!-- ValidationMessages.resx -->
    <data name="NewKey"><value>...</value></data>
    
    <!-- ValidationMessages.ru.resx -->
    <!-- Missing NewKey -->
    

  4. Don't Use Culture-Specific Formatting Without Consideration

    // ❌ BAD - May not work in all cultures
    var dateStr = date.ToString("MM/dd/yyyy");
    
    // ✅ GOOD - Culture-aware
    var dateStr = date.ToString("d");
    

  5. Don't Ignore Fallback Behavior

    // ❌ BAD - Assumes specific culture exists
    CultureInfo.CurrentUICulture = new CultureInfo("fr-CA");
    var message = localizer["Key"]; // May not exist
    
    // ✅ GOOD - Handles fallback
    var message = localizer["Key"];
    if (localizer["Key"].ResourceNotFound)
    {
        // Handle missing resource
    }
    

Troubleshooting

Issue: Localized Strings Not Appearing

Symptoms: Always returns English/default strings.

Solutions: 1. Verify culture is set correctly: CultureInfo.CurrentUICulture 2. Check resource file naming: ValidationMessages.{culture}.resx 3. Verify resource files are included in build output 4. Check ResourcesPath configuration matches actual path 5. Verify satellite assemblies are deployed (culture-specific DLLs)

Issue: Missing Resource Keys

Symptoms: Key not found, returns key name.

Solutions: 1. Verify key exists in default resource file 2. Check key name matches exactly (case-sensitive) 3. Rebuild project to regenerate .Designer.cs files 4. Verify resource files are marked as "Embedded Resource"

Issue: Culture Not Detected

Symptoms: Always uses default culture.

Solutions: 1. Check Accept-Language header is sent 2. Verify culture is in SupportedCultures list 3. Check middleware order (must be after routing) 4. Verify UseRequestLocalization() is called 5. Check query string format: ?culture=ru-RU

Issue: Resource Files Not Found

Symptoms: ResourceNotFoundException or missing translations.

Solutions: 1. Verify resource files are in correct folder (Resources/) 2. Check ResourcesPath configuration 3. Ensure resource files are marked as "Embedded Resource" in project 4. Verify satellite assemblies are deployed (check output folder) 5. Rebuild project to ensure satellite assemblies are generated

Summary

Localization in the ConnectSoft Microservice Template provides:

  • Multi-Language Support: Support for multiple languages and cultures
  • Resource Files: .NET resource files (.resx) for maintainable localization
  • Culture Detection: Automatic detection from HTTP headers and request context
  • Strongly-Typed Resources: Type-safe resource access via generated classes
  • IStringLocalizer Integration: Flexible dependency injection-based localization
  • Validation Localization: Localized validation and error messages
  • Culture-Specific Formatting: Automatic formatting of dates, numbers, currencies
  • Fallback Support: Automatic fallback to parent or default cultures
  • Response Headers: Automatic Content-Language header setting
  • Testing Support: Comprehensive testing patterns and helpers

By following these patterns, teams can:

  • Support Global Audiences: Serve users in multiple languages and regions
  • Maintain Consistency: Keep localized strings organized and versioned
  • Ensure Quality: Test all cultures to verify translations
  • Improve UX: Provide culturally appropriate content and formatting
  • Simplify Maintenance: Use strongly-typed resources and dependency injection
  • Handle Edge Cases: Graceful fallback when translations are missing

Localization is a critical component for microservices serving international audiences, enabling culturally appropriate content, proper formatting, and improved user experience across diverse regions and languages.