Skip to content

Move i18n onto Spring Boot's MessageSource - #16102

Open
codeconsole wants to merge 11 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 11 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 90.81081% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.7399%. Comparing base (3de06ee) to head (3a1c222).
⚠️ Report is 11 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...roovy/org/grails/plugins/i18n/I18nDescriptors.java 86.6667% 2 Missing and 4 partials ⚠️
...ils/plugins/i18n/I18nEnvironmentPostProcessor.java 89.2857% 0 Missing and 6 partials ⚠️
...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.3410%   53.7399%   +1.3988%     
+ Complexity      18293      17987       -306     
==================================================
  Files            2036       1973        -63     
  Lines           96347      92343      -4004     
  Branches        16829      16125       -704     
==================================================
- Hits            50429      49625       -804     
+ Misses          38493      35475      -3018     
+ Partials         7425       7243       -182     
Files with missing lines Coverage Δ
...core/src/main/groovy/grails/config/Settings.groovy 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/plugins/i18n/AvailableLocaleResolver.java 82.3529% <66.6667%> (-10.2396%) ⬇️
...roovy/org/grails/plugins/i18n/I18nDescriptors.java 86.6667% <86.6667%> (ø)
...ils/plugins/i18n/I18nEnvironmentPostProcessor.java 89.2857% <89.2857%> (ø)

... and 73 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.

@testlens-app

testlens-app Bot commented Aug 6, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 3a1c222
▶️ Tests: 46171 executed
⚪️ Checks: 62/62 completed


Learn more about TestLens at testlens.app.

@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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant