Skip to content

Move i18n onto Spring Boot's MessageSource - #16102

Open
codeconsole wants to merge 15 commits into
apache:8.0.xfrom
codeconsole:feature/i18n-boot-message-source-8.0.x
Open

Move i18n onto Spring Boot's MessageSource#16102
codeconsole wants to merge 15 commits into
apache:8.0.xfrom
codeconsole:feature/i18n-boot-message-source-8.0.x

Conversation

@codeconsole

Copy link
Copy Markdown
Contributor

Grails registered its own messageSource bean (PluginAwareResourceBundleMessageSource), so Spring Boot's MessageSourceAutoConfiguration backed off. Grails then discovered bundles by scanning classpath*:*.properties at runtime — which GraalVM native images cannot do, and which costs start-up time on the JVM. spring.messages.* was ignored entirely as a result.

Spring Boot now owns the messageSource bean outright. Grails supplies only the one thing Boot cannot know: which base names the application and its plugins contribute. That is recorded at build time, so nothing scans the classpath at runtime.

spring.messages.* now works

All of Boot's message-source properties apply, where previously they were silently ignored:

spring:
    messages:
        encoding: UTF-8
        cache-duration: 5s
        fallback-to-system-locale: false
        use-code-as-default-message: true

Grails contributes its own defaults at the lowest precedence — fallback-to-system-locale: false, spring.messages.encoding from grails.views.gsp.encoding, and a short cache-duration in development so bundle edits still reload without a restart.

grails.i18n.cache.seconds and grails.i18n.filecache.seconds are removed in favour of spring.messages.cache-duration.

Base names are discovered for you

You do not normally set spring.messages.basename. The Grails Gradle plugin records each artifact's bundles into META-INF/grails/i18n.properties, and an EnvironmentPostProcessor composes the effective list at boot: your application's base names first, then each plugin's.

Setting it yourself still works and is how you reach a bundle outside grails-app/i18n — whatever you declare is kept and outranks the discovered names:

spring:
    messages:
        basename: config/i18n/custom

Plugin bundles must be namespaced

A plugin's base names must be its plugin name, or that name followed by a hyphen:

grails-app/i18n/spring-security-core.properties
grails-app/i18n/spring-security-core_fr.properties
grails-app/i18n/spring-security-core-validation.properties

Spring resolves a base name to the first matching resource on the classpath, so two plugins sharing a base name would shadow one another rather than merge. The plugin build now rejects a bundle outside the plugin's namespace. Applications are unaffected — there is only one application, so its base names cannot collide.

Within this repository that meant renaming three bundles: spring-security-oauth2, spring-security-ui (whose dotted messages.spring-security-ui base name is unreachable under ResourceBundle, which maps . to /), and the loadfirst test plugin.

Ambiguous file names

An application may use any base name, with one reservation: a name ending in a valid locale identifier is ambiguous, since api_fr.properties reads as base name api in French. Declare the base name when the inference is wrong:

grails {
    i18n {
        basenames = ['api', 'api_errors']
    }
}

Native image

Because the bundles are known ahead of time, Grails registers the GraalVM resource hints for them — covering plugin base names and any base name an application configures itself, neither of which Boot's own registrar handles (it registers two hardcoded messages* patterns and derives nothing from the configured base names).

Limitations

  • Adding or removing a base name requires a restart. Spring Boot reads the configured base names once, when it builds the message source. Editing values in an existing bundle, and adding a locale file for a base name that already exists, both still work without one.
  • A plugin shipping a root messages.properties no longer contributes messages. It previously acted as a fallback behind the application's bundle; that behaviour depended on Grails' own merge tier and cannot be reproduced under first-match-wins resolution.
  • Every bundle needs a locale-independent file. Boot's auto-configuration only activates when a configured base name has an unsuffixed bundle, and it contributes the whole messageSource bean — so an application shipping only messages_de.properties would have had no message source at all. The build now rejects that rather than letting it fail at runtime.
  • Only grails-app/i18n is indexed. Bundles elsewhere need an explicit spring.messages.basename; they are still covered by the native-image hints, which read the effective property rather than the descriptors.
  • The GraalVM bundle-hint behaviour is not verified by a native test. Hints use resource patterns rather than registerResourceBundle, following Spring Boot's own choice for this case; that is a design signal, not something this PR proves with a native image build.

