Skip to content

Set up localization

Granit.Localization provides a modular JSON-based localization system that integrates with IStringLocalizer<T>. Each package embeds its own translations, supports culture fallback via CultureInfo.Parent, and allows runtime overrides from the database without redeployment.

  • A .NET 10 project with Granit module system configured
  • Familiarity with IStringLocalizer<T> from Microsoft.Extensions.Localization
  • For runtime overrides: a PostgreSQL (or other EF Core-supported) database
Terminal window
# Core localization
dotnet add package Granit.Localization
# Source-generated type-safe keys (optional but recommended)
dotnet add package Granit.Localization.SourceGenerator
# EF Core override store (optional -- runtime corrections)
dotnet add package Granit.Localization.EntityFrameworkCore
# HTTP endpoint for SPA clients (optional)
dotnet add package Granit.Localization.Endpoints

Each JSON file contains translations for a single culture. Place them under Localization/{ResourceName}/:

src/MyApp/
Localization/
MyApp/
en.json
en-GB.json
fr.json
fr-CA.json
nl.json
de.json
es.json
it.json
pt.json
pt-BR.json
zh.json
ja.json
pl.json
tr.json
ko.json
sv.json
cs.json
{
"culture": "en",
"texts": {
"Patient:NotFound": "Patient not found.",
"Validation": {
"Required": "This field is required.",
"MaxLength": "Maximum {0} characters."
}
}
}

Regional files (fr-CA.json, en-GB.json, pt-BR.json) should only contain keys that differ from the base language. The .NET native fallback (CultureInfo.Parent) resolves automatically: fr-CA -> fr -> default culture.

There is no need for an en-US.json file since en.json is already US English (native fallback: en-US -> en).

Mark the files as embedded resources in your .csproj:

<ItemGroup>
<EmbeddedResource Include="Localization\**\*.json" />
</ItemGroup>

Each localization resource is represented by an empty marker class:

using Granit.Localization.Attributes;
[LocalizationResourceName("MyApp")]
[InheritResource(typeof(GranitLocalizationResource))]
public sealed class MyAppResource;

The [InheritResource] attribute lets your app inherit base error messages from GranitLocalizationResource (Granit:EntityNotFound, Granit:ValidationError, etc.) without redefining them.

[DependsOn(typeof(GranitLocalizationModule))]
public sealed class MyAppModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.Configure<GranitLocalizationOptions>(options =>
{
options.Resources
.Add<MyAppResource>(defaultCulture: "en")
.AddJson(
typeof(MyAppModule).Assembly,
"MyApp.Localization.MyApp")
.AddBaseTypes(typeof(GranitLocalizationResource));
options.DefaultResourceType = typeof(MyAppResource);
options.Languages.Add(new LanguageInfo("en", "English", isDefault: true));
options.Languages.Add(new LanguageInfo("fr", "Francais"));
options.Languages.Add(new LanguageInfo("nl", "Nederlands"));
options.Languages.Add(new LanguageInfo("de", "Deutsch"));
});
}
}

Use Granit.Localization.Endpoints to auto-configure the ASP.NET Core RequestLocalizationMiddleware:

app.UseGranitRequestLocalization();

This reads the Languages list from GranitLocalizationOptions and sets SupportedCultures, SupportedUICultures, and DefaultRequestCulture accordingly.

Inject IStringLocalizer<T> in any service or handler:

public sealed class PatientService(IStringLocalizer<MyAppResource> localizer)
{
public string GetNotFoundMessage(Guid id)
=> localizer["Patient:NotFound", id];
}

Key resolution follows this order:

  1. Current UI culture (CultureInfo.CurrentUICulture) — e.g., fr-BE
  2. Parent culture (CultureInfo.Parent) — e.g., fr
  3. Ancestors up to CultureInfo.InvariantCulture
  4. Default culture of the resource (defaultCulture in Add<T>())
  5. Parent resources (inheritance chain)

SmartFormat.NET provides automatic pluralization based on CLDR rules:

{
"culture": "en",
"texts": {
"Files:Count": "{0:No file|One file|{} files}"
}
}
var zero = localizer["Files:Count", 0]; // "No file"
var one = localizer["Files:Count", 1]; // "One file"
var many = localizer["Files:Count", 42]; // "42 files"

The Granit.Localization.SourceGenerator package eliminates magic strings by generating compile-time constants from your JSON files.

Declare JSON files as AdditionalFiles in the consuming project’s .csproj:

<ItemGroup>
<AdditionalFiles Include="Localization/**/*.json" />
</ItemGroup>

From this JSON:

{
"culture": "en",
"texts": {
"Patient:NotFound": "Patient not found.",
"Patient:Created": "Patient {0} created."
}
}

The source generator produces:

public static class LocalizationKeys
{
public static class Patient
{
public const string NotFound = "Patient:NotFound";
public const string Created = "Patient:Created";
}
}

Use the constants with full IDE autocompletion:

// Before -- magic string
string message = localizer["Patient:NotFound"];
// After -- type-safe constant
string message = localizer[LocalizationKeys.Patient.NotFound];

Granit.Localization.EntityFrameworkCore enables runtime translation corrections without redeployment. Overrides are stored in PostgreSQL and cached in memory (5-minute TTL by default).

[DependsOn(typeof(GranitLocalizationEntityFrameworkCoreModule))]
public sealed class AppModule : GranitModule { }
builder.AddGranitLocalizationEntityFrameworkCore(options =>
options.UseNpgsql(connectionString));

Run the migration:

Terminal window
dotnet ef migrations add AddLocalizationOverrides
dotnet ef database update
public sealed class TranslationAdminService(
ILocalizationOverrideStoreReader storeReader,
ILocalizationOverrideStoreWriter storeWriter)
{
public async Task CorrectTranslationAsync(CancellationToken cancellationToken)
{
// Override "Patient" with "Beneficiary" for French
await storeWriter.SetOverrideAsync(
"MyApp", "fr", "Patient.Title", "Beneficiaire", cancellationToken);
}
public async Task RevertAsync(CancellationToken cancellationToken)
{
await storeWriter.RemoveOverrideAsync(
"MyApp", "fr", "Patient.Title", cancellationToken);
}
}

Granit.Localization.Endpoints exposes admin endpoints protected by Localization.Overrides.Manage:

MethodRouteDescription
GET/api/granit/localization/overridesList overrides by resource and culture
PUT/api/granit/localization/overrides/{resource}/{culture}/{key}Create or update an override
DELETE/api/granit/localization/overrides/{resource}/{culture}/{key}Remove an override
app.MapGranitLocalizationOverrides();

Overrides are isolated by tenant via ICurrentTenant. Each tenant sees only its own overrides. Host-level overrides (TenantId = null) apply globally.

Granit supports 17 cultures out of the box: 14 base languages plus 3 regional variants.

Base languages (14)Regional variants (3)
en, fr, nl, de, es, it, pt, zh, ja, pl, tr, ko, sv, csfr-CA, en-GB, pt-BR

Every src/*/Localization/**/*.json must exist for all 17 cultures. Regional files only contain keys that differ from the base.