Skip to content

Set Up Localization — i18n for 17 Cultures

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).

Localization keys use PascalCase segments separated by . (dots) for hierarchy, with an optional : (colon) namespace prefix for resource scoping (e.g., Showcase:Admin:Users.Columns.Email). Two-letter acronyms stay uppercase (AI, ID); three or more letters use PascalCase (Api, Url).

See the key naming convention reference for the full specification including standard sections, shared key namespaces, and complete examples.

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. Positional placeholders ({0}, {1}) and built-in formatters (plural, conditional) are supported. Reflection-based member access ({0.Name}) is disabled for security — use positional arguments only.

{
"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/{version}/localization/overridesList overrides by resource and culture
PUT/api/{version}/localization/overrides/{resource}/{culture}/{key}Create or update an override
DELETE/api/{version}/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.