i18n guide
vibe_erp is built to be sold worldwide. There are no hard-coded user-facing strings, currencies, date formats, time zones, number formats, address shapes, or tax models in the framework — everything user-facing flows through the i18n and formatting layers from day one. This is guardrail #6 in CLAUDE.md, and it applies to the core, every PBC, and every plug-in.
Status (current build). The api.v1 contract for i18n (
Translator,LocaleProvider,MessageKey) is locked in and present. The host implementation backed by ICU4J is P1.6 in the implementation plan and is not yet wired —context.translatorcurrently throwsUnsupportedOperationException. This guide describes the design that P1.6 will deliver. See../../PROGRESS.mdfor the live status of every feature.
For the architectural placement of i18n, see ../architecture/overview.md and the full spec at ../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md.
ICU MessageFormat is the backbone
vibe_erp uses ICU MessageFormat (via ICU4J) on top of Spring's MessageSource. ICU MessageFormat handles the things naive printf-style formatting cannot:
- Plurals (
{count, plural, one {# item} other {# items}}) - Gender selection
- Locale-aware number, date, and currency formatting
- Nested arguments and select clauses
A message in a bundle file looks like this:
orders_sales.cart.summary = {count, plural, =0 {Your cart is empty.} one {# item, total {total, number, currency}} other {# items, total {total, number, currency}}}
The same key produces correct output in English, German, Japanese, Chinese, and Spanish without changing a line of calling code.
Plug-ins ship message bundles
Each plug-in (and each PBC) ships its message bundles inside its JAR under i18n/messages_<locale>.properties. The locale tag follows BCP 47:
src/main/resources/
└── i18n/
├── messages_en-US.properties
├── messages_zh-CN.properties
├── messages_de-DE.properties
├── messages_ja-JP.properties
└── messages_es-ES.properties
The plugin.yml manifest lists the bundle paths under its flat metadata: array so the loader knows to pick them up:
metadata:
- i18n/messages_en-US.properties
- i18n/messages_zh-CN.properties
How the host merges bundles
On boot, and on every plug-in install, the host builds a merged MessageSource per locale. The merge order, from lowest precedence to highest:
- Core bundles shipped inside the vibe_erp image.
- Plug-in bundles picked up from each plug-in's JAR in plug-in load order. A plug-in may override a core key with the same key in its own bundle, but is not encouraged to.
-
Operator overrides stored as
metadata__translationrows taggedsource = 'user'. These will be entered through the Tier 1 customization UI (P3.x) and let an integrator rewrite any string for the instance without rebuilding anything.
operator overrides ← highest precedence
plug-in bundles
core bundles ← lowest precedence
The merged MessageSource is per-locale. Switching the user's locale picks the right merged bundle without restarting anything. (vibe_erp is single-tenant per instance, so there are no per-tenant bundles — every user of the same instance shares the same merge result for a given locale.)
Shipping locales for v1.0
vibe_erp v1.0 ships with five locales:
| Locale tag | Language |
|---|---|
en-US |
English (United States) |
zh-CN |
Chinese (Simplified) |
de-DE |
German |
ja-JP |
Japanese |
es-ES |
Spanish (Spain) |
Adding a sixth locale (once P1.6 lands) is a Tier 1 operation: drop a <locale>.properties file into i18n-overrides/ on the mounted volume, or add metadata__translation rows through the customization UI. No rebuild.
The reference business documentation under raw/业务流程设计文档/ is in Chinese. That does not make Chinese the default. Chinese is one supported locale among the five.
Translation key naming
Translation keys follow <plugin-or-pbc>.<area>.<message>:
identity.login.title
identity.login.error.invalid_credentials
catalog.item.field.sku.label
orders_sales.order.status.confirmed
plugin_printingshop.plate.field.thickness.label
Rules:
- The first segment is the PBC table prefix (
identity,catalog,orders_sales, …) or, for a plug-in,plugin_<id>. This is the same prefix used in the database, the metadatasourcecolumn, and the OpenAPI tag. - Segments are
snake_case. - The last segment names the message itself, not the widget that renders it.
- Two plug-ins can never collide: their first segment differs by definition.
The Translator is the only sanctioned way
The host injects a Translator (from org.vibeerp.api.v1.i18n) into every plug-in endpoint, every workflow task handler, every event listener. There is no string concatenation in user-facing code, anywhere, ever.
val message = translator.format(
MessageKey("orders_sales.order.created.notification"),
mapOf(
"orderNumber" to order.number,
"customerName" to order.customer.name,
"total" to order.total,
),
)
What this means in practice:
- A reviewer who sees
"Order " + number + " was created"in a PR rejects the PR. - A reviewer who sees a
String.formatagainst a hard-coded format string rejects the PR. - A reviewer who sees
if (locale == "en") "..." else "..."rejects the PR with prejudice.
The Translator is locale-aware: it picks up the request's resolved locale from the LocaleProvider, which in turn resolves from (in order) the user's profile preference, the request's Accept-Language, and the instance default configured in vibeerp.i18n.default-locale.
PII, dates, numbers, currency
Locale handling does not stop at strings:
- Dates and times are formatted through the locale, not through hard-coded patterns. The host exposes locale-aware formatters; plug-ins use them.
- Numbers use the locale's grouping and decimal separators.
-
Currency values are
Money(fromapi.v1.core) — an amount plus an ISO 4217 code — and the formatter renders them in the user's locale (e.g.$1,234.56vs.1.234,56 €vs.¥1,235). -
Time zones are per-user (with an instance default configured in
vibeerp.instance.*), not per-server. - PII fields are tagged in the field metadata. Tagged fields drive auto-generated DSAR exports and erasure jobs (GDPR Articles 15/17), and the audit log records access to them when audit-strict mode is on.
- Address shapes are not assumed. There is no "state/province" or "ZIP code" hard-coded in the core; address fields are configurable via metadata so a Japanese address can have prefecture/city/ward and a French address can have arrondissement.
Where to go next
- Plug-in author walkthrough that uses
Translator:../plugin-author/getting-started.md - Plug-in API surface for i18n:
../plugin-api/overview.md