Skip to content

Internationalization (i18n)

Overview and Motivation

Tentackle applications are localized from the ground up: every user-visible text is looked up in a resource bundle and rendered in the user's language, dates and money respect the locale, and — in a multi-tier deployment — a single server serves many clients each in their own language at the same time.

The framework's i18n story has three parts, and this document is the map of all three:

Layer Where What it does
Foundation tentackle-common The bundle convention, the module-aware BundleFactory, the LocaleProvider, and the multilingual I18NText value type.
Database-backed bundles tentackle-i18n Serve and live-edit translations from the database instead of *.properties files — see i18n.md.
Build-time tooling tentackle-i18n-maven-plugin, tentackle-check-maven-plugin Sync property files ↔ database, and verify that every key used in the code actually resolves.

This page focuses on the foundation in tentackle-common, on which the other two build.


The Bundle Convention

Application code never calls ResourceBundle.getBundle(...) directly. Instead, each package that needs texts has a small bundle class annotated with @Bundle, exposing a static getString(key). The pattern (from FxFxBundle):

@Bundle
public class FxFxBundle {
  public static ResourceBundle getBundle() {
    return BundleFactory.getBundle(FxFxBundle.class.getName(),
                                   LocaleProvider.getInstance().getLocale());
  }
  public static String getString(String key) {
    return getBundle().getString(key);
  }
  private FxFxBundle() { }
}

Call sites read as plain, legible text:

copyButton.setTooltip(new Tooltip(FxFxBundle.getString("COPY TO CLIPBOARD")));
throw new FxRuntimeException(
    MessageFormat.format(FxFxBundle.getString("CANT OPEN URL {0}"), uri), ex);

Three conventions are worth calling out:

  • Keys are the source text itself. The key COPY TO CLIPBOARD is the English phrase (upper-cased). The default FxFxBundle.properties maps it to a readable English string and FxFxBundle_de.properties to the German one:

# FxFxBundle.properties
CANT\ OPEN\ URL\ {0}=cannot open URL: {0}
WARNING=Warning!
# FxFxBundle_de.properties
CANT\ OPEN\ URL\ {0}=URL konnte nicht geöffnet werden: {0}
WARNING=Achtung!

This means code is readable even before a translation exists, and an untranslated key degrades to a sensible phrase rather than a cryptic identifier. - Parameters use MessageFormat. Keys may contain {0}, {1}, … placeholders filled with MessageFormat.format(...). - Bundle class naming follows <Package><Module>Bundle — e.g. FxFxBundle (package fx, module fx), RdcFxRdcBundle (package rdc, module fx-rdc), CommonCommonBundle. The .properties files live next to the class.

FXML and properties co-location. Per the framework's FX conventions, FXML and *.properties files sit beside their controller in src/main/java and are copied as resources. An @FxControllerService controller's bundle is loaded the same way (DefaultFxFactory resolves it through BundleFactory.getBundle(...)).


The Bundle Factory

BundleFactory is the framework's replacement for ResourceBundle.getBundle(...). Using it instead of the JDK call solves three problems at once (see the factory's own Javadoc):

  1. Modular (JPMS) applications. Under the module system, a resource bundle is by default only visible from its own module, and ResourceBundleControlProvider is disabled. A framework-level factory could not otherwise load a bundle that lives in an application module. Tentackle's factory uses ModuleSorter / ModuleHook to know which module a bundle belongs to and loads it from that module.
  2. Non-modular (classpath) applications. The JDK only loads ResourceBundleControlProviders from installed extensions. Tentackle uses the same SPI semantics but scans the application classpath instead.
  3. Build-time awareness. @Bundle-annotated classes are recorded under META-INF during the analyze step, so the same set of bundles is visible to the running app and to the build-time plugins.

DefaultBundleFactory is the default implementation (registered via the service SPI):

  • It builds a map of bundle base names → BundleSupport for every @Bundle (or bundle-providing annotation such as @FxControllerService) found through the service finder.
  • findBundle(baseName, locale) routes a named-module bundle through that module's ModuleHook.getBundle(...), and everything else through the standard ResourceBundle.getBundle(...) — optionally with a ResourceBundle.Control contributed by a registered provider (this is the hook the database-backed module plugs into).
  • clearCache() flushes the JDK bundle cache — used to make live translation edits visible without a restart.

The factory itself is replaceable: register your own @Service(BundleFactory.class) to change bundle resolution globally (which is exactly what tentackle-i18n's StoredBundleFactory does).


The Locale Provider

LocaleProvider is the single source of truth for "what language is now". It is a replaceable @Service, so an application can override its policies.

  • Current locale (thread-local). getLocale() returns the thread-local locale, or the JVM default if none is set. setCurrentLocale(locale) sets it for the current thread. This thread-local design is what lets a multi-tier server serve every client in its own language: each remote request sets the locale from the caller's SessionInfo and clears it afterward —
// RemoteDbDelegateInvocationHandler, per invocation
LocaleProvider.getInstance().setCurrentLocale(session.getSessionInfo().getLocale());
try { ... } finally {
  LocaleProvider.getInstance().setCurrentLocale(null);
}
  • Effective locale. getEffectiveLocale(locale) lets an application narrow the many possible request locales to the few it actually translates. The default is identity; an app that ships only en and de would map every de* to de and everything else to en.
  • Fallback locale. getFallbackLocale() (default Locale.ENGLISH, language-only) is the locale of the keyless default .properties file — the last resort when no translation matches.
  • Supported locales. getEffectiveLocales() / isLocaleSupported(locale) enumerate what the app can render; I18NText and the translation editor use this.
  • Locale tags & currency. toTag / fromTag give a guaranteed round-trip for supported locales (unlike Locale.toLanguageTag), used for serialization; and getCurrency(locale) derives the locale's currency.

Bundle locale fall-back (Texts_de_DETexts_deTexts) is the standard ResourceBundle chain — a more specific locale only needs to override the keys that actually differ.


The I18NText Value Type

Resource bundles localize static texts known at build time. For data that is itself multilingual — a product description, a category name a user types in several languages — Tentackle provides I18NText: an immutable, serializable value object holding one string per locale.

I18NText d = I18NText.valueOf("invoice")     // in the current locale
                     .with(Locale.GERMAN, "Rechnung");
d.get();                 // translation for the current locale
d.get(Locale.GERMAN);    // "Rechnung"

Key properties:

  • It is a model attribute type, persisted via the SQL data type I18NTextType (one wide column where the backend supports it, e.g., Postgres TEXT, otherwise a VARCHAR + overflow CLOB). In a model:
I18NText  description  description  internationalized description [COLS=30]
  • It implements CharSequence and Comparable for the current locale only (so it can be used like a String in the UI), but equals/hashCode cover all translations — so beware of sorted sets.
  • get(locale) resolves a translation, falling back to the text's default locale (the first one set, if the fallback language wasn't provided) and then to the LocaleProvider's fallback locale. Translations narrow to language only.
  • It round-trips to/from a ParameterString (default=…, de=…, en=…) for storage and transport.

The FX layer offers an I18NEditor that presents one input per supported locale for editing an I18NText.


Build-Time Discovery and Verification

i18n is wired up at build time, not by runtime scanning:

  • Discovery. The @Bundle annotation carries @Analyze("…BundleAnalyzeHandler"). During the analyze step every @Bundle (and other bundle-providing annotations such as @FxControllerService) is recorded under META-INF, so BundleSupport and the factory see exactly the same bundles at runtime and at build time, in both modular and classpath mode.
  • Verification. The check plugin can verify during the test phase that every bundle key referenced in the source actually resolves to an entry in a property file — catching typos and missing translations before they reach production.
  • Translation sync. The i18n maven plugin (mvn tentackle-i18n:push / :pull) moves translations between the *.properties files and the database in both directions.

Database-Backed Bundles (runtime)

The optional tentackle-i18n module lets translations be served from — and edited live in — the database instead of (or on top of) the property files, with no change to calling code. It registers a replacement BundleFactory / ResourceBundle.Control that returns a stored translation when one exists and falls back to the property file otherwise, and it invalidates the bundle cache through the ModificationTracker so a translator's edit appears on every client and server immediately. See i18n.md for the full story.


Putting It Together

  1. Give each package a @Bundle class (<Package><Module>Bundle) with a default .properties plus one per locale (…_de.properties, …); use the source text as the key.
  2. Look texts up with XxxBundle.getString("…"), formatting parameters with MessageFormat.
  3. For multilingual data, use I18NText model attributes.
  4. Set the locale per thread (LocaleProvider.setCurrentLocale) — the server does this automatically per remote request from the client's SessionInfo.
  5. At build time, analyze records the bundles, the check plugin verifies the keys, and — optionally — the i18n plugin and tentackle-i18n module move translations into the database for live editing.

See Also