Skip to content

Localization — 17 Cultures & CLDR Plurals

.NET’s built-in IStringLocalizer works for simple cases, but it falls short in multi-tenant SaaS applications: there is no way for tenants to override translations without redeploying, no culture fallback chain, and .resx files are painful to manage at scale across dozens of cultures. Magic string keys like L["Invoices.Status.Paid"] compile fine but fail silently at runtime when a key is missing or renamed.

Granit.Localization solves these problems with a layered architecture: JSON resource files are embedded per module and auto-discovered at startup (no registration boilerplate). Tenants can override any translation at runtime through a database-backed store, cached in-memory with per-tenant isolation. A Roslyn source generator produces strongly-typed key constants at build time — misspelled keys become compile errors, not runtime blanks. The system ships with 17 cultures (14 base + 3 regional), so your application is international from the first commit.

  • DirectoryGranit.Localization/ Core abstractions, JSON resource loading, auto-discovery, override caching
    • Granit.Localization.AI AI-powered translation suggestions via LLM
    • Granit.Localization.EntityFrameworkCore EF Core persistence for translation overrides
    • Granit.Localization.Endpoints Minimal API (SPA bootstrapping + override CRUD)
    • Granit.Localization.SourceGenerator Roslyn incremental generator for type-safe keys
PackageRoleDepends on
Granit.LocalizationGranitLocalizationModule, JSON localizer, override store abstractionsGranit
Granit.Localization.AIITranslationSuggestionService, LLM-based translation suggestionsGranit.Localization, Granit.AI
Granit.Localization.EntityFrameworkCoreLocalizationDbContext, EfCoreLocalizationOverrideStoreGranit.Localization, Granit.Persistence
Granit.Localization.EndpointsAnonymous SPA endpoint, override CRUD endpointsGranit.Localization, Granit.Authorization
Granit.Localization.SourceGeneratorBuild-time LocalizationKeys class generation(analyzer, no runtime dependency)
graph TD
    L[Granit.Localization] --> CO[Granit]
    AI[Granit.Localization.AI] --> L
    AI --> AIM[Granit.AI]
    EF[Granit.Localization.EntityFrameworkCore] --> L
    EF --> P[Granit.Persistence]
    EP[Granit.Localization.Endpoints] --> L
    EP --> A[Granit.Authorization]
    SG[Granit.Localization.SourceGenerator] -.->|build-time| L
[DependsOn(typeof(GranitLocalizationModule))]
public class AppModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.Configure<GranitLocalizationOptions>(options =>
{
// Register your application resource
options.Resources
.Add<AcmeLocalizationResource>("fr")
.AddJson(
typeof(AcmeLocalizationResource).Assembly,
"Acme.App.Localization.Acme");
// Add supported languages
options.Languages.Add(new LanguageInfo("nl", "Nederlands", "nl"));
options.Languages.Add(new LanguageInfo("de", "Deutsch", "de"));
});
}
}

Default languages registered by the module: fr, fr-CA, en (default), en-GB.

Each module registers a marker type and its embedded JSON files via GranitLocalizationOptions.Resources:

options.Resources
.Add<AcmeLocalizationResource>("fr") // default culture = "fr"
.AddJson(
typeof(AcmeLocalizationResource).Assembly,
"Acme.App.Localization.Acme") // embedded resource prefix
.AddBaseTypes(typeof(GranitLocalizationResource)); // inherit Granit keys

The LocalizationResourceStore holds all registered resources. Each entry is a LocalizationResourceInfo with:

PropertyDescription
ResourceTypeMarker type (empty class)
DefaultCultureFallback culture (e.g. "fr")
BaseTypesParent resources for key inheritance
JsonSourcesEmbedded JSON file locations

When EnableAutoDiscovery = true (default), loaded module assemblies are scanned for types decorated with [LocalizationResourceName]. Their embedded JSON files are registered automatically without explicit AddJson() calls.

Files follow the Granit convention: { "culture": "...", "texts": { ... } }. Nested keys are flattened with . separators.

Localization/Acme/en.json
{
"culture": "en",
"texts": {
"Patient": {
"Created": "Patient {0} created successfully.",
"NotFound": "Patient not found."
},
"Validation": {
"NissRequired": "National identification number is required."
}
}
}

Usage in code:

public class PatientHandler(IStringLocalizer<AcmeLocalizationResource> l)
{
public string GetMessage(string name) =>
l["Patient.Created", name];
}

Granit localization keys follow a PascalCase, dot-separated structure with an optional colon-separated namespace prefix:

[Prefix:]Feature.Section.Key
SeparatorRoleExample
: colonNamespace boundary (resource scope)Showcase:Admin:, Granit:Validation:
. dotHierarchy within a scopeUsers.Columns.Email

Backend JSON files include the full prefix:

Localization/ShowcaseAdmin/en.json
{
"culture": "en",
"texts": {
"Showcase:Admin:Users.Title": "User Management",
"Showcase:Admin:Users.Columns.Email": "Email"
}
}

All key segments use PascalCase, following Microsoft .NET naming guidelines for acronyms:

RuleExampleCounter-example
Standard words → PascalCaseUsers, Columns, Titleusers, columns
Acronyms ≤ 2 letters → ALL CAPSAI, UI, IDAi, Ui, Id
Acronyms ≥ 3 letters → PascalCaseApi, Url, HtmlAPI, URL
Compound words → PascalCaseApiKeys, BackgroundJobsAPIKeys, BGJobs

Each application defines its own prefix using the {App}:{Scope}: pattern:

ApplicationPrefixExample key
Granit frameworkGranit:Granit:EntityNotFound
Granit validationGranit:Validation:Granit:Validation:NotEmptyValidator
Granit permissionsPermission: / PermissionGroup:Permission:Settings.Global.Read
Guava adminGuava:Admin:Guava:Admin:Users.Title
Showcase adminShowcase:Admin:Showcase:Admin:Auth.Login
Webhook eventsWebhookEventType:WebhookEventType:order.created

For admin frontend applications, keys are organized by feature (matching the frontend feature module structure):

{Prefix}:{Feature}.{Section}.{Key}

Standard sections:

SectionPurposeExample
TitlePage titleUsers.Title → “User Management”
SubtitlePage descriptionUsers.Subtitle → “Manage platform users…”
SearchPlaceholderSearch input placeholderUsers.SearchPlaceholder → “Search by name…”
Columns.*Table column headersUsers.Columns.Email → “Email”
Form.*Form field labelsCountries.Form.Alpha3 → “Alpha-3 Code”
Actions.*Button/action labelsCountries.Actions.Deactivate → “Deactivate”
Status.*Status/enum valuesUsers.Status.Enabled → “Enabled”
Filters.*Filter labelsCountries.Filters.Region → “Region”
Detail.*Detail page elementsUsers.Detail.BackToUsers → “Back to users”
*Dialog.*Dialog title/messageCountries.DeactivateDialog.Title → “Deactivate Country”
*SuccessToast success messageCountries.SaveSuccess → “Country saved successfully”
*ErrorToast error messageCountries.SaveError → “Failed to save country”

Keys shared across features use these namespaces:

NamespaceScopeExamples
Common.*Buttons, labels reused everywhereCommon.Save, Common.Cancel, Common.Close
Navigation.*Sidebar menu itemsNavigation.Users, Navigation.Countries
Navigation.Groups.*Sidebar group headingsNavigation.Groups.Identity
Auth.*Authentication flowAuth.Login, Auth.AccessDenied
Header.* / Footer.*App chromeHeader.AdminConsole
Components.*Reusable UI componentsComponents.Querying.Columns.Label
Localization/ShowcaseAdmin/en.json
{
"culture": "en",
"texts": {
"Showcase:Admin:Common.AppName": "Granit Showcase Admin",
"Showcase:Admin:Common.Save": "Save",
"Showcase:Admin:Common.Cancel": "Cancel",
"Showcase:Admin:Navigation.Users": "Users",
"Showcase:Admin:Navigation.Groups.AI": "Artificial Intelligence",
"Showcase:Admin:Auth.Login": "Sign in",
"Showcase:Admin:Auth.LoginPage.Subtitle": "Sign in with your administrator account.",
"Showcase:Admin:Users.Title": "User Management",
"Showcase:Admin:Users.Columns.Name": "Name",
"Showcase:Admin:Users.Columns.Email": "Email",
"Showcase:Admin:Users.Status.Enabled": "Enabled",
"Showcase:Admin:Users.Status.Disabled": "Disabled",
"Showcase:Admin:Users.Actions.Enable": "Enable user",
"Showcase:Admin:Users.SaveSuccess": "User saved successfully",
"Showcase:Admin:Countries.Form.Code": "Alpha-2 Code",
"Showcase:Admin:Countries.DeactivateDialog.Title": "Deactivate Country",
"Showcase:Admin:AI.Workspaces.Title": "AI Workspaces"
}
}
Frontend usage
import { useTranslation } from '@granit/react-localization';
function UsersPage() {
const { t } = useTranslation();
return (
<>
<h1>{t('Users.Title')}</h1>
<p>{t('Users.Subtitle')}</p>
</>
);
}

Available languages are declared via GranitLocalizationOptions.Languages:

new LanguageInfo(
cultureName: "fr-CA",
displayName: "Francais (Canada)",
flagIcon: "ca",
isDefault: false)
PropertyDescription
CultureNameBCP 47 culture code ("fr", "en-GB")
DisplayNameUI-friendly name
FlagIconOptional icon identifier for the language selector
IsDefaultPre-selected language in the UI

The Languages list also drives SupportedUICultures for ASP.NET Core request localization (UseGranitRequestLocalization).

Overrides allow administrators to customize translations at runtime without redeploying. They are stored per resource, per culture, per tenant.

public interface ILocalizationOverrideStoreReader
{
Task<IReadOnlyDictionary<string, string>> GetOverridesAsync(
string resourceName, string culture,
CancellationToken cancellationToken = default);
}
public interface ILocalizationOverrideStoreWriter
{
Task SetOverrideAsync(
string resourceName, string culture, string key, string value,
CancellationToken cancellationToken = default);
Task RemoveOverrideAsync(
string resourceName, string culture, string key,
CancellationToken cancellationToken = default);
}

The CachedLocalizationOverrideStore wraps the EF Core store with a FusionCache layer (L1 memory + optional L2 distributed cache + backplane). Cache keys are tenant-scoped (localization:{tenantId}:{resource}:{culture}) and invalidated on every write.

sequenceDiagram
    participant L as IStringLocalizer
    participant C as CachedLocalizationOverrideStore
    participant FC as IFusionCache
    participant EF as EfCoreLocalizationOverrideStore

    L->>C: GetOverridesAsync("Acme", "fr")
    C->>FC: TryGet(cacheKey)
    alt Cache hit
        FC-->>C: overrides
    else Cache miss
        C->>EF: GetOverridesAsync("Acme", "fr")
        EF-->>C: overrides
        C->>FC: SetAsync(cacheKey, overrides, TTL)
    end
    C-->>L: overrides

GET /localization?cultureName=fr returns all registered resources for the requested culture, plus the list of available languages. This endpoint is anonymous and includes cache headers (Cache-Control: public, max-age=3600, Vary: Accept-Language).

{
"currentCulture": "fr",
"resources": {
"Acme": {
"Patient.Created": "Patient {0} cree avec succes.",
"Patient.NotFound": "Patient introuvable."
},
"Granit": {
"EntityNotFound": "Entite introuvable."
}
},
"languages": [
{ "cultureName": "fr", "displayName": "Francais (France)", "flagIcon": "fr", "isDefault": false },
{ "cultureName": "en", "displayName": "English (United States)", "flagIcon": "us", "isDefault": true }
]
}

All override endpoints require the Localization.Overrides.Manage permission.

MethodRouteDescription
GET/localization/overrides?resourceName=X&cultureName=frList overrides for a resource/culture
PUT/localization/overrides/{resourceName}/{cultureName}/{key}Create or update an override
DELETE/localization/overrides/{resourceName}/{cultureName}/{key}Remove an override

If no ILocalizationOverrideStoreReader/Writer is registered (EF Core package not installed), all override endpoints return 501 Not Implemented.

The Granit.Localization.SourceGenerator package provides a Roslyn incremental generator that reads JSON localization files declared as <AdditionalFiles> and produces a LocalizationKeys class with nested constant string fields.

Acme.App.csproj
<ItemGroup>
<ProjectReference Include="..\Granit.Localization.SourceGenerator\Granit.Localization.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Localization\Acme\en.json" />
</ItemGroup>

Given the JSON file from the example above, the generator produces:

LocalizationKeys.g.cs (auto-generated)
public static class LocalizationKeys
{
public static class Patient
{
public const string Created = "Patient.Created";
public const string NotFound = "Patient.NotFound";
}
public static class Validation
{
public const string NissRequired = "Validation.NissRequired";
}
}

Usage with IStringLocalizer:

string message = localizer[LocalizationKeys.Patient.Created, patientName];

Keys with the Resource:Key convention (e.g., "Granit:EntityNotFound") produce nested classes matching the resource prefix.

CategoryKey typesPackage
ModuleGranitLocalizationModule, GranitLocalizationAIModule, GranitLocalizationEntityFrameworkCoreModule, GranitLocalizationEndpointsModule
CoreLocalizationResourceStore, LocalizationResourceInfo, LanguageInfoGranit.Localization
OptionsGranitLocalizationOptions (EnableAutoDiscovery, Resources, Languages, FormattingCultures)Granit.Localization
AbstractionsILocalizationOverrideStoreReader, ILocalizationOverrideStoreWriterGranit.Localization
CachingCachedLocalizationOverrideStore (internal, FusionCache L1+L2)Granit.Localization
AIITranslationSuggestionService, TranslationSuggestion, TranslationContextGranit.Localization.AI
EF CoreLocalizationDbContext, EfCoreLocalizationOverrideStore (internal)Granit.Localization.EntityFrameworkCore
EndpointsMapGranitLocalization(), MapGranitLocalizationOverrides()Granit.Localization.Endpoints
GeneratorLocalizationKeysGeneratorGranit.Localization.SourceGenerator

Use Granit.Localization when:

  • Your application supports multiple languages (even just 2 — the investment pays off early)
  • Tenants need to customize translations at runtime without redeployment
  • You want compile-time safety on localization keys (source generator catches typos)
  • You need culture fallback chains (fr-CA → fr → en) with module-scoped resource isolation

Skip it when:

  • Your app is single-language with no plans for i18n — IStringLocalizer adds ceremony for no benefit
  • You only need to localize validation error messages — FluentValidation’s built-in .WithMessage() with resource files may be enough
  • Your localization needs are frontend-only (React i18n) — use the frontend SDK instead