Skip to content

Component-aware ResourceBundle routing in multi-module Java applications

Gunnar Morling’s article Resource Bundle Lookups in Modular Java Applications describes an elegant technique for routing ResourceBundle lookups across JPMS module boundaries. While designing the JPMS module structure for i18n-core, I found two aspects of the technique worth reconsidering, and I’ve documented those changes here. The modified code is available at https://github.com/clydegerber/modular-resource-bundles/tree/improved-component-routing, if you’d like to follow along.


Java’s ResourceBundle API predates the module system, and loading bundles from modules other than the caller’s is not straightforward. If module A calls ResourceBundle.getBundle("com.example.Messages") and the actual bundle resides in module B, module B must explicitly open its resources to A — or provide them through a service interface.

Gunnar’s article works through this problem in a multi-module golf application. A central ErrorHandler class in the core module needs to retrieve localized messages that live in separate greenkeeping and tournament modules. The solution uses two different mechanisms depending on how the application is loaded:

  • Classpath mode: ResourceBundle.Control (via the ResourceBundleControlProvider SPI) intercepts the lookup and redirects it to the appropriate module’s internal packages.
  • Module path mode: ResourceBundleProvider implementations in each module respond to service lookups and return the appropriate bundle.

Both mechanisms are wired together through a single ErrorHandler.getErrorMessage(String key, UserContext context) entry point.


1. The resource key is overloaded with a module identifier.

In the original implementation, the caller is expected to pass a key like "greenkeeping.greenclosed", where the "greenkeeping." prefix identifies which module owns the message. The ErrorHandler extracts that prefix before performing the lookup.

This works, but it means the caller must understand the module structure to construct the key correctly. Treating the component identifier as a first-class parameter — getErrorMessage(String component, String key, UserContext context) — makes the intent explicit and removes that implicit coupling.

2. The Locale variant is repurposed to carry the component identifier.

Java’s Locale has three parts: language, country, and variant. The variant is intended for distinctions like script (Cyrillic vs. Latin) or regional dialect. In the original technique, the component identifier is encoded into the variant — for example, new Locale("en", "US", "greenkeeping") — so that ResourceBundle.Control implementations can extract it during lookup.

This is clever, but it occupies a field that may genuinely be needed for its intended purpose. An application that also needs locale variants for script selection would have a conflict.


The changes are in two commits on the improved-component-routing branch.

Commit 1 — Introduce the component parameter

ErrorHandler.getErrorMessage() now takes an explicit component argument:

public String getErrorMessage(String component, String key, UserContext context)

The bundle base name is composed from the component:

String bundleName = "dev.morling.links." + component + ".LinksMessages";

The locale is used only to select the language — the variant is no longer set.

Commit 2 — Simplify the providers and control

Since the bundle name now identifies the component, the providers and controls no longer need to extract a component from the locale variant. They simply redirect the lookup from the public bundle name to the module’s internal package:

  • GreenKeepingMessagesProvider.getBundle() and TournamentMessagesProvider.getBundle() replace .LinksMessages with .internal.LinksMessages in the base name.
  • LinksMessagesControl.toBundleName() does the same for the classpath case.

The message key lookup uses component + "." + key to match the existing properties file format (e.g., greenkeeping.greenclosed).


Why explicit ServiceLoader invocation is required in module mode

Section titled “Why explicit ServiceLoader invocation is required in module mode”

This is the most important — and least obvious — consequence of the change.

In the original implementation, ErrorHandler calls ResourceBundle.getBundle("dev.morling.links.core.LinksMessages"). When this call is made from a named module, the JDK applies a naming convention to derive a service interface: it inserts .spi. before the last element of the bundle name and appends Provider, yielding dev.morling.links.core.spi.LinksMessagesProvider. It then consults the ServiceLoader for implementations of that interface automatically — no explicit ServiceLoader call is needed in ErrorHandler.

After the change, the bundle name becomes dev.morling.links.greenkeeping.LinksMessages. Applying the same convention yields dev.morling.links.greenkeeping.spi.LinksMessagesProvider. No provider implements that interface, so the implicit lookup finds nothing.

The providers implementing dev.morling.links.core.spi.LinksMessagesProvider are still available, but the JDK’s automatic mechanism no longer connects them to the new bundle name. The fix is to call ServiceLoader explicitly in the module-path version of ErrorHandler:

for (LinksMessagesProvider provider : ServiceLoader.load(LinksMessagesProvider.class)) {
ResourceBundle bundle = provider.getBundle(bundleName, context.getLocale());
if (bundle != null) {
return "[User: " + context.getName() + "] " + bundle.getString(component + "." + key);
}
}

The changes described here address the two limitations without altering the overall architecture: a central ErrorHandler in core reaches into the greenkeeping and tournament modules to retrieve translated messages.

It is worth asking whether that central reach-back is itself the right design. An alternative would give each module responsibility for producing its own translated messages. The core module would provide common formatting, logging, and any translation of shared elements (such as the "[User: X]" prefix), while each component module would expose a method to retrieve its own error messages in the appropriate locale. This inverts the dependency: instead of core pulling from the modules, the caller assembles the final message from components.

Whether this design is preferable depends on how much control the central component needs over the final message format. But if the goal is to keep modules genuinely self-contained, it is worth considering.