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.
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/<locale>.properties. The locale tag follows BCP 47:
src/main/resources/
└── i18n/
├── en-US.properties
├── zh-CN.properties
├── de-DE.properties
├── ja-JP.properties
└── es-ES.properties
The plugin.yml manifest lists the bundles so the loader knows to pick them up:
metadata:
i18n:
- i18n/en-US.properties
- i18n/de-DE.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
./plugins/*.jarin 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. -
Tenant overrides stored in
metadata__translationrows tagged with the tenant id. These are entered through the Tier 1 customization UI and let an integrator rewrite any string for any tenant without rebuilding anything.
tenant overrides ← highest precedence
plug-in bundles
core bundles ← lowest precedence
The merged MessageSource is per-locale and per-tenant. Switching the user's locale or switching tenants picks the right merged bundle without restarting anything.
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 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, the tenant default, and the system default.
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-tenant and per-user, 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