Skip to main content

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 q values or try the second-choice language. Accept-Language: de,fr;q=0.9 is treated as de.
  • Neutral cultures are expanded. CreateSpecificCulture("fr") yields fr-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 cultureTestDefaultTestNeutralTestSpecific
enDefault EnglishNeutral EnglishSpecific English
en-USDefault EnglishNeutral EnglishSpecific American English
frDefault EnglishNeutral FrenchSpecific French
fr-FRDefault EnglishNeutral FrenchSpecific France French
fr-CHDefault EnglishNeutral FrenchSpecific French
deDefault EnglishNeutral EnglishSpecific English
de-ATDefault EnglishNeutral EnglishSpecific 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.

Instance reuse is a library detail

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:

en.Base.txt
XYZ=ABC {DEF} GHI
DEF=JKL
fr-FR.Base.txt
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:

Tagenfrfr-FR
DEFfed (override)fed (inherited)PQR (culture wins)
XYZABC fed GHIABC fed GHIABC 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​

Compare two cultures in two requests
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".