Skip to content

Tentackle I18N — Database-backed Resource Bundles

Overview and Motivation

The tentackle-i18n module lets an application serve its localized texts from the database instead of — or in addition to — the *.properties files baked into its jars. Translators can then edit texts at runtime (typically through the BundleMonkey tool) and the change takes effect immediately, across all running clients and servers, without rebuilding or redeploying anything.

It does so without changing a single line of calling code. Application code keeps using the framework's bundle lookups; this module transparently intercepts bundle loading and, when a translation exists in the database, returns it instead of the one from the property file. If nothing is stored, it falls back to the property file, so adding the module to a project is non-intrusive.

tentackle-i18n is the runtime half of Tentackle's database i18n story. Its build-time counterpart is the tentackle-i18n-maven-plugin, which pushes the property files into the database and pulls translator edits back into the sources. Both sides talk to the same two PDOs — StoredBundle and StoredBundleKey.

The module depends on tentackle-persistence and tentackle-domain (both declared optional so they are not forced onto downstream artifacts transitively). It contributes a ModuleHook service and exports the org.tentackle.i18n, org.tentackle.i18n.pdo and org.tentackle.i18n.pdo.trip packages.

The Data Model

Translations live in two tables, modeled as a Tentackle aggregate (a composite root with its components):

PDO Table Class id Holds
StoredBundle bundle 8 one bundle for one locale (name + locale)
StoredBundleKey bundlekey 9 one keyvalue entry of a bundle

A StoredBundle is identified by its unique domain key (name, locale)name is the fully-qualified bundle base name (e.g. org.myapp.ui.Texts), locale is the locale suffix (de, de_DE, …). A StoredBundle whose locale is null carries the default / fall-back translations. The two are always derived from the requested base name and Locale, never by splitting a bundle name at its first underscore, so base names containing underscores (org.myapp.Texts_v2) work as expected.

StoredBundle is a composite root: its StoredBundleKey entries are components that belong to it, are always loaded eagerly with the bundle, and are edited only through the bundle. Use StoredBundle.setTranslation(key, value) to add, change (or, with a null value, remove) a translation, and getTranslation(key) to read one — never add or mutate a StoredBundleKey directly.

Because they are normal PDOs, the bundles are remoting-capable: the pdo.trip package provides the TRIP delegates so they can be loaded across a Tentackle server just like any other entity.

How Bundle Loading Is Intercepted

Tentackle locates resource bundles through the BundleFactory service in tentackle-common. By default, that is the DefaultBundleFactory. This module ships a replacement, registered via the service SPI:

  • StoredBundleFactory@Service(BundleFactory.class), extends DefaultBundleFactory. It first tries to load a bundle from the database and only falls back to the superclass (property-file) behavior when nothing is stored. This is the path used in modular (JPMS) applications, where the JDK's ResourceBundleControlProvider SPI is not honored.
  • StoredBundleControlProvider@Service(ResourceBundleControlProvider.class). For non-modular (classpath) applications it hooks straight into the JDK's ResourceBundle.getBundle(...) machinery, so even plain JDK bundle lookups go through the stored-bundle control.

Both ultimately delegate to StoredBundleControl, a ResourceBundle.Control whose newBundle(...):

  1. Requires a current Session. If Session.getCurrentSession() is null (e.g., very early at startup, or in a context without a database) it behaves like the standard control and just reads the property file. So no database round-trip happens before persistence is up.
  2. Otherwise, it builds the (name, locale) domain key, loads the matching StoredBundle from the database (via a cached unique-domain-key select), and wraps it in a StoredResourceBundle.
  3. If no stored bundle is found and fallbackToProperties is enabled (the default), it loads the java.properties bundle as usual and logs that it fell back.
  4. If the load itself fails — the database is unreachable, a query blows up, a remote server is down — the same property-file fallback applies and the failure is logged as a warning. A backend hiccup must not break every text lookup, all the more since the message of the very exception being reported is itself looked up in a bundle. Such a fallback is deliberately not cached (TTL_DONT_CACHE), so it does not outlive the failure that caused it.

StoredResourceBundle is a thin ResourceBundle over the bundle's key/value map. The map is built once in the constructor and immutable afterward, so lookups from many threads need no locking at all. An entry whose key or value is null — which validation prevents, but corrupt data might not — is logged and skipped rather than failing the whole bundle. Locale fall-back (Texts_de_DETexts_deTexts) is reproduced by chaining stored bundles as ResourceBundle parents, so a more specific locale only needs to store the keys that actually differ from its parent. Locales without a stored bundle are skipped rather than terminating the chain, exactly as ResourceBundle treats its list of candidate locales: if only Texts_de_DE and Texts are stored, Texts becomes the parent of Texts_de_DE.

The property files are the last resort of that chain, not merely a replacement for it. A key that none of the stored bundles defines still resolves from the property file, so a text added to the sources but not yet pushed to the database keeps working. Stored bundles always win over property files. Setting fallbackToProperties to false removes the property files from the picture entirely — on both paths — and a missing key becomes a MissingResourceException.

Note on the two paths. After the fallback is appended, the modular path resolves a key as all stored levels, then all property levels, whereas the classpath path resolves it per locale level, preferring the stored bundle at each — the JDK builds that chain itself and cannot be told to do otherwise for named modules. The difference only shows when the same key exists in a stored bundle of a less specific locale and in a property file of a more specific one. Keeping the database in sync with the property files (which is what the tentackle-i18n-maven-plugin is for) avoids the situation altogether.

Caching and Live Updates

Stored bundles are cached in the factory (and backed by a preloading PdoCache in the persistence layer), so repeated lookups don't hit the database. To keep that cache correct when a translator changes a text, StoredBundleControl registers a modification listener on StoredBundle with the ModificationTracker. Whenever a StoredBundle changes — including changes made on another node and propagated through the modification tracker — the listener calls BundleFactory.clearCache(), so the new translation becomes visible everywhere without a restart.

The listener is registered lazily on first use (not in the constructor), because the modification tracker is not yet running at application startup. It is registered exactly once per JVM, even though a modular application has more than one control instance — the factory holds one and the ResourceBundleControlProvider another. Loading uses a thread-local DomainContext created on demand, shared by all threads that use the control; since it is created without a fixed session, each thread still resolves its own thread-local one.

A bundle is always loaded outside the factory's cache map, never from within a ConcurrentHashMap mapping function. Loading walks the whole parent chain and every step queries the database or a remote server, and anything it touches on the way may look up a bundle itself — for a validation or error message, say. Holding a map lock across that would serialize unrelated lookups and could re-enter the map from the very thread that already holds it. Two threads missing on the same bundle at the same time therefore both load it; the first to finish wins the cache entry and the other simply drops its copy, since loading is idempotent.

Bundles looked up before a session exists — by the splash screen or the login dialog, for example — come from the property files (see step 1 above). Such results are deliberately not remembered: they are never entered into the factory's bundle map, and the first lookup made once a session is available drops the caches that were filled without one. Otherwise, those bundles would keep serving property-file texts for the rest of the JVM's life, since nothing but a StoredBundle modification would ever invalidate them.

Configuration Switches

The behavior can be tuned through two static switches:

  • StoredBundleFactory.setEnabled(false) — turn off database loading entirely; the factory then behaves exactly like DefaultBundleFactory (property files only). Useful to disable the feature globally without removing the module.
  • StoredBundleControl.setFallbackToProperties(false) — applies to both paths: make a missing stored bundle a hard error: the lookup throws MissingResourceException instead of silently reading the property file. This is handy in environments where every text must come from the database.

Both default to the convenient, non-intrusive behavior (enabled, with property-file fallback).

Putting It Together

  1. Add tentackle-i18n to the application and make sure the bundle / bundlekey tables exist (they are part of the model, so the SQL plugin creates them like any other entity).
  2. Seed the database from the property files with mvn tentackle-i18n:push.
  3. At runtime, the StoredBundleFactory / StoredBundleControl serve those texts transparently; translators edit them live through BundleMonkey, and the modification tracker propagates the changes to every client and server.
  4. Periodically run mvn tentackle-i18n:pull to bring translator edits back into the property files so they are committed with the sources.

Further Reading