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 CLIPBOARDis the English phrase (upper-cased). The defaultFxFxBundle.propertiesmaps it to a readable English string andFxFxBundle_de.propertiesto the German one:
# 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
*.propertiesfiles sit beside their controller insrc/main/javaand are copied as resources. An@FxControllerServicecontroller's bundle is loaded the same way (DefaultFxFactoryresolves it throughBundleFactory.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):
- Modular (JPMS) applications. Under the module system, a resource bundle is by
default only visible from its own module, and
ResourceBundleControlProvideris disabled. A framework-level factory could not otherwise load a bundle that lives in an application module. Tentackle's factory usesModuleSorter/ModuleHookto know which module a bundle belongs to and loads it from that module. - Non-modular (classpath) applications. The JDK only loads
ResourceBundleControlProviders from installed extensions. Tentackle uses the same SPI semantics but scans the application classpath instead. - Build-time awareness.
@Bundle-annotated classes are recorded underMETA-INFduring 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 →
BundleSupportfor 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'sModuleHook.getBundle(...), and everything else through the standardResourceBundle.getBundle(...)— optionally with aResourceBundle.Controlcontributed 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'sSessionInfoand 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 onlyenanddewould map everyde*todeand everything else toen. - Fallback locale.
getFallbackLocale()(defaultLocale.ENGLISH, language-only) is the locale of the keyless default.propertiesfile — the last resort when no translation matches. - Supported locales.
getEffectiveLocales()/isLocaleSupported(locale)enumerate what the app can render;I18NTextand the translation editor use this. - Locale tags & currency.
toTag/fromTaggive a guaranteed round-trip for supported locales (unlikeLocale.toLanguageTag), used for serialization; andgetCurrency(locale)derives the locale's currency.
Bundle locale fall-back (
Texts_de_DE→Texts_de→Texts) is the standardResourceBundlechain — 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., PostgresTEXT, otherwise aVARCHAR+ overflowCLOB). In a model:
- It implements
CharSequenceandComparablefor the current locale only (so it can be used like aStringin the UI), butequals/hashCodecover 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 theLocaleProvider'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
@Bundleannotation carries@Analyze("…BundleAnalyzeHandler"). During the analyze step every@Bundle(and other bundle-providing annotations such as@FxControllerService) is recorded underMETA-INF, soBundleSupportand 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*.propertiesfiles 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¶
- Give each package a
@Bundleclass (<Package><Module>Bundle) with a default.propertiesplus one per locale (…_de.properties, …); use the source text as the key. - Look texts up with
XxxBundle.getString("…"), formatting parameters withMessageFormat. - For multilingual data, use
I18NTextmodel attributes. - Set the locale per thread (
LocaleProvider.setCurrentLocale) — the server does this automatically per remote request from the client'sSessionInfo. - At build time,
analyzerecords the bundles, the check plugin verifies the keys, and — optionally — the i18n plugin andtentackle-i18nmodule move translations into the database for live editing.
See Also¶
- common.md — this module's overview.
- services.md — the
@Service/ModuleHookmachinery behind module-aware bundle loading. - i18n.md — database-backed, live-editable bundles.
- tentackle-i18n-maven-plugin.md — property-file ↔ database synchronization.
- tentackle-check-maven-plugin.md — build-time bundle-key verification.
- model-definition.md —
I18NTextas a model attribute.