Skip to content

Improvement of Localization #82

@Matt-17

Description

@Matt-17

The current localization is a bit outdated. Recreation of Resources.Designer.cs causes git to think it changed. Removing this file alone does not allow build without explicit recreation.

There is an approach in:
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/make-content-localizable?view=aspnetcore-9.0

Migrate from .resx + *.Designer.cs to IStringLocalizer (no designer files)

Type: refactor ♻️ · Area: localization 🌍 · Priority: high
Goal: Eliminate *.Designer.cs churn/diffs and adopt the ASP.NET Core-native localization stack (IStringLocalizer / IViewLocalizer), keeping only .resx files in source control.


Why

  • *.Designer.cs are generated and frequently re-written across branches/VS versions → noisy diffs and merge conflicts.
  • ASP.NET Core supports localization without designer classes using IStringLocalizer (runtime lookup from embedded .resx).
  • Cleaner repo: only .resx committed; generated code stays out of Git.

Out of scope

  • Changing actual translation keys/wording (we keep existing keys).
  • Re-structuring routes or business logic.

Plan (high level)

  1. Remove legacy designer files (delete from repo & project).
  2. Wire up localization in Program.cs.
  3. Move/confirm resource files under Resources/ and ensure Build Action = Embedded resource.
  4. Replace calls to Resources.MyStrings.SomeKey (designer props) with _localizer["SomeKey"] (or Localizer["SomeKey"] in views).
  5. Update DataAnnotations and validation messages to use localization.
  6. Add MSTest tests (with NSubstitute) for culture switching.
  7. Update CI: ensure resx are embedded and culture tests run.
  8. Add .gitignore rules after deleting designer files.

Important ordering: First delete all *.Designer.cs and references, commit that, then add .gitignore entries so they aren’t reintroduced.


Detailed tasks & checklists

0) Remove designer files (first!)

  • Search project for any *.Designer.cs generated from .resx.
  • Remove them from the project and disk.
    • Visual Studio: Right-click → Exclude From Project (or delete), then delete file from disk.
    • Ensure .csproj has no <Compile Include="...Designer.cs" />.
  • Add compile removal to protect against accidental inclusion:
<!-- In .csproj -->
<ItemGroup>
  <Compile Remove="**\*.Designer.cs" />
</ItemGroup>
  • Commit this change (no .gitignore yet; do that after this commit).

1) Enable localization services & middleware

  • Edit Program.cs:
// Program.cs (.NET 8)
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Options;
using System.Globalization;

var builder = WebApplication.CreateBuilder(args);

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

var app = builder.Build();

var supportedCultures = new[] { "en", "de" }; // extend as needed
var localizationOptions = new RequestLocalizationOptions()
    .SetDefaultCulture("en")
    .AddSupportedCultures(supportedCultures)
    .AddSupportedUICultures(supportedCultures);

app.UseRequestLocalization(localizationOptions);

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
}

app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();

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

app.Run();

Acceptance: App chooses translations based on Accept-Language header or explicit culture.


2) Resource file layout

  • Create Resources/ at the project root if it doesn’t exist.
  • Shared strings:
    • Resources/SharedResource.resx (neutral)
    • Resources/SharedResource.de.resx, Resources/SharedResource.en.resx, …
  • Per-type resources (optional), mirror namespaces and class names:
    • Resources/Controllers.HomeController.resx, Resources/Controllers.HomeController.de.resx, …
  • Ensure each .resx has Build Action = Embedded resource (default in SDK-style projects).
  • Add a marker class for shared resources:
// /Resources/SharedResource.cs
namespace MyApp.Resources;
public sealed class SharedResource { }

3) Replace designer usages

Before (designer style):

ViewBag.Title = Resources.MyStrings.PageTitle;           // property from Designer.cs
var msg = Resources.ValidationTexts.RequiredFieldError;  // property from Designer.cs

After (localizer style):

using Microsoft.Extensions.Localization;
using MyApp.Resources;

public class HomeController : Controller
{
    private readonly IStringLocalizer<SharedResource> _L;
    public HomeController(IStringLocalizer<SharedResource> localizer) => _L = localizer;

    public IActionResult Index()
    {
        ViewBag.Title = _L["PageTitle"];
        var msg = _L["RequiredFieldError"];
        return View();
    }
}

In Razor views:

@using MyApp.Resources
@inject Microsoft.Extensions.Localization.IViewLocalizer Localizer
<h1>@Localizer["PageTitle"]</h1>
<p>@Localizer["WelcomeText"]</p>

In non-MVC services:

using Microsoft.Extensions.Localization;
using MyApp.Resources;

public class MailSender
{
    private readonly IStringLocalizer<SharedResource> _L;
    public MailSender(IStringLocalizer<SharedResource> L) => _L = L;

