Move i18n onto Spring Boot's MessageSource - #16102
Conversation
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.
… the upgrade guide
…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.
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 Report❌ Patch coverage is Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
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.
Spring Boot MessageSource Migration: ComparisonEven 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 implementationStandard Spring Boot behaviorPreviously 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-messagesThe new implementation delegates resolution to Boot's stock Much less Grails-owned codeIt deletes roughly 1,000 lines from The replacement is primarily integration metadata: No runtime wildcard scanningPreviously Grails searched for Benefits beyond AOT include:
Deterministic discovery and precedenceThe new implementation explicitly defines: It preserves the legacy winner for plugin message-code collisions while making the rule testable. Classloader enumeration order no longer determines behavior. Better plugin isolationPlugins now use namespaced bundles such as: rather than all shipping Consistent encodingThe 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 Better configuration capabilitiesBoot 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-messageApplications can also add explicit basenames outside For migration convenience, Available locales match actual resolutionPreviously locale discovery independently scanned resources and could disagree with the effective plugin set. Now basename composition and Earlier failure for invalid packagingThe Gradle build now rejects:
Previously many such problems appeared only as missing or shadowed messages at runtime. Stronger testsThe 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 restrictiveExisting plugin artifacts must be rebuilt and renamedThis is the biggest cost. A plugin that currently ships 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 constrainedThe build must infer whether an underscore introduces a locale. For example, grails {
i18n {
basenames = ['api_fr']
}
}This is deterministic, but requires more ceremony than the previous permissive scanning model. More build-time couplingMessage 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 weakerEditing an existing bundle still reloads automatically. However:
A newly added basename already effectively required reinitialization previously, so the available-locale descriptor dependency is the clearest development-time regression. More startup bootstrap integrationThe custom message source disappears, but the new system introduces an Strict descriptor failures may expose unusual classpathsThe 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.
|
| 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.
✅ All tests passed ✅🏷️ Commit: 3a1c222 Learn more about TestLens at testlens.app. |
Grails registered its own
messageSourcebean (PluginAwareResourceBundleMessageSource), so Spring Boot'sMessageSourceAutoConfigurationbacked off. Grails then discovered bundles by scanningclasspath*:*.propertiesat 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
messageSourcebean 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 worksAll of Boot's message-source properties apply, where previously they were silently ignored:
Grails contributes its own defaults at the lowest precedence —
fallback-to-system-locale: false,spring.messages.encodingfromgrails.views.gsp.encoding, and a shortcache-durationin development so bundle edits still reload without a restart.grails.i18n.cache.secondsandgrails.i18n.filecache.secondsare removed in favour ofspring.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 intoMETA-INF/grails/i18n.properties, and anEnvironmentPostProcessorcomposes 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:Plugin bundles must be namespaced
A plugin's base names must be its plugin name, or that name followed by a hyphen:
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 dottedmessages.spring-security-uibase name is unreachable underResourceBundle, which maps.to/), and theloadfirsttest 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.propertiesreads as base nameapiin 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
messages.propertiesno 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.messageSourcebean — so an application shipping onlymessages_de.propertieswould have had no message source at all. The build now rejects that rather than letting it fail at runtime.grails-app/i18nis indexed. Bundles elsewhere need an explicitspring.messages.basename; they are still covered by the native-image hints, which read the effective property rather than the descriptors.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.