Inter-plugin precedence follows the reverse of Grails' plugin topological order, preserving what the previous message source did when two plugins define the same code. An application's own bundle always overrides every plugin's.

Grails registered its own messageSource bean (PluginAwareResourceBundleMessageSource),
so Boot's MessageSourceAutoConfiguration backed off. Grails then discovered bundles by
scanning classpath*:*.properties at runtime, which GraalVM native cannot do and which
costs start-up time on the JVM. spring.messages.* was ignored entirely.

Spring Boot now owns the messageSource bean outright. Grails supplies only the one thing
Boot cannot know: which base names the application and its plugins contribute.

- The Grails Gradle plugin records each artifact's bundles in META-INF/grails/i18n.properties,
  normalising base names and locales once at build time. Ambiguous file names fail the build,
  with grails { i18n { basenames } } as the escape hatch.
- Plugin base names must sit in the plugin's own namespace (<plugin-name> or <plugin-name>-*),
  so they cannot shadow one another under first-match-wins resolution.
- I18nEnvironmentPostProcessor composes spring.messages.basename from those descriptors:
  application first, then plugins in reverse topological order, preserving the precedence
  the old merge-based message source had.
- Locale discovery and native-image hints read the same effective descriptor set, so a
  filtered or evicted plugin can neither resolve messages nor advertise its locales.
- I18nRuntimeHintsProcessor registers resource patterns for the effective base names,
  covering plugin bundles and application-configured base names that Boot's own registrar
  (two hardcoded messages* patterns) does not.

Removes PluginAwareResourceBundleMessageSource and the ReloadableResourceBundleMessageSource
fork. Renames three bundles that cannot work under ResourceBundle semantics:
spring-security-oauth2, spring-security-ui (a dotted base name is unreachable) and the
loadfirst test plugin.
Covers the descriptor reader's classpath-ambiguity rejection, the environment
post processor's composition and ordering, the AOT hint patterns including
dotted base-name normalisation, and the Gradle task's plugin namespace check.

The precedence regression test arranges topological and load order to differ,
which is what caught EffectiveI18nDescriptors matching descriptor names against
plugin names raw: descriptors record the hyphenated form (spring-security-core)
while a discovered plugin reports the logical camel-case form
(springSecurityCore), so every multi-word plugin's bundles were being dropped.
Both sides now normalise through PluginUtils.normalizePluginName.
…onally

Three review findings, all valid:

Development reload was broken by default. ResourceBundleMessageSource caches every
bundle in a map of its own when no cache duration is set, which
ResourceBundle.clearCache cannot reach - that only clears the JDK cache. The
previous Grails message source applied grails.i18n.cache.seconds automatically in
reload mode; dropping it meant editing a bundle had no effect until a restart. The
environment post processor now contributes a short spring.messages.cache-duration
when reload is enabled, at the lowest precedence so an application can still
override it. MessageSourceReloadSpec pins both halves: the edit is invisible
without a duration, visible with one.

Grails' message-source defaults were skipped for an application with no descriptor.
An application may point spring.messages.basename outside grails-app/i18n and so
have a message source but no generated descriptor; it was falling back to Boot's
own defaults rather than Grails'. The defaults are now contributed before the
empty-descriptor return.