    public Task SendWelcomeAsync(string email)
    {
        var subject = _L["WelcomeSubject"];
        var body = _L["WelcomeBody"];
        // ...
        return Task.CompletedTask;
    }
}
  • Grep-replace patterns like Resources.*. with _L["..."] / Localizer["..."].
  • Keep keys exactly as in the .resx “Name” column.

4) DataAnnotations & validation messages

Before:

[Required(ErrorMessageResourceType = typeof(Resources.ValidationTexts),
          ErrorMessageResourceName = "Required")]
public string Name { get; set; }

After (preferred):

// Use DataAnnotationsLocalization; provide resource overrides if needed
[Required]
public string Name { get; set; }
  • Ensure AddDataAnnotationsLocalization() is configured.
  • Provide keys like RequiredAttribute_ValidationError in Resources/ to override defaults, or inject IStringLocalizer in custom validators.

5) Tests (MSTest + NSubstitute)

  • Add MSTest + NSubstitute packages:
<!-- In test .csproj -->
<ItemGroup>
  <PackageReference Include="MSTest.TestAdapter" Version="3.6.0" />
  <PackageReference Include="MSTest.TestFramework" Version="3.6.0" />
  <PackageReference Include="NSubstitute" Version="5.1.0" />
  <PackageReference Include="Microsoft.Extensions.Localization" Version="$(MicrosoftExtensionsVersion)" />
  <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="$(MicrosoftExtensionsVersion)" />
</ItemGroup>
  • Example MSTest with culture switching:
using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MyApp.Resources;

namespace MyApp.Tests.Localization
{
    [TestClass]
    public class LocalizationSmokeTests
    {
        [TestMethod]
        public void SharedResource_ReturnsGerman_WhenUICultureIsDe()
        {
            var services = new ServiceCollection()
                .AddLocalization(o => o.ResourcesPath = "Resources")
                .BuildServiceProvider();

            var L = services.GetRequiredService<IStringLocalizer<SharedResource>>();

            var saved = CultureInfo.CurrentUICulture;
            try
            {
                CultureInfo.CurrentUICulture = new CultureInfo("de");
                Assert.AreEqual("Hallo", L["Hello"]); // adjust to your expected value
            }
            finally
            {
                CultureInfo.CurrentUICulture = saved;
            }
        }
    }
}
  • Example MSTest with NSubstitute:
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NSubstitute;
using Microsoft.Extensions.Localization;
using MyApp.Resources;

namespace MyApp.Tests.Localization
{
    public interface IGreeter
    {
        string Greet();
    }

    [TestClass]
    public class LocalizerConsumerTests
    {
        [TestMethod]
        public void Greeter_UsesLocalizerKey()
        {
            var L = Substitute.For<IStringLocalizer<SharedResource>>();
            L["Welcome"].Returns(new LocalizedString("Welcome", "Welcome (mock)"));

            var greeter = new Greeter(L);
            Assert.AreEqual("Welcome (mock)", greeter.Greet());
        }

        private sealed class Greeter : IGreeter
        {
            private readonly IStringLocalizer<SharedResource> _L;
            public Greeter(IStringLocalizer<SharedResource> L) => _L = L;
            public string Greet() => _L["Welcome"];
        }
    }
}

6) CI/CD updates

  • Ensure SDK-style project and default EmbeddedResource behavior for .resx.
  • Run MSTest tests for at least one non-default culture.
  • Add a guard that fails if any *.Designer.cs is present:
# scripts/verify-no-designer.sh
set -euo pipefail
if git ls-files '*.Designer.cs' | grep -q .; then
  echo "Error: *.Designer.cs files found in repo. Remove them."
  exit 1
fi

Invoke in CI before build/tests.


7) Add .gitignore rules (after deletion)

Do this after all designer files have been deleted and committed.

  • Update .gitignore:
# generated designer files (legacy)
*.Designer.cs
  • (Optional) Normalize line endings:
# .gitattributes
* text=auto eol=lf

Notes on resource scoping

  • IStringLocalizer<SharedResource> → single shared resource pool for the app.
  • IStringLocalizer<HomeController> → per-type resources (Resources/Controllers.HomeController.*.resx).
  • Mix both patterns as needed.

Optional: strongly-typed keys without Designer files

Consider a ResX Source Generator for compile-time safety without committing generated code.


Rollback plan

  • Re-introduce designer files by setting Custom Tool on .resx back to ResXFileCodeGenerator in Visual Studio, re-add *.Designer.cs to the project, and remove ignore rule.
  • Restore previous code paths referencing Resources.*.

Acceptance criteria

  • App builds without any *.Designer.cs in the repo.
  • All previously localized UI strings resolve via IStringLocalizer or IViewLocalizer.
  • Switching Accept-Language: de vs en returns localized content.
  • CI includes at least one culture test and fails if *.Designer.cs exists.
  • No runtime missing-resource exceptions for known keys.

Effort

  • S (if few usages) to M (if many references). Grep-replace + targeted fixes usually suffice.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions