Skip to content

Localization

Cargonerds uses ABP's resource-based localization. Display strings live in per-culture JSON files that are compiled into the assembly as embedded resources, served at runtime through the ABP Virtual File System, and resolved through a typed localization resource marker class. Translations for the non-English cultures are produced automatically by the ResourceTranslator.CLI dotnet tool, wired into an MSBuild target that runs on Debug builds.

This page covers the server-side (ABP) localization used by the Blazor app, AuthServer and the public web. The separate Next.js realtime frontend has its own next-intl stack — see Realtime (Next.js) Frontend.

Concept: how ABP localization works

ABP localization is built around three pieces:

  • A resource — a marker class decorated with [LocalizationResourceName] that gives a logical name to a bundle of strings (e.g. Cargonerds). Code asks for strings through IStringLocalizer<TResource>.
  • One JSON file per culture (en.json, de-DE.json, …) under a folder, each containing a culture field and a Texts dictionary of key → value.
  • A registration in AbpLocalizationOptions that ties the resource to its default culture, its JSON folder (via the virtual file system) and any base types it inherits keys from.

Resources can inherit from other resources (AddBaseTypes). When a key is not found in a resource, ABP falls through to its base types — this is how the app reuses ABP framework strings (validation, UI) and how it overrides another module's strings.

The resources in this solution

The host app owns CargonerdsResource; the feature modules own their own resources. Each is a tiny marker class plus a folder of JSON.

Resource Marker class JSON folder Registered in
Cargonerds src/Cargonerds.Domain.Shared/Localization/CargonerdsResource.cs src/Cargonerds.Domain.Shared/Localization/Cargonerds/ CargonerdsDomainSharedModule
Hub modules/hub/src/Hub.Domain.Shared/Localization/HubResource.cs modules/hub/src/Hub.Domain.Shared/Localization/Hub/ HubDomainSharedModule
CodeEntities .../Hub.Domain.Shared/Localization/CodeEntitiesResource.cs .../Localization/CodeEntities/ HubDomainSharedModule
Realtime .../Hub.Domain.Shared/Localization/RealtimeResource.cs .../Localization/Realtime/ HubDomainSharedModule
Pricing modules/pricing/src/Pricing.Domain.Shared/Localization/PricingResource.cs .../Pricing.Domain.Shared/Localization/Pricing/ PricingDomainSharedModule

The marker class is intentionally empty:

src/Cargonerds.Domain.Shared/Localization/CargonerdsResource.cs
using Volo.Abp.Localization;

namespace Cargonerds.Localization;

[LocalizationResourceName("Cargonerds")]
public class CargonerdsResource { }

Note

Resources are always declared in the *.Domain.Shared layer because every other layer (Domain, Application, HttpApi, Blazor) references it, so all of them can take a dependency on IStringLocalizer<CargonerdsResource>. See Layered Architecture.

Resource registration (CargonerdsResource)

The primary resource is registered in src/Cargonerds.Domain.Shared/CargonerdsDomainSharedModule.cs. Three things happen there: the embedded JSON file set is registered with the VFS, the resource is added to AbpLocalizationOptions, and the selectable languages are declared.

CargonerdsDomainSharedModule.ConfigureServices (excerpt)
Configure<AbpVirtualFileSystemOptions>(options =>
{
    options.FileSets.AddEmbedded<CargonerdsDomainSharedModule>();
});

Configure<AbpLocalizationOptions>(options =>
{
    options
        .Resources.Add<CargonerdsResource>("en")          // default culture = en
        .AddBaseTypes(typeof(AbpValidationResource))      // fall through to ABP validation texts
        .AddVirtualJson("/Localization/Cargonerds");      // VFS folder of *.json

    options.DefaultResourceType = typeof(CargonerdsResource);

    options.Languages.Add(new LanguageInfo("en", "en", "English"));
    options.Languages.Add(new LanguageInfo("de-DE", "de-DE", "Deutsch (Deutschland)"));
    // ... 18 more
});

Key points:

  • Default culture is en. en.json is the source of truth — every key is authored there first, and the translation tool generates the rest from it.
  • DefaultResourceType makes CargonerdsResource the fallback resource for the whole app, so an injected IStringLocalizer (non-generic) and view localization resolve against it.
  • AddVirtualJson("/Localization/Cargonerds") points at a folder in the virtual file system, not a physical path. The JSON files are embedded resources (see Virtual file system & embedded resources).
  • AddBaseTypes(typeof(AbpValidationResource)) lets validation messages resolve through the Cargonerds resource. Other layers add more base types (see next section).

Supported languages

The LanguageInfo entries registered in CargonerdsDomainSharedModule are the languages that appear in the UI language switcher. There are 20:

