What per-module localization is and why a global catalog breaks down
Per-module localization applies the ownership rules of a modular monolith to user-facing text. Each module keeps its translation files next to its code and schema. Those files sit under the module's directory, are reviewed by the module's owners, and ship with the module's changes. One shared layer decides which locale a request uses, and it does so once, at the request boundary. Modules never parse headers or user settings themselves. They receive a resolved locale and look up their own keys.
The usual starting point is a single global catalog, often one large file per locale. It works while one team owns everything. Once several teams own modules, it causes four specific problems:
- Merge conflicts. Every team edits the same files, so unrelated string changes collide in review and slow down releases.
- Unclear ownership. Nobody can say who approves a change to
error.invalid_amountor who may delete it. Stale keys pile up because removing one feels risky. - Key collisions. Two modules both need
status.pending, but each means something different. One wording wins, and the other module shows the wrong text. - Hidden coupling. Billing starts reusing Orders' strings because they already exist. A copy edit in Orders then changes Billing's UI, and no dependency graph records the link.
This pattern is for backend and platform teams running a modular monolith with several owning teams. It pays off when you have multiple modules and multiple locales, and when teams need to release string changes without coordinating with each other. With a single team or a single locale, a global catalog is usually still the cheaper choice.
How it works: module catalogs plus one locale resolver
The architecture has two parts with a strict division of labor. Locale is decided once, centrally. Strings are owned and looked up locally, by each module.
One resolver at the edge
A single middleware runs before any module code and resolves the locale through an ordered precedence chain:
- The authenticated user's saved preference
- An explicit request parameter, or else the
Accept-Languageheader - The tenant's configured default
- The system default
The order matters. A user who chose Portuguese should not get German because their browser sends de. A tenant default should apply only when the request itself carries no signal. The resolved value goes into request context, and modules read it from there. They never parse headers themselves. This stops each module from growing its own slightly different resolution logic.
Module-owned catalogs
Each module ships its own catalog and translator, and its keys are namespaced (billing.invoice.overdue, not invoice.overdue). Two teams can then add keys without colliding or coordinating releases. The translator applies a fallback chain such as pt-BR → pt → default language. A missing regional string degrades to the base language rather than to a raw key.
Beyond the request
Background jobs and queued messages have no request, so they must not guess. When work is enqueued, the code persists or passes the locale explicitly. Usually this means storing it on the record the job acts on, such as the user or the order, and reading it back when the job runs. This also covers retries that execute hours later, after the user may have changed their preference. Decide deliberately whether the job should use the locale captured at enqueue time or the current preference at execution time.
HTTP request Job dequeued or retried
| |
v v
+--------------------------------+ +---------------------------+
| Locale middleware (runs first) | | Job runner (no request) |
| 1. user saved preference | | reads locale from record |
| 2. param or Accept-Language | | (user, order) or payload |
| 3. tenant default | +-------------+-------------+
| 4. system default | |
+---------------+----------------+ |
v v
+--------------------------------+ +---------------------------+
| Request context: locale=pt-BR | | Job context: locale=pt-BR |
+---------------+----------------+ +-------------+-------------+
| |
+----------------+-----------------+
|
+----------------------+----------------------+
v v v
+------------------+ +------------------+ +------------------+
| billing module | | orders module | | accounts module |
| translator | | translator | | translator |
| billing.* keys | | orders.* keys | | accounts.* keys |
+---------+--------+ +---------+--------+ +---------+--------+
| | |
v v v
+----------------------------------------------------------------+
| Per-module catalog lookup, fallback: pt-BR --> pt --> default |
+----------------------------------------------------------------+
The edge middleware resolves the locale once and stores it in request context. Jobs read the locale stored on their record. Each module's translator looks up its own namespaced keys and falls back from pt-BR to pt to the default language.
Implementing translation files per module
Give each module its own locales directory beside its code, so translations ship, move and get deleted with the feature they describe. A typical layout looks like modules/billing/locales/en/messages.json, modules/billing/locales/de/messages.json, and the same pattern under modules/orders/ and modules/accounts/. One exception lives outside any feature module: shared/common/locales/<locale>/messages.json, holding the small set of strings every module legitimately needs.
Discovery and registration
At startup, or as a build step that emits a single bundle, walk modules/*/locales/*/, derive the module name from the directory path, and register each file under that module's namespace. The loader should fail if a file contains a key that does not start with its module prefix, or if two modules claim the same namespace. Doing this at boot or build time means a misplaced key breaks CI, not a customer's invoice email.
Namespacing rules
- Prefix every key with the module name, for example
billing.invoice.overdue_noticeorbilling.errors.card_declined. The prefix makes ownership obvious in logs, diffs and missing-key reports. - Forbid cross-module lookups. If
ordersneeds a billing string, it should call a billing function that returns the localized text. Readingbilling.*keys directly couples modules through their copy, and a harmless rewording then turns into a breaking change. - Keep
commonsmall and explicitly owned. Restrict it to units, date and number formats, and generic errors such ascommon.errors.not_found. Assign it a named owner in your code-ownership file. Without that owner,commonslowly fills with every string nobody wanted to scope, and you end up rebuilding the global catalog you set out to avoid.
import json
import sys
from pathlib import Path
def load_catalogs(root: Path) -> dict[str, dict[str, str]]:
"""Build {locale: {key: text}} from every module catalog plus the shared common one."""
# modules/<module>/locales/<locale>/messages.json -> namespace is <module>
sources = [(p.parents[2].name, p) for p in sorted(root.glob("modules/*/locales/*/messages.json"))]
if any(ns == "common" for ns, _ in sources):
# 'common' is reserved for shared/common so ownership stays unambiguous
raise ValueError("a feature module may not use the reserved namespace 'common'")
sources += [("common", p) for p in sorted(root.glob("shared/common/locales/*/messages.json"))]
catalogs: dict[str, dict[str, str]] = {}
for namespace, path in sources:
locale = path.parent.name
messages = json.loads(path.read_text(encoding="utf-8"))
merged = catalogs.setdefault(locale, {})
for key, text in messages.items():
# Every key must carry its owner's prefix, e.g. billing.invoice.overdue_notice
if not key.startswith(namespace + "."):
raise ValueError(f"{path}: key '{key}' must start with '{namespace}.'")
if key in merged:
raise ValueError(f"{path}: duplicate key '{key}' for locale '{locale}'")
merged[key] = text
return catalogs
if __name__ == "__main__":
# Run in CI or the build step: a non-zero exit blocks the merge, not a customer email
try:
bundle = load_catalogs(Path(sys.argv[1] if len(sys.argv) > 1 else "."))
except ValueError as err:
sys.exit(f"i18n catalog error: {err}")
# Example catalog, modules/billing/locales/en/messages.json:
# {"billing.invoice.overdue_notice": "Invoice {number} is {days} days overdue.",
# "billing.errors.card_declined": "Your card was declined."}
Path("i18n-bundle.json").write_text(json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8")
A build-time loader that discovers each module's locale files, derives the namespace from the directory path, rejects unprefixed keys, duplicates and misuse of the reserved 'common' namespace, and writes a single bundle.
Locale resolution at the request boundary and beyond HTTP
Resolve the locale exactly once, in middleware that runs before any module handler. A typical chain checks an explicit query or path parameter, then the authenticated user's saved preference, then Accept-Language matched against your supported list, then a tenant default, and finally the system default. Put the result on a request context object that gets passed explicitly into service calls. Do not let each module rerun this logic. If two modules disagree about precedence, one page renders in two languages.
Avoid storing the locale in a thread-local or a mutable global. Those values are bound to a thread, not to a request. When work moves to a thread pool, an executor, or a coroutine that resumes on a different worker, the value is either missing or left over from the previous request. That produces intermittent wrong-language responses, which are hard to reproduce.
Background work has no request to read from. When you enqueue a job, write the resolved locale into the job payload. For scheduled tasks and emails, read the locale from the recipient's record at send time. A digest sent at 3 a.m. should use the recipient's language, not the language of whoever triggered the job or the server's default.
Lookups fall back from pt-BR to pt to the default locale. Each fallback and each miss should increment a metric tagged with module, key, and locale, and should log once. The user should never see the raw key. In development and CI, make missing keys fail loudly with an error or an obvious marker, so gaps are caught before release.
type Locale = string;
const SUPPORTED = ["en", "pt", "pt-BR", "de"];
const DEFAULT: Locale = "en";
// Built once per request and passed explicitly into every service call.
export interface RequestContext { locale: Locale; userId?: string; tenantId?: string }
// Precedence: explicit param > user preference > Accept-Language > tenant default > system default.
export function resolveLocale(req: { query: Record<string, string | undefined>; headers: Record<string, string | undefined> },
user?: { locale?: Locale }, tenant?: { defaultLocale?: Locale }): Locale {
const fromHeader = (req.headers["accept-language"] ?? "")
.split(",").map(p => p.split(";")[0].trim()).find(tag => SUPPORTED.includes(tag));
const candidates = [req.query.lang, user?.locale, fromHeader, tenant?.defaultLocale];
return candidates.find((c): c is Locale => !!c && SUPPORTED.includes(c)) ?? DEFAULT;
}
// No request exists when the worker runs, so the locale travels in the payload.
export interface DigestJob { recipientId: string; locale: Locale }
export function enqueueDigest(queue: { add(name: string, data: DigestJob): Promise<unknown> },
recipient: { id: string; locale?: Locale }) {
return queue.add("digest", { recipientId: recipient.id, locale: recipient.locale ?? DEFAULT });
}
// Fallback pt-BR -> pt -> default. Count fallbacks and misses; never render the raw key.
const warned = new Set<string>();
export function t(catalogs: Record<Locale, Record<string, string>>, module: string, key: string, locale: Locale,
metrics: { increment(name: string, tags: Record<string, string>): void }): string {
const chain = [locale, locale.split("-")[0], DEFAULT].filter((l, i, a) => a.indexOf(l) === i);
for (let i = 0; i < chain.length; i++) {
const msg = catalogs[chain[i]]?.[`${module}.${key}`];
if (msg === undefined) continue;
if (i > 0) metrics.increment("i18n.fallback", { module, key, locale });
return msg;
}
metrics.increment("i18n.missing", { module, key, locale });
// Fail loudly in development and CI so gaps are caught before release.
if (process.env.NODE_ENV !== "production") throw new Error(`Missing i18n key ${module}.${key} (${locale})`);
const id = `${module}.${key}:${locale}`;
if (!warned.has(id)) { warned.add(id); console.warn("i18n missing key", id); } // log once
return ""; // safe output in production instead of the raw key
}
Framework-agnostic TypeScript that resolves the locale once into an explicit request context, stores it on a job payload, and translates with a pt-BR to pt to default fallback that records metrics, logs misses once, and throws outside production.
Backend localization beyond string lookup
A key lookup returns a string, but most user-facing text is not static. "1 invoice is overdue" versus "3 invoices are overdue" cannot be built by concatenating count with a translated noun. Languages have different plural categories: English uses one and other, while Polish and Arabic need more. ICU MessageFormat moves that logic into the translation, so translators control it without code changes: {count, plural, one {# invoice is overdue} other {# invoices are overdue}}.
Numbers, currencies, and dates follow the same rule. Format them at the edge with a locale-aware formatter, not with string templates, because separators, symbol placement, and date order all vary by locale. The formatter needs data that is locale-neutral, so store it that way:
- Timestamps in UTC, converted to the user's time zone only when rendered.
- Money as an integer in minor units with an ISO 4217 currency code. Not every currency has two decimals, so take the exponent from the currency rather than assuming 100.
- Enumerations as codes such as
status: "overdue", never as translated labels. A stored label locks the data to the language that wrote it.
Apply the same split to API errors. Return a stable code and a localized message, for example {"error": {"code": "invoice.already_paid", "message": "Diese Rechnung wurde bereits bezahlt.", "locale": "de-DE"}}. Clients branch on code, and tests assert on code. Rewording a translation then cannot break an integration or a test suite.
// Stored data stays locale-neutral: UTC instant, minor units + ISO 4217 code, enum code.
const invoice = { dueAt: "2026-10-01T09:30:00Z", amountMinor: 129950, currency: "EUR", status: "overdue" };
// Take the exponent from the currency (EUR = 2, JPY = 0) instead of assuming 100.
function formatMoney(amountMinor: number, currency: string, locale: string): string {
const fmt = new Intl.NumberFormat(locale, { style: "currency", currency });
const digits = fmt.resolvedOptions().maximumFractionDigits ?? 2;
return fmt.format(amountMinor / 10 ** digits);
}
// Convert UTC to the user's time zone only when rendering.
function formatDate(isoUtc: string, locale: string, timeZone: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone }).format(new Date(isoUtc));
}
// The plural category comes from CLDR rules for the locale. Translators own the variants.
const overdue: Record<string, Record<string, string>> = {
"en-US": { one: "# invoice is overdue", other: "# invoices are overdue" },
"de-DE": { one: "# Rechnung ist überfällig", other: "# Rechnungen sind überfällig" },
};
function overdueMessage(count: number, locale: string): string {
const variants = overdue[locale] ?? overdue["en-US"];
const category = new Intl.PluralRules(locale).select(count);
return (variants[category] ?? variants.other).replace("#", new Intl.NumberFormat(locale).format(count));
}
// Error bodies carry a stable code for clients and tests. The message is display-only.
const errors: Record<string, Record<string, string>> = {
"invoice.already_paid": { "en-US": "This invoice has already been paid.", "de-DE": "Diese Rechnung wurde bereits bezahlt." },
};
function errorBody(code: string, locale: string) {
const used = errors[code][locale] ? locale : "en-US"; // report the locale actually served
return { error: { code, message: errors[code][used], locale: used } };
}
console.log(formatMoney(invoice.amountMinor, invoice.currency, "de-DE"));
console.log(formatDate(invoice.dueAt, "de-DE", "Europe/Berlin"));
console.log(overdueMessage(3, "de-DE"));
console.log(JSON.stringify(errorBody("invoice.already_paid", "de-DE")));
This example uses only the built-in Intl APIs. It renders locale-neutral stored data (a UTC timestamp, minor currency units, an enum code) at the edge, chooses plural variants with Intl.PluralRules, and returns an API error that pairs a stable code with a localized message.
Testing and CI guardrails
Tests that assert on error codes cover behavior. The catalogs need their own checks, and those checks only work if they run on every pull request.
Four checks worth automating
Missing keys. Every key referenced in code must exist in the module's default-locale file. A missing key usually fails quietly, because most lookup functions fall back to printing the raw key. The bug then appears as billing.invoice.overdue_subject in a customer's inbox.
Unused keys. If the default locale has keys that no code references, the check should flag them. Dead keys still get sent to translators, and someone pays to translate them in every locale.
Cross-module keys. A lint rule should reject any lookup where the key's namespace differs from the calling module. If notifications reads billing.* keys directly, the two modules are coupled through their catalogs. The fix is to have billing expose a rendered message or a code, so each module keeps ownership of its own strings.
Pseudo-localization. Generate a fake locale that wraps each string in markers, adds accented characters and pads the length by roughly a third. Then run email and PDF snapshot tests against it. Any text without the markers is hardcoded. Any template that clips the padded strings will also clip German.
Sketching the key-consistency script
A key-consistency script can be short:
- For each module, collect literal keys from translation calls with a regex or AST walk.
- Load that module's default catalog.
- Report used-minus-defined as errors and defined-minus-used as warnings.
- Exit non-zero on errors.
Dynamic keys built at runtime defeat static extraction. Allow them only through an explicit, reviewed allowlist.
Why this saves engineering time
These checks catch problems in review, while the change is still small. Without them, a support ticket about an English email sent to a French customer leads someone to trace locale resolution through a queue days after the change shipped.
Trade-offs, alternatives, and impact on cost and engineering time
Per-module catalogs are not the only workable layout, and they cost more to set up than the alternatives.
| Approach | Strength | Main cost |
|---|---|---|
| Per-module catalogs | Clear ownership, independent string changes | Export/import tooling, key migration on renames |
| Single global catalog | One file, trivial tooling | Merge conflicts, no ownership |
| Shared catalog only | Common phrases defined once | Turns into a dumping ground for unowned keys |
| Codes only, client localizes | Backend stays locale-free | Emails, PDFs and push notifications still need server-side text |
Expect some duplication. "Cancel" may appear in several modules, and that is usually cheaper than a shared catalog that every team writes to and no team cleans up. Renaming or merging modules forces a key migration, so the importer needs a mapping step that carries existing translations over to the new keys. Translation vendors normally want one bundle, which means you need tooling that flattens module catalogs on export and splits them again on import.
Loading has its own trade-off. Eager loading costs memory and startup time but keeps first-request latency stable. Lazy loading starts faster, but the first request for each locale pays the load cost, and you have to invalidate the cache when catalogs change.
Skip the pattern for a small monolith with one team, a single-locale product, or clients that already own all presentation.
Illustrative estimate (assumed numbers, not a benchmark): break-even weeks = upfront hours ÷ weekly hours saved. With 40 hours of tooling and CI work and 2 hours a week saved on conflicts and ownership questions, break-even is 40 ÷ 2 = 20 weeks.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith i18n structure, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.