The Gradle integration was only tested by invoking the task action directly.
I18nDescriptorFunctionalSpec drives it through the plugins an author actually
applies, covering automatic registration, application-versus-plugin dispatch,
plugin-name derivation from the descriptor class, the grails i18n block,
processResources output, and regeneration when a bundle is removed.
@codeconsole
codeconsole marked this pull request as draft August 6, 2026 04:07
spring-security-core is the case a single-word plugin cannot exercise: its
descriptor records the hyphenated spring-security-core while the discovered
plugin reports the logical springSecurityCore. Comparing those without
normalising drops every multi-word plugin's bundles silently - messages simply
stop resolving, with no error. No existing test resolved a springSecurity.* code,
so the full suite passed while that path was unverified.
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.73077% with 57 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.7510%. Comparing base (321c9de) to head (992084a).
⚠️ Report is 56 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% 17 Missing ⚠️
...gradle/plugin/core/GrailsPluginGradlePlugin.groovy 0.0000% 7 Missing ⚠️
...g/grails/gradle/plugin/i18n/I18nBundleIndex.groovy 86.7924% 0 Missing and 7 partials ⚠️
...roovy/org/grails/plugins/i18n/I18nDescriptors.java 87.5000% 2 Missing and 4 partials ⚠️
...ils/plugins/i18n/I18nEnvironmentPostProcessor.java 89.2857% 0 Missing and 6 partials ⚠️
...adle/plugin/i18n/GenerateI18nDescriptorTask.groovy 86.8421% 1 Missing and 4 partials ⚠️
...g/grails/gradle/plugin/core/GrailsExtension.groovy 20.0000% 4 Missing ⚠️
...g/grails/plugins/i18n/AvailableLocaleResolver.java 66.6667% 1 Missing and 3 partials ⚠️
...grails/plugins/i18n/I18nRuntimeHintsProcessor.java 96.2963% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16102        +/-   ##
==================================================
+ Coverage     52.4647%   52.7510%   +0.2863%     
- Complexity      18321      18491       +170     
==================================================
  Files            2032       2043        +11     
  Lines           96301      96421       +120     
  Branches        16836      16836                
==================================================
+ Hits            50524      50863       +339     
+ Misses          38349      38147       -202     
+ Partials         7428       7411        -17     
Files with missing lines Coverage Δ
...core/src/main/groovy/grails/config/Settings.groovy 100.0000% <ø> (ø)
...grails/gradle/plugin/core/GrailsI18nOptions.groovy 100.0000% <100.0000%> (ø)
.../grails/plugins/i18n/EffectiveI18nDescriptors.java 100.0000% <100.0000%> (ø)
...groovy/org/grails/plugins/i18n/I18nDescriptor.java 100.0000% <100.0000%> (ø)
...vy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy 52.7778% <100.0000%> (+2.1449%) ⬆️
...grails/plugins/i18n/I18nRuntimeHintsProcessor.java 96.2963% <96.2963%> (ø)
...g/grails/gradle/plugin/core/GrailsExtension.groovy 50.9804% <20.0000%> (-3.3674%) ⬇️
...g/grails/plugins/i18n/AvailableLocaleResolver.java 82.3529% <66.6667%> (-10.2396%) ⬇️
...adle/plugin/i18n/GenerateI18nDescriptorTask.groovy 86.8421% <86.8421%> (ø)
...roovy/org/grails/plugins/i18n/I18nDescriptors.java 87.5000% <87.5000%> (ø)
... and 4 more

... and 20 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Translated into spring.messages.cache-duration, with a deprecation warning and
config metadata pointing at the replacement.

Deliberately faithful to the old behaviour rather than to the property's name:
the previous message source applied cacheSeconds only inside its reload check, so
mapping it unconditionally would quietly start re-reading bundles in production
applications that have cached them forever. The value therefore applies only when
reload is enabled, while the warning is emitted wherever the property is set.
An explicit spring.messages.cache-duration always wins.

No compatibility is added for plugin artifacts built before the descriptor
existed, root-level plugin bundles, runtime classpath scanning, or the removed
message-source classes.

grails.i18n.filecache.seconds is not aliased - it tuned the file-timestamp
re-check of the message source Grails used to own, and Boot's has no equivalent.
…compatibility

Every failure now ends with a reference to the upgrade section, so a plugin
author hitting one does not have to re-derive the naming rules from a one-line
error. The namespace failure suggested '<plugin>-messages.properties' as the
rename, which is not what any plugin would want; it now suggests the plugin's own
base name, with the multi-bundle and locale-variant forms alongside.

Also documents that a namespaced bundle works unchanged under Grails 7, so one
source branch can support both. Verified against Grails 7's discovery: it scans
the plugin jar root for *.properties and matches locale suffixes without ever
inspecting the base name, which is why spring-security-core.properties has worked
there all along.
The table claimed grails.i18n.filecache.seconds maps to
spring.messages.cache-duration, contradicting the paragraph below it. It has no
equivalent and is ignored.

The lead-in also said both settings were removed, when grails.i18n.cache.seconds
is deprecated and still honoured. A status column now carries that, so the table
is correct read on its own.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Spring Boot MessageSource Migration: Comparison

Even without native-image/AOT support, the new implementation is a net improvement architecturally and operationally, with a few meaningful compatibility and development-workflow costs.

Better than the previous implementation

Standard Spring Boot behavior

Previously Grails replaced Boot's message source, so standard properties such as these were mostly irrelevant:

spring.messages.basename
spring.messages.encoding
spring.messages.cache-duration
spring.messages.fallback-to-system-locale
spring.messages.always-use-message-format
spring.messages.use-code-as-default-message
spring.messages.common-messages

The new implementation delegates resolution to Boot's stock ResourceBundleMessageSource, making Grails behave like a normal Boot application and reducing custom framework behavior users must learn.

Much less Grails-owned code

It deletes roughly 1,000 lines from PluginAwareResourceBundleMessageSource and Grails' fork of ReloadableResourceBundleMessageSource. Those classes duplicated caching, merging, discovery, formatting, and plugin-specific loading behavior that Spring already handles.

The replacement is primarily integration metadata:

bundle files → generated descriptor → Boot basename configuration

No runtime wildcard scanning

Previously Grails searched for classpath*:*.properties, while binary plugins scanned their resource roots for *.properties. Gradle now records known bundles during the build, and runtime discovery uses the exact descriptor name:

META-INF/grails/i18n.properties

Benefits beyond AOT include:

  • lower startup work;
  • unrelated properties files cannot accidentally become message basenames;
  • no searchClasspath performance compromise;
  • behavior no longer depends on resource-pattern resolver peculiarities.

Deterministic discovery and precedence

The new implementation explicitly defines:

user-configured basenames
→ application bundles
→ plugins in reverse topological order

It preserves the legacy winner for plugin message-code collisions while making the rule testable. Classloader enumeration order no longer determines behavior.

Better plugin isolation

Plugins now use namespaced bundles such as:

spring-security-core.properties
spring-security-core-validation.properties

rather than all shipping messages.properties. This prevents silent shadowing and makes bundle ownership obvious. Multiple logical bundles per plugin remain supported.

Consistent encoding

The old application and plugin paths did not necessarily use the same encoding. Binary plugin messages could use the platform encoding while application messages used configured UTF-8. Boot now applies one spring.messages.encoding value consistently to all basenames.

Better configuration capabilities

Boot supplies useful behavior Grails did not cleanly expose, particularly:

spring.messages.common-messages
spring.messages.always-use-message-format
spring.messages.use-code-as-default-message

Applications can also add explicit basenames outside grails-app/i18n without replacing the generated application and plugin list.

For migration convenience, grails.i18n.cache.seconds remains temporarily supported in reload mode. Grails translates it to spring.messages.cache-duration, emits a deprecation warning, and lets an explicitly configured Boot property win. This preserves an existing development-time tuning without retaining the old message-source implementation.

Available locales match actual resolution

Previously locale discovery independently scanned resources and could disagree with the effective plugin set. Now basename composition and AvailableLocaleResolver use the same descriptor model. Filtered, evicted, failed, or deliberately excluded plugins cannot advertise locales whose messages will not resolve.

Earlier failure for invalid packaging

The Gradle build now rejects:

  • locale-only bundles without a base file;
  • colliding plugin basenames;
  • malformed or ambiguous locale naming;
  • duplicate plugin or application descriptors.

Previously many such problems appeared only as missing or shadowed messages at runtime.

Stronger tests

The new implementation covers real Gradle application and plugin wiring, descriptor regeneration, plugin-name derivation, custom declarations, namespace enforcement, precedence, formatted and unformatted lookups, plugin filtering, reload behavior, and configuration precedence.

Worse or more restrictive

Existing plugin artifacts must be rebuilt and renamed

This is the biggest cost. A plugin that currently ships messages.properties must move to a namespaced basename. Old plugin artifacts without generated descriptors no longer contribute messages automatically.

Backward binary compatibility is explicitly out of scope, but this is still an ecosystem migration.

The source migration is less restrictive than the artifact break suggests: namespaced bundles also work with Grails 7's classpath-scanning implementation. A plugin can therefore rename its bundles once and build the same source for both Grails 7 and Grails 8; only the Grails 8 artifact gains and requires the generated descriptor.

Bundle naming is more constrained

The build must infer whether an underscore introduces a locale. For example, api_fr.properties could mean basename api in French or unsuffixed basename api_fr. The new implementation reserves valid locale suffixes and requires explicit configuration for ambiguous cases:

grails {
    i18n {
        basenames = ['api_fr']
    }
}

This is deterministic, but requires more ceremony than the previous permissive scanning model.

More build-time coupling

Message availability now depends on the Grails Gradle plugin generating and packaging the descriptor. Nonstandard build systems must reproduce the descriptor format, and malformed descriptors fail startup. This trades runtime flexibility for build-time correctness.

Structural reload is weaker

Editing an existing bundle still reloads automatically. However:

  • a new locale requires descriptor regeneration before appearing in <g:localeSelect>;
  • a new basename requires restart;
  • removing a basename may stop resolution after cache invalidation while its metadata remains until restart.

A newly added basename already effectively required reinitialization previously, so the available-locale descriptor dependency is the clearest development-time regression.

More startup bootstrap integration

The custom message source disappears, but the new system introduces an EnvironmentPostProcessor, descriptor parsing, effective plugin filtering, a Gradle descriptor task, and an AOT processor. This is less semantic duplication overall, but behavior now spans build time, bootstrap, and application context.

Strict descriptor failures may expose unusual classpaths

The new code rejects multiple application descriptors, duplicate plugin descriptors, and unsupported descriptor versions. This improves correctness but may expose ambiguous layered applications or unusual test fixtures that previously started.

ResourceBundleMessageSource is less reload-oriented

Boot uses ResourceBundleMessageSource, not ReloadableResourceBundleMessageSource. Grails compensates by setting a short cache duration in reload mode and clearing JDK bundle caches. This is tested, but less direct than calling clearCache() on a reloadable source.

Overall comparison

Area Previous New implementation
Message implementation Grails-specific Standard Boot/Spring
Discovery Runtime wildcard scans Build-generated descriptors
Plugin collision handling Runtime merge Unique basenames
Configuration Grails properties/custom behavior spring.messages.*
Encoding Potential app/plugin mismatch Unified
Startup cost Classpath/resource scanning Exact descriptor lookup
Error detection Often runtime or silent Build/startup failure
Existing plugin compatibility Broad artifact compatibility Rebuild required; migrated source can support Grails 7 and 8
Content hot reload Supported Supported
Structural hot reload Limited but more dynamic locale scan Descriptor/restart dependent
Nonstandard builds More tolerant Must generate metadata
Maintenance Large custom message-source stack Smaller integration layer

Verdict

Excluding AOT, the new implementation is still substantially better for framework maintainability, startup determinism, configuration consistency, and failure diagnostics.

Its main disadvantages are the deliberate old-artifact compatibility break and the shift from permissive runtime discovery to stricter build-time metadata. The temporary cache-property bridge and cross-version-compatible source layout reduce upgrade friction without carrying the old message-source implementation forward. For a major-version modernization where backward plugin artifact compatibility is explicitly not required, that is a favorable trade.

@codeconsole
codeconsole marked this pull request as ready for review August 6, 2026 20:27
@codeconsole
codeconsole requested review from jdaugherty, matrei and sbglasius and removed request for matrei August 6, 2026 20:27
@sbglasius