Culture Display name Culture Display name
en English it Italiano
en-GB English (United Kingdom) cs Čeština
de-DE Deutsch (Deutschland) hu Magyar
fr Français ro-RO Română (România)
es Español sv Svenska
pt-BR Português (Brasil) fi Suomi
ru Русский sk Slovenčina
tr Türkçe is Íslenska
ar العربية zh-Hans 简体中文
hi हिन्दी zh-Hant 繁體中文

JSON files exist for cultures that are not selectable

The Localization/Cargonerds folder ships 25 culture files — five more than are registered as LanguageInfo: hr, nl, pl-PL, sl, vi. These are produced by the translation tool (its TargetCultures list is wider than the registered languages) but are not added to options.Languages, so they exist on disk but never appear in the language switcher. A culture only becomes selectable once it is added to the Languages list above.

zh-Hans / zh-Hant are registered but not auto-translated

Simplified and Traditional Chinese are registered languages, but they are not in the translator's TargetCultures, so the tool will not regenerate them. Their JSON must be maintained by hand — when you add a key, remember to add the Chinese values manually.

Extending and overriding resources in other layers

CargonerdsResource is registered in Domain.Shared with only AbpValidationResource as a base type. Later layers add more base types and override folders so that the right framework strings are in scope for each host.

graph TD
    AVR[AbpValidationResource]
    AUR[AbpUiResource]
    ACR[AccountResource]
    AO["/Localization/AccountOverrides<br/>(VFS folder)"]
    CR[CargonerdsResource]

    AVR -->|base type, Domain.Shared| CR
    AUR -->|base type, HttpApi + Blazor.Client + AuthServer| CR
    ACR -->|base type, AuthServer only| CR
    AO -.->|AddVirtualJson onto AccountResource| ACR

    classDef abp fill:#eef,stroke:#88a;
    class AVR,AUR,ACR abp;
Layer / module What it adds File
Cargonerds.HttpApi AddBaseTypes(AbpUiResource) on CargonerdsResource src/Cargonerds.HttpApi/CargonerdsHttpApiModule.cs
Cargonerds.Blazor.Client AddBaseTypes(AbpUiResource) on CargonerdsResource src/Cargonerds.Blazor.Client/CargonerdsBlazorClientModule.cs (ConfigureLocalization)
Cargonerds.AuthServer AddBaseTypes(AbpUiResource, AccountResource) and Account text overrides src/Cargonerds.AuthServer/CargonerdsAuthServerModule.cs
Cargonerds.Web.Public DataAnnotations → CargonerdsResource src/Cargonerds.Web.Public/CargonerdsWebPublicModule.cs

Account text overrides

The AuthServer overrides selected texts of ABP's Account module by layering a JSON folder onto the existing AccountResource — a clean way to change a few framework strings without forking the module:

CargonerdsAuthServerModule.ConfigureServices (excerpt)
Configure<AbpLocalizationOptions>(options =>
{
    options.Resources.Get<CargonerdsResource>().AddBaseTypes(typeof(AbpUiResource), typeof(AccountResource));
    options.Resources.Get<AccountResource>().AddVirtualJson("/Localization/AccountOverrides");
});

The override files live in src/Cargonerds.Domain.Shared/Localization/AccountOverrides/ and exist for three cultures only — en, en-GB, de-DE:

Localization/AccountOverrides/en.json (excerpt)
{
  "culture": "en",
  "texts": {
    "NotAMemberYet": "No account yet?",
    "Register": "Sign up",
    "CompleteRegistration": "Complete Registration"
  }
}

Because these are merged onto AccountResource, the keys must match ABP's own Account keys; any key present here wins over the one shipped by the Account module for that culture.

DataAnnotations (Web.Public)

The public web maps [Required]/[StringLength]/etc. validation messages to the resource via AbpMvcDataAnnotationsLocalizationOptions:

CargonerdsWebPublicModule.PreConfigureServices (excerpt)
context.Services.PreConfigure<AbpMvcDataAnnotationsLocalizationOptions>(options =>
{
    options.AddAssemblyResource(
        typeof(CargonerdsResource),
        typeof(CargonerdsDomainSharedModule).Assembly,
        typeof(CargonerdsApplicationContractsModule).Assembly,
        /* ... */);
});

JSON file shape and key conventions

Every culture file has a culture field and a Texts dictionary:

Localization/Cargonerds/en.json (excerpt)
{
  "culture": "en",
  "Texts": {
    "Actions": "Actions",
    "ActiveShipments": "Active Shipments",
    "AddEntity": "Add {0}",
    "AllowedRange": "Allowed range: {0}–{1}",
    "ApiKeyDeletionConfirmationMessage": "Are you sure you want to delete the API key '{0}'?",
    "AppName": "Cargonerds"
  }
}

Conventions used across the codebase:

  • Grouped keys use a Group:Key form, e.g. Booking:AddContainer, Menu:Dashboard, Permission:ApiKeys:Create, ContainerMode:9. The colons are just part of the key string.
  • Placeholders are positional ({0}, {1}) or named ({Name}); ABP fills them from the arguments you pass to the localizer.
  • Keys are kept alphabetically sorted — the translation tool re-sorts on every run (AutoSort: true), which keeps diffs small.

Casing inconsistency: Texts vs texts

The Cargonerds, Hub and Pricing files use "Texts" (capital T) while the AccountOverrides files use "texts" (lowercase). ABP's JSON localizer tolerates both, but be aware of the difference when copy-pasting between folders.

Adding a key

  1. Add the key/value to the source en.json of the relevant resource folder (Localization/Cargonerds/en.json for app strings, or Hub's Localization/Hub/en.json, etc.):
{
  "culture": "en",
  "Texts": {
    "MyNewKey": "My new text"
  }
}
  1. On the next Debug build, the Translate MSBuild target auto-generates the value into all target cultures. Review and commit the changed JSON files. (Remember zh-Hans/zh-Hant are not auto-translated — fill them by hand.)

  2. Consume the key:

C# / Razor code-behind
public class Foo(IStringLocalizer<CargonerdsResource> l)
{
    public string Title => l["MyNewKey"];
    public string Added => l["AddEntity", "Container"]; // -> "Add Container"
}
Blazor .razor component
@inject IStringLocalizer<CargonerdsResource> L
<h3>@L["MyNewKey"]</h3>

In ABP Blazor base components an L property is already provided, so existing components use L["Booking:Submit"] directly.

Adding a language

  1. Add a JSON file named after the culture (e.g. nl.json) under src/Cargonerds.Domain.Shared/Localization/Cargonerds/ (one already exists for several "produced but unregistered" cultures — see the note above).
  2. Register it in CargonerdsDomainSharedModule.ConfigureServices:
options.Languages.Add(new LanguageInfo("nl", "nl", "Nederlands"));
  1. Add the culture to TargetCultures in translation.options.json if it should be auto-translated, and add it to the other resource folders (Hub, Pricing) as needed.
  2. Add Account-specific overrides under Localization/AccountOverrides/ only if you need to change Account texts for that culture.

Translation pipeline (ResourceTranslator.CLI)

Non-English files are generated by the ResourceTranslator.CLI global dotnet tool, which calls Azure Cognitive Services Translator. Each *.Domain.Shared project carries an identical translation.options.json:

src/Cargonerds.Domain.Shared/translation.options.json
{
  "TextTranslationEndpoint": "https://api.cognitive.microsofttranslator.com/",
  "ApiKey": "…",
  "FileOutputFormat": "{Culture}.{Extension}",
  "TargetCultures": "ar,cs,de-DE,en,en-GB,es,fi,fr,hi,hr,hu,is,it,pt-BR,nl,pl-PL,ro-RO,ru,sk,sv,sl,tr,vi",
  "Region": "germanywestcentral",
  "Encoding": "utf-8",
  "AutoSort": true
}

The tool is invoked from a Translate MSBuild target in each *.Domain.Shared.csproj. It installs (or updates) the global tool, then runs the translator once per resource folder, always against that folder's en.json source:

src/Cargonerds.Domain.Shared/Cargonerds.Domain.Shared.csproj
<Target Name="Translate" AfterTargets="Build" Condition="'$(Configuration)' == 'Debug'">
  <Exec IgnoreExitCode="true" Command="dotnet tool install --global ResourceTranslator.CLI" />
  <Exec IgnoreExitCode="true" Command="dotnet tool update --global ResourceTranslator.CLI" />
  <Exec IgnoreExitCode="true"
        Command="resourceTranslator --optionsfile $(ProjectDir)translation.options.json -f $(ProjectDir)Localization\Cargonerds\en.json" />
</Target>

The Hub project (which owns three resources) has three resourceTranslator <Exec> lines — one for Localization\Hub\en.json, Localization\CodeEntities\en.json and Localization\Realtime\en.json.

Tip

TargetCultures is deliberately wider than the registered LanguageInfo list, which is why files like nl.json, pl-PL.json, hr.json, sl.json, vi.json exist but are not selectable. To stop generating a culture, remove it from TargetCultures; to make a generated culture selectable, add a LanguageInfo.

The translation target is fragile by design

  • It runs only on Debug builds, so a Release/CI build never refreshes translations.
  • Every Debug build runs dotnet tool install/update --global — the first build needs network access.
  • Every <Exec> uses IgnoreExitCode="true", so a translator or network failure silently leaves translations stale and never breaks the build. If a new key only shows up in en, suspect a failed translate step.
  • ApiKey is a plaintext Azure Translator key committed to the repo (in three copies). It should be rotated and moved to a secret store. See Configuration.

Virtual file system & embedded resources

The JSON files are compiled as embedded resources, not content files, so they ship inside the assembly and are served through the virtual file system rather than from disk:

Cargonerds.Domain.Shared.csproj (excerpt)
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
<!-- ... -->
<EmbeddedResource Include="Localization\Cargonerds\*.json" />
<Content Remove="Localization\Cargonerds\*.json" />
<EmbeddedResource Include="Localization\AccountOverrides\*.json" />
<Content Remove="Localization\AccountOverrides\*.json" />

The module then registers its embedded file set so AddVirtualJson("/Localization/Cargonerds") can resolve against it:

Configure<AbpVirtualFileSystemOptions>(options =>
    options.FileSets.AddEmbedded<CargonerdsDomainSharedModule>());

Live editing in development

Because the files are embedded, changing a JSON value would normally require a rebuild. In development the MVC hosts swap the embedded set for the physical folder so edits are picked up without recompiling:

CargonerdsAuthServerModule (Development only)
options.FileSets.ReplaceEmbeddedByPhysical<CargonerdsDomainSharedModule>(
    Path.Combine(env.ContentRootPath, "..", "Cargonerds.Domain.Shared"));

CargonerdsWebPublicModule does the same. See the Virtual file system docs for the AddEmbedded / ReplaceEmbeddedByPhysical pattern.

Runtime culture selection

Host Mechanism
Cargonerds.AuthServer (MVC) app.UseAbpRequestLocalization() in OnApplicationInitialization
Cargonerds.Web.Public (MVC) app.UseAbpRequestLocalization() in OnApplicationInitialization
Cargonerds.Blazor / .Client (WASM) LeptonX language menu + ABP LanguageManagement module; culture flows through the framework cookie

UseAbpRequestLocalization() wires ABP's request-localization pipeline (culture from the .AspNetCore.Culture cookie, the Accept-Language header, or a query string) from the registered LanguageInfo list. The Blazor WebAssembly client has no UseAbpRequestLocalization call (it is a client app); it depends on the ABP LanguageManagement module — LanguageManagementDomainSharedModule in Domain.Shared and LanguageManagementBlazorWebAssemblyModule in CargonerdsBlazorClientModule — and the LeptonX language switcher, with the selected culture carried in the framework's culture cookie.

Exception (error-code) localization

Business exceptions throw with an error code like Cargonerds:ApiKeys:NameAlreadyExists. ABP maps the namespace prefix of that code to a resource so the message can be localized. Each module wires its own namespaces via AbpExceptionLocalizationOptions.MapCodeNamespace:

CargonerdsDomainSharedModule.ConfigureServices
Configure<AbpExceptionLocalizationOptions>(options =>
{
    options.MapCodeNamespace("Cargonerds", typeof(CargonerdsResource));
});

The Hub module maps the Hub and CodeEntities namespaces to HubResource / CodeEntitiesResource; Pricing maps Pricing to PricingResource. The text for an error code is just a normal localization key (the part after the namespace) in the mapped resource's JSON. See the Error Codes Reference.

Gotchas

Neutral vs specific culture: de.json vs de-DE.json

The Cargonerds resource ships only de-DE.json (no neutral de.json), and de-DE is the registered language — so it is clean. The Hub and Pricing resources, however, each ship both de.json and de-DE.json. Only de-DE is registered and only de-DE is in TargetCultures, so the neutral de.json files are stale leftovers that the translator no longer regenerates, yet they can still resolve for a bare de request and may diverge from de-DE. (Recent git history shows related neutral-culture bugs in CountryLookup — the same de vs de-DE footgun.)

  • TargetCultures ⊋ registered languages. The translator emits hr, nl, pl-PL, sl, vi, but they are not registered as LanguageInfo, so they are dead weight on disk. Conversely zh-Hans/zh-Hant are registered but not in TargetCultures, so their JSON must be hand-maintained.
  • Committed translator API key. translation.options.json (three copies) carries a plaintext Azure Translator key — rotate it and move it to a secret.
  • Casing inconsistency. Texts (Cargonerds/Hub/Pricing) vs texts (AccountOverrides). ABP tolerates both.
  • Translate is Debug-only, self-installing, and failure-silent. A missing key in non-English cultures usually means a failed (but ignored) translate step.