Culture Fallback
From Header to Localizer
Accept-Language header
└─ first entry only
└─ CultureInfo.CreateSpecificCulture(...)
└─ localizer for that culture
└─ fallback chain
Two things about the first step surprise people:
- Only the first language in the header is used. Agility does not evaluate
qvalues or try the second-choice language.Accept-Language: de,fr;q=0.9is treated asde. - Neutral cultures are expanded.
CreateSpecificCulture("fr")yieldsfr-FR. You cannot request a neutral culture over HTTP; you always land on a specific one, which then falls back through the neutral one.
An unparseable value, or a missing header, produces the invariant culture, which resolves to the default localizer — English.
The Chain
Each localizer has at most one fallback localizer. LocalizationManager wires
them up as:
specific culture -> neutral (parent) culture -> default culture
fr-FR fr en
Agility's default culture is en.
Per-Tag Fallback
Fallback is evaluated per tag, not per culture. A culture that defines only a handful of terms inherits everything else.
Using the library's own fixtures, where en.Base defines all three tags and
fr.Base / fr-FR.Base define progressively more specific ones:
| Requested culture | TestDefault | TestNeutral | TestSpecific |
|---|---|---|---|
en | Default English | Neutral English | Specific English |
en-US | Default English | Neutral English | Specific American English |
fr | Default English | Neutral French | Specific French |
fr-FR | Default English | Neutral French | Specific France French |
fr-CH | Default English | Neutral French | Specific French |
de | Default English | Neutral English | Specific English |
de-AT | Default English | Neutral English | Specific English |
Read the fr-CH and de-AT rows carefully. fr-CH has no templates of its
own, so it behaves exactly like fr. de-AT has neither a specific nor a
neutral set, so it behaves exactly like en. A wholly unmatched culture is
not an error and produces no marker in the response — it silently returns the
default culture's text.
Those four tests assert instance identity — de returns the very same
Localizer object as en. In Agility that optimisation does not fire, because
one of Core's template providers contributes a set for every culture, so each
requested culture gets its own localizer object chained to its parent. The
observable per-tag behaviour in the table above is unchanged; only the object
graph differs.
The Behaviour Nobody Expects
A template found in a fallback culture is expanded against the culture you asked for, not the culture it was found in.
Nested tags in the template body are resolved by the localizer that received the request. So a template can live in English while every word it splices in comes from French.
Given:
XYZ=ABC {DEF} GHI
DEF=JKL
DEF=PQR
Requesting XYZ in fr-FR returns:
ABC PQR GHI
The XYZ template exists only in English. Its {DEF} placeholder is
nonetheless resolved by the fr-FR localizer, which has its own DEF.
This is the mechanism that lets Agility ship one English sentence structure and have every embedded noun localize correctly. It is also the mechanism that produces mixed-language output when a culture translates a noun but not the sentence around it — which is working as designed, not a bug.
The same rule applies to instance-wide overrides:
| Tag | en | fr | fr-FR |
|---|---|---|---|
DEF | fed (override) | fed (inherited) | PQR (culture wins) |
XYZ | ABC fed GHI | ABC fed GHI | ABC PQR GHI |
Specificity Does Not Cross Cultures
Worth repeating from Tag resolution, because it is the most common cause of "my translation is being ignored":
The term-stack walk completes entirely within one culture before the next
culture is consulted. A generic Plural in fr-FR beats a specific
Plural'Story in en.
If a French user is seeing a French generic plural where you expected an English irregular one, that is why — and the fix is to add the specific entry to the French set, not the English one.
Checking What a Culture Actually Returns
curl "https://www.example.com/VersionOne/loc-2.v1?[Story,Plural'Story,Scope]" \
-H "Accept-Language: en-US"
curl "https://www.example.com/VersionOne/loc-2.v1?[Story,Plural'Story,Scope]" \
-H "Accept-Language: fr-FR"
Because a wholly unmatched culture is indistinguishable from a matched one in
the response body, diffing two cultures like this is the practical way to tell
whether a translation set is installed at all. If every value is identical,
either the culture has no templates or the tags have no translations — the API
cannot tell you which. Pair this with
noloc to separate "not translated" from "not localized
at all".
Related Topics
- Requests and responses — the
Accept-Languagecontract - Template sets and override order — how a culture's templates are assembled
- Debugging with
noloc