sbglasius commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

This is a yellow flag ⚠️ Existing plugin artifacts must be rebuilt and renamed as we are still struggling to keep up with plugin changes.

Would it be possible for the code to automatically prefix bundles coming from a plugin with the plugin-name. That way it might be possible to use plugins without rebuilding?

for (String pluginName : pluginNamesInTopologicalOrder) {
I18nDescriptor descriptor = pluginDescriptors.get(PluginUtils.normalizePluginName(pluginName));
if (descriptor != null) {
effectivePlugins.add(descriptor);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin descriptors are keyed by normalized name in a plain HashMap via put(), so two discovered plugins whose names normalize to the same key (e.g. differing only in hyphenation/case) silently overwrite one another instead of erroring.

Two plugins on the classpath (e.g. "springSecurityCore" reported by PluginDiscovery vs. a differently-cased/hyphenated variant recorded in another plugin's descriptor) normalize to the same key; the second put() silently drops the first plugin's basenames/locales from message resolution and the language selector, with no warning — unlike I18nDescriptors.rejectAmbiguousClasspath, which only compares raw (unnormalized) names and would not catch this case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 0221bb0.

The two sites used different key forms: this class keys plugins by normalized name, while I18nDescriptors.rejectAmbiguousClasspath counted raw ones. So spring-security-core and springSecurityCore passed the ambiguity check as distinct, then collapsed to one key here and lost a plugin's basenames and locales to a silent put() — the classpath-order-dependent outcome that check exists to prevent. It now counts by normalized name and reports both raw spellings.

One detail: case alone doesn't collide. normalizePluginName only rewrites names containing a hyphen and returns others untouched, so SpringSecurityCore stays distinct from springSecurityCore. The reachable collision is hyphenated vs camel-case.

Severity is low in practice — the Gradle plugin always emits the hyphenated form via GrailsNameUtils.getPluginName, so two same-named plugins already collided on the raw name and were caught. Reaching this needs a hand-edited or foreign-tool descriptor. Worth fixing regardless, since the check's javadoc claims it rejects "two plugins sharing a name", and under the matching semantics that actually apply, these do.

Covered by two tests: the collision throws naming both spellings, and genuinely distinct plugins still load.

@bito-code-review

Copy link
Copy Markdown

The issue described, where plugins with normalized names that collide silently overwrite each other in a HashMap, is a known limitation in the current implementation of plugin descriptor discovery. Since the put() method on a standard HashMap does not check for existing keys, the second plugin effectively replaces the first, leading to lost message bundles and incorrect language selector behavior.

To address this, the plugin discovery mechanism should be updated to use a map implementation that detects collisions or to explicitly check for existing keys before inserting. For example, using compute() or putIfAbsent() with a check could allow you to log a warning or throw an exception when a collision is detected, ensuring that ambiguous plugin names are identified rather than silently ignored.

Descriptors are keyed by normalised name when matched to discovered plugins, but
the ambiguity check compared raw names. Two descriptors named
spring-security-core and springSecurityCore therefore passed the check and then
collapsed to one key downstream, where a plain put() dropped one plugin's
basenames and locales with no warning - the silent, classpath-order-dependent
outcome that check exists to prevent.

The check now counts by normalised name and reports both raw spellings.

Only reachable from a hand-edited or foreign-tool descriptor, since the Gradle
plugin always emits the hyphenated form via GrailsNameUtils.getPluginName. Fixed
anyway because it is the same class of bug the check was written for, and because
the check's own contract says it rejects two plugins sharing a name - which, under
the matching semantics that actually apply, these do.

Note normalizePluginName only rewrites hyphenated names, so case alone does not
collide; the reachable collision is hyphenated versus camel case.
…-8.0.x' into feature/i18n-boot-message-source-8.0.x
@testlens-app

testlens-app Bot commented Aug 14, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 992084a
▶️ Tests: 68298 executed
⚪️ Checks: 77/77 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants