guide.md 6.31 KB

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:

  1. Core bundles shipped inside the vibe_erp image.
  2. Plug-in bundles picked up from ./plugins/*.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.
  3. Tenant overrides stored in metadata__translation rows 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 metadata source column, 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.format against 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 (from api.v1.core) — an amount plus an ISO 4217 code — and the formatter renders them in the user's locale (e.g. $1,234.56 vs. 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