You need to sign in before continuing.
guide.md 7.09 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.

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 wiredcontext.translator currently throws UnsupportedOperationException. This guide describes the design that P1.6 will deliver. See ../../PROGRESS.md for 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:

  1. Core bundles shipped inside the vibe_erp image.
  2. 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.
  3. Operator overrides stored as metadata__translation rows tagged source = '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 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, 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 (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-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