A system sold into several regions needs its field captions, form names and dropdown option text to change with the language. Anyone who has done internationalization has handled this, and .NET's standard answer is .resx: strings go into a resource file, get compiled into satellite assemblies, and are resolved at run time from CultureInfo.CurrentUICulture.
bee-library did not take that route. A language resource is one of the thirteen kinds of definition file from The Purpose and Classification of Definition Files, sitting under the same root directory as FormSchema, layouts and menus, loaded, cached and overlaid by the same mechanism. Of everything in the customization layer the previous post described, this is the part used most.
This post covers:
- Why translations are not compiled into an assembly but put in a definition file
- What a language resource looks like, and how form captions line up with it
- What stands in when a translation cannot be found, and why the three fallback routes end differently
- Which end the overlay happens on, and how finely the cache is cut
1. Why localization is a definition file
"This field is called something else at our company" is a request an ERP gets over and over. If translations are compiled into an assembly, changing one word means a full build and release, which weighs the same as changing a piece of code.
This is not to say .resx is bad. Its model treats translations as part of an assembly, and the range that suits is quite clear: they travel with the version, they are displayed inside a .NET process, and one deployment has one set. The tooling is mature and access is strongly typed with compile time protection. The five things the framework wants all happen to fall outside that range:
| What the framework needs | Why it needs it |
|---|---|
| Translations can be swapped out on their own, with no rebuild and no new assembly | Changing one caption should not amount to shipping a release |
| Language and customization id are parameters on every call rather than process state | One process serves requests in different languages and different customizations at the same time |
| One customization covers a few keys and leaves the rest to the package | The per key overlay from the previous post |
| The front end and the back end share one resource file | Translations compiled into an assembly leave the front end maintaining a second copy, and two copies will differ sooner or later |
| What gets translated is not only strings but ordered option sets | Dropdown options need code and text in pairs, and the order carries meaning |
The first of those can be measured. With the case study's server running, change the value of one field caption in {DefinePath}/Language/zh-TW/Order.Language.xml, without rebuilding and without restarting, and the next fetch of that language resource comes back with the new word. The cache takes the file itself as the change signal, a route already laid down in Cross-Node Invalidation for Definition and Data Caches, and a language resource is one more kind of definition running on top of it.
The second is visible from the types alone. The service that resolves them holds no state per user, and both the language and the customization id are parameters (from ILanguageService.cs):
string GetLangText(string customizeId, string lang, string @namespace, string subKey); The position .resx fills on that side is CultureInfo.CurrentUICulture, an ambient value hanging off the thread. In a single user desktop application it is convenient. In a server process serving many sessions at once, every call has to set it correctly and set it back, and nothing at all will tell you which call you missed.
What was replaced is the storage, not the API surface
Not taking .resx does not mean giving up .NET's localization interface. The framework ships an adapter connecting the standard IStringLocalizer<T> to the language definition files (from BeeStringLocalizer.cs):
public sealed class BeeStringLocalizer<T> : IStringLocalizer<T> The type parameter's simple name is the namespace, so BeeStringLocalizer<Common> reads the Common resource.
Replacing the storage costs two things. A mistyped key is not caught by the compiler, which .resx would have caught, and here it is not visible until run time. And GetAllStrings returns an empty set, so a resource cannot be listed out, because the service underneath can only answer for one key at a time.
2. The shape of a language resource
The file path is {DefinePath}/Language/{lang}/{namespace}.Language.xml. One language and one namespace make one file. The namespace is a grouping you cut yourself, by form, by module or by feature, however it is easiest to manage, and the one shared across forms is conventionally called Common.
The content comes in two sections, extracted below from the framework's own Employee.Language.xml:
<LanguageResource Namespace="Employee" Lang="zh-TW"> <Items> <LanguageItem Key="Schema.DisplayName" Value="員工" /> <LanguageItem Key="Table.Employee.DisplayName" Value="員工" /> <LanguageItem Key="Field.sys_id.Caption" Value="員工編號" /> <LanguageItem Key="Field.ref_dept_name.Caption" Value="部門名稱" /> </Items> <Enums /> </LanguageResource> Items is text, keyed by sub-key, and Enums is the option sets. A complete lookup key is {namespace}.{subKey}, split at the first period, so a sub-key can contain periods of its own.
FormSchema lines up with a language resource through three sub-keys
What localizes a FormSchema is FormSchemaLocalizer, which takes the form's own ProgId as the namespace and then builds sub-keys by fixed convention. The whole mapping is three rules and one exception:
| What gets replaced | sub-key | Decided by |
|---|---|---|
| The form name | Schema.DisplayName | A fixed string |
| A table name | Table.{TableName}.DisplayName | The table name in the FormSchema |
| A field caption | Field.{FieldName}.Caption | The field name in the FormSchema |
| Dropdown option text | No sub-key; goes through FormField.LangEnumName | The option set named on the field |
The keys in the first three are all derivable from the FormSchema, so which keys a form needs translated is not something anyone has to remember, it is something a pass over the definition lists out. Producing a translation template, checking which keys are still untranslated, filling them in with a tool in bulk, none of it requires reading any code first. A definition is structured data, and How FormSchema Drives SQL listed the three direct consequences of that: batch scripts can walk it, tools can produce it, and AI can pick it up. All three hold particularly well for language keys, because their shape is decided entirely by another definition.
There is a related division of labor here left over from FormLayout and Screen Generation for Multiple Front Ends. The English captions on the customized layout extract in the previous post are the same thing: a layout file can carry captions, but at run time an already localized FormSchema overrides them, and only the ones it does not reach stay with the layout. A layout file describes structure, the authoritative source of a caption is the FormSchema, and the FormSchema's captions come from language resources. Across those three layers only one path decides the word on the screen.
An option set is a different kind of thing
A LanguageEnum is an ordered group of entries keyed by code:
<Enums> <LanguageEnum Name="Gender"> <Entry Code="M" Text="男" /> <Entry Code="F" Text="女" /> </LanguageEnum> </Enums> Code is the value stored in the database and Text is the word people read, with the order preserved as it stands in the XML, so a dropdown and a table's lookup binding do not have to sort it again. Which group a field uses is written on the FormSchema as LangEnumName. A bare name like OrderStatus is looked up in its own namespace, and a full name like Common.Gender reaches into another namespace. A shared option set is therefore maintained in one place.
On a hit, the framework replaces the field's ListItems with the whole group of entries. A field could have had its options written into the FormSchema directly, and writing LangEnumName instead is declaring that this column's options are supplied by the language resource.
3. The three fallback routes when a translation is missing
A missing translation is normal rather than an error. A system with a thousand forms will always have somewhere still untranslated, so what actually needs designing is what stands in when the lookup fails.
The framework's answer is not one thing. Three entry points into the same language subsystem end their last step differently, and one of them is a step shorter:
| Entry point | Lookup order | Last step |
|---|---|---|
Plain text key (business object, IStringLocalizer) | The customization layer and the package layer in the current language, then both again in the default language | Return the key string {namespace}.{subKey} itself |
| Form caption (the three sub-keys above) | The customization layer and the package layer in the current language | Keep whatever literal was written in the FormSchema |
Option set (LangEnumName) | The customization layer and the package layer in the current language, then both again in the default language | Keep the field's original ListItems |
Laid out, the plain text key route is four lookups plus an ending, and the option set route runs those same four steps with only the ending different:
customization layer (current language) ─ hit → return ↓ miss package layer (current language) ────── hit → return ↓ miss ← both steps below are skipped entirely customization layer (default language) ─ hit → return when current = default ↓ miss package layer (default language) ────── hit → return ↓ miss return "{namespace}.{subKey}" The first two steps and the last two are the same piece of code run twice, differing only in the language argument. Choosing between the customization layer and the package layer uses the pure decision component from the previous post, where language text is per key and option sets are whole file replacement.
Why the form caption route is a step shorter
The difference between the three routes is not an oversight. The test is whether there is already a value on hand at this position that a human can read.
Every field in a FormSchema carries a Caption to begin with, a word typed by whoever wrote the definition. Keeping it when a translation is missing puts a genuinely readable caption on the screen, and there is no reason to fall back to another language and take a chance. Option sets are the same, since a field may have had ListItems written on it already.
A plain text key has nothing. There is no backup text under a key like Common.OK, so it falls one more step to the default language, and when even the default language misses, the framework chooses to display the key on the screen as it stands. That is deliberate: a visible Common.OK gets reported, and an empty string or a quiet default does not.
The measured conditions were a current language of en-US, a default language of zh-TW, and translations only in zh-TW. Over the same data, a plain text key came back with the zh-TW translation, while form captions and table names stayed as the English literals in the FormSchema rather than turning into Chinese. Two behaviors coexist in one subsystem, and both of them are correct.
Where the starting language comes from
The current language is the session property from Session Management and Lifecycle, read from the user table at login and falling back to a deployment level setting when it is not there.
The default language is a separate setting answering a different question. One is what language this session speaks, and the other is where a key falls back to when it cannot be found in its own language. They are kept apart down to their default values, zh-TW for the first and en-US for the second. So a user speaking Chinese while resources fall back to English is reasonable in one deployment, because a package's resources may well be most complete in English.
4. Which end the overlay happens on, and how finely the cache is cut
The premise was established in The Scope of the Customization Layer and Overlay Granularity: the server does not overlay first, both raw layers go out, and the client picks with the same decision component once it has them. Language resources take the same route, System.GetLanguage for the package layer and System.GetCustomizeLanguage for the customization layer, with which customization it is decided by the server from the session.
The client therefore has to know first which namespaces a form will use. The FormSchema can answer that itself: the form's ProgId counts as one, and each LangEnumName named in full on a field counts as one more. Both layers of those namespaces are then fetched in one go, along with the default language if the route can fall back to it, and collected into one synchronized snapshot, so the localizing step does not have to reach the server again (from FormDefinitionLoader.cs).
var customize = await _defineAccess.GetCustomizeLanguageAsync(language, ns); var @base = await SafeGetLanguageAsync(language, ns); snapshot[BuildKey(language, ns)] = new LanguageLayers(@base, customize); The cost is more round trips: assembling one form definition means fetching two layers of language and two layers of layout. So on the Avalonia side this is optional, a form with no loader specified is neither localized nor customized, and which forms pay that cost is for the application to decide.
One cache slot is one language and one namespace, and changing one slot leaves every other language and namespace alone. Which gives the earlier line about cutting namespaces however is easiest to manage one more thing to weigh: a language file is a unit of caching. Put everything in one file and changing a word rereads the whole thing, and cut it too finely and the client makes several more trips to assemble one form.
A missing file here is a normal answer rather than a fault. The read path throws nothing, and the fact of having looked and found nothing is itself cacheable. FormSchema is the opposite, where a missing file is a configuration error. Of the two classes sorted out in The Purpose and Classification of Definition Files, language resources land on the permissive side.
The language at the two ends does not come from one source either. The server reads the language on the session, and a desktop client reads the process UI culture by default and can override it. What the server decides is which language this call's messages come back in, and what the front end decides is which language the user is looking at right now, which were two different things to begin with.
Back to Northwind
The case study's company record carries the customization id northwind-demo, and the order form has a language file in each of the two layers. The package's declares every caption on the order once, among them Field.customer_rowid.Caption as "customer" and Field.ref_customer_name.Caption as "customer name". The customized one is this in its entirety (Order.Language.xml):
<LanguageResource Namespace="Order" Lang="zh-TW"> <Items> <LanguageItem Key="Field.customer_rowid.Caption" Value="經銷商" /> <LanguageItem Key="Field.ref_customer_name.Caption" Value="經銷商名稱" /> </Items> <Enums /> </LanguageResource> This company calls a customer a dealer, so it overrides those two keys and writes not one word more. Of the two, the one that appears on the list screen is the second:
Measured across the two APIs, the package layer returns twenty four keys and the customization layer returns two, both go to the client as they are, and which one applies is something that happens at the moment of lookup.
The other half of the case study is in en-US. That language does not even have a folder, both layers come back empty, and the screen is fine regardless: none of the three sub-keys hit, so the captions stay as the English written in the FormSchema itself, which is exactly the route that is a step shorter. The order's status column lists its three options in the definition directly and names no option set, so the option set route is never taken at all.
This is the default the mechanism deliberately keeps: a missing translation is not a state waiting to be fixed, it is a legitimate state. A deployment selling into one region, with captions that are the words typed during development, does not have to produce a pile of language files just because the framework supports several languages. When another language is genuinely wanted, the thing to do is put a file into that folder, and the FormSchema, the layouts and the code all stay untouched.
In closing
Localization lands on a definition file, and translations no longer have to be delivered along with the code. Changing a caption is changing a piece of data, with no rebuild, no new assembly and no restart. The cost is that the compiler no longer checks keys for you, and a typo is not visible until run time.
- Five things the
.resxmodel cannot hold: translations that can be swapped out on their own, language and customization id as parameters on every call, a customization covering only a few keys, one resource file shared by both ends, and translations that are not only strings. The standardIStringLocalizerinterface stays, and only the storage underneath was replaced - One language and one namespace make one file,
FormSchemalines up with it through three sub-keys, and all three are computed from the definition itself. Option sets do not go through a sub-key but through the name written on the field, and can be shared across namespaces - There are three fallback routes, the longest being four lookups plus an ending and the shortest stopping after two
The one most worth taking away is why the three routes differ. Their length is not decided by importance, it is decided by whether there is a value on hand at that position that a human can read. A form caption has a literal to keep, so it does not need to fall any further. A plain text key has nothing, which is why it falls one more step to the default language.
The step where everything misses is a decision too. What the framework returns is the key string itself, not an empty string and not some default that looks fine. A missing translation has no correct answer, only the question of whether it gets seen, and the version that gets seen is the one somebody reports.
The next post, Business Logic Customization and the Four Plugin Stages, changes the situation. When the difference one company wants is too large for language resources or layouts to hold, where a business plugin hooks in, and how far this line of customization has come.
📚 Part of the thirty-post series ERP Architect's Notes: Designing a Definition-Driven Framework
📘 Original article on HackMD, in Traditional Chinese:
👉 https://hackmd.io/@jeff377/day-20-language-resources
📢 You are welcome to republish this, please credit the source.
📬 Follow for more engineering notes:
Facebook | HackMD | GitHub | NuGet

