Register plugin beans before Spring Boot auto-configuration (doWithSpringBeforeAutoConfiguration)#15755
Open
codeconsole wants to merge 20 commits into
Open
Conversation
@EnableWebMvc is a Spring Framework annotation that is not meant to be combined with Spring Boot. Importing it (via DelegatingWebMvcConfiguration) disables Boot's WebMvcAutoConfiguration MVC setup (which is @ConditionalOnMissingBean(WebMvcConfigurationSupport)) and registers MVC infrastructure beans in the user-configuration phase, ahead of all auto-configuration. A concrete symptom is the localeResolver bean: WebMvcConfigurationSupport registers an AcceptHeaderLocaleResolver in the user phase, so grails-i18n's @AutoConfigureBefore(WebMvcAutoConfiguration) SessionLocaleResolver can only ever override it (triggering a bean-definition-override) rather than supersede it cleanly. Remove the auto-injected @EnableWebMvc so Boot's WebMvcAutoConfiguration drives MVC setup.
…ctive
With @EnableWebMvc no longer auto-injected, Boot's WebMvcAutoConfiguration becomes
active and registers an OrderedRequestContextFilter (order -105). That runs between
GrailsWebRequestFilter (-150) and the Spring Security chain (-100) and rebinds a plain
ServletRequestAttributes, so security-chain code that expects a GrailsWebRequest fails
with a ClassCastException (HTTP 500 on every request).
Boot's filter is @ConditionalOnMissingBean({RequestContextListener, RequestContextFilter}),
so make GrailsWebRequestFilter extend RequestContextFilter (it already overrides
shouldNotFilterAsyncDispatch/ErrorDispatch identically and binds a richer request context)
and expose it as a bean that the FilterRegistrationBean wraps. Boot then backs off in
favour of Grails' request-context binding.
With @EnableWebMvc no longer auto-injected, Spring Boot's WebMvcAutoConfiguration is active and contributes a defaultViewResolver (InternalResourceViewResolver). For a Grails app that does not use JSP/InternalResource views (e.g. a REST/JSON-views app) this catch-all resolves any unmatched view name to a servlet forward, yielding "Circular view path [index]: would dispatch back to the current handler URL" (HTTP 500). grails-gsp already strips this bean for GSP apps via its own registrar, but a JSON-only app never loads grails-gsp. Add a core auto-configuration (ordered after WebMvcAutoConfiguration) that removes defaultViewResolver for every Grails servlet web application, so Grails' own view resolution is used. Disable with grails.web.removeDefaultViewResolverBean=false. Fixes the graphql, issue-views-182 and views-functional-tests example apps.
GrailsViewResolverAutoConfiguration (grails-controllers) already removes Boot's defaultViewResolver for every Grails servlet web app, so grails-gsp's registrar no longer needs to do it too. Drop the duplicate defaultViewResolver removal (and its spring.gsp.removeDefaultViewResolverBean flag) from grails-gsp, leaving only the GSP-specific behaviour: replacing the viewResolver bean with an alias to gspViewResolver. Rename the registrar to ReplaceViewResolverRegistrar to reflect its remaining role. No behaviour change: GSP apps still have defaultViewResolver removed (now via the core auto-configuration) and gspViewResolver aliased as viewResolver.
Introduces a modern, Spring Boot-aligned plugin bean-registration phase that runs BEFORE auto-configuration, so Boot beans guarded by @ConditionalOnMissingBean defer to the plugin's bean instead of the plugin having to override or remove the Boot bean afterwards (the legacy behaviour of doWithSpring, which runs after auto-config via GrailsApplicationPostProcessor). - GrailsApplicationLifeCycle.doWithSpringBeforeAutoConfiguration() (default no-op) + Plugin override - GrailsPlugin/GrailsPluginManager drain plumbing for the new phase - GrailsBeforeAutoConfigurationPostProcessor: a BeanDefinitionRegistryPostProcessor added via addBeanFactoryPostProcessor (so it runs ahead of ConfigurationClassPostProcessor, which expands the @autoConfiguration imports), draining the new closures into the registry - GrailsBeforeAutoConfigurationInitializer wires it in (spring.factories ApplicationContextInitializer) The legacy doWithSpring phase is unchanged; this is additive and opt-in. Compiles clean; runtime defer-behaviour validation is the next step.
Self-contained proof of the ordering linchpin behind the doWithSpringBeforeAutoConfiguration phase: a BeanDefinitionRegistryPostProcessor added via addBeanFactoryPostProcessor registers beans before ConfigurationClassPostProcessor evaluates @ConditionalOnMissingBean, so a Boot auto-config bean defers to the plugin's bean. Includes a control showing the conditional bean is created when nothing registers it early.
…bean Adds DeferrableOverrideWarner: when a late doWithSpring bean overrides a Spring Boot auto-configuration bean that is name-guarded @ConditionalOnMissingBean (one that would have deferred), emit a one-line nudge toward doWithSpringBeforeAutoConfiguration. Wired into GrailsApplicationPostProcessor before the late beans flush; gated on grails.plugins.warnOnDeferrableOverride (default true). Deliberately narrow — silent on the permanent, legitimate late-registration uses (decoration, aggregation, artefact-driven beans, intentional overrides of unconditional beans) and on type-guarded conditionals (override-by-name doesn't prove the type condition would have matched). So it flags only the one anti-pattern the modern phase removes. Test asserts: name-guarded override flagged; type-guarded and unconditional left alone.
Investigated sharing one plugin manager + GrailsApplication across the early and late phases to avoid the second (lightweight) plugin instantiation. Rejected it: the late doWithSpring closures bind `application` from DefaultGrailsPlugin's own grailsApplication field, which GrailsPluginManager.setApplication() does not update (it sets AbstractGrailsPlugin.application) — so rebinding the early-loaded plugins to the full GrailsApplication leaves the closures pointing at the un-initialised minimal app (No such property: tagLibClasses). Plugins hold multiple GrailsApplication references; rebinding mid-lifecycle is fragile. The robust design is the prototype's: the early phase does an isolated, side-effect-free lightweight plugin load, fully decoupled from the main lifecycle (no Holders/global-state pollution). The second plugin instantiation is cheap and benign. Documents the contract on doWithSpringBeforeAutoConfiguration: define bean definitions only, no side effects.
…olation doRuntimeConfigurationBeforeAutoConfiguration now (1) respects plugin enablement for the current scope/environment via isEnabled(activeProfiles) — matching the late doRuntimeConfiguration so a plugin disabled for the active profile does not contribute early beans — and (2) wraps each plugin's closure in try/catch, rethrowing as GrailsConfigurationException with the plugin name so a failure is attributable rather than aborting the boot opaquely.
#7: the before-auto-config phase now builds grailsApplication.config with the same conversion service + converters the main lifecycle registers (String->Resource, NullSafeNavigator->null x2), so early closures read config identically — null-safe navigation and resource coercion, not just scalar getProperty(). #5: documented that the early phase is plugin-scoped. An application class does not need it: it is a @configuration, and Spring Boot processes user configuration before auto-configuration, so a plain @bean already registers ahead of auto-config and @ConditionalOnMissingBean beans defer to it. Draining the app class early is also unsafe (its grailsApplication is not available yet).
app3 BeforeAutoConfigPhaseSpec (@Integration, real GrailsApp boot) asserts that the loadafter plugin's doWithSpringBeforeAutoConfiguration bean is registered through real plugin discovery and the early drain. Exercises the actual GrailsBeforeAutoConfigurationPostProcessor in a real boot (not a stub), complementing the unit tests that prove the @ConditionalOnMissingBean defer mechanism. Runs as part of the CI Functional Tests matrix.
Adds a "Registering Beans Before Auto-Configuration" section to the plugins guide (hookingIntoRuntimeConfiguration.adoc): when to use doWithSpringBeforeAutoConfiguration vs doWithSpring (the two permanent phases), the bean-definition-only/no-side-effects contract, the app-class-uses-@bean note, and the grails.plugins.warnOnDeferrableOverride flag.
First real framework site moved onto the overridable pattern the before-auto-config phase enables. localeResolver, localeChangeInterceptor and messageSource in I18nAutoConfiguration are now @ConditionalOnMissingBean by name. They still win by default (grails-i18n is @AutoConfigureBefore WebMvc/MessageSource auto-config, so it registers first — Boot's localeResolver defers to it), but are now cleanly overridable by a plugin's doWithSpringBeforeAutoConfiguration bean or an app @bean, retiring the "unconditional framework bean that can only be replaced via an override warning" pattern. This is what PR apache#15751 attempted and could not achieve alone — it works here because the base PR (apache#13863) removed the injected @EnableWebMvc, so WebMvcAutoConfiguration is active and grails-i18n's before-ordering makes it win the @ConditionalOnMissingBean race. Validated: app1 boots green; conditions report shows I18nAutoConfiguration#localeResolver created and Boot's WebMvc localeResolver deferred.
- (Critical-ish) Fix latent double ApplicationListener registration: the early phase no longer propagates the real context to its throwaway plugin instances (manager context left unset; the drain reads active profiles from the GrailsApplication's main context instead). Null-guard DefaultGrailsPlugin.setApplicationContext's listener registration. A plugin implementing ApplicationListener is now registered only once, by the real lifecycle. - Make the app3 integration test actually prove DEFERRAL: Application now defines a @ConditionalOnMissingBean(name='beforeAutoConfigProbe') default and the spec asserts the plugin's value wins (the default deferred), not just bean presence. Fixed the javadoc (loadafter, not loadfirst; the default does exist). - Drop the inert PriorityOrdered/getOrder() from the BDRPP — Spring does not sort manually-added post-processors by getOrder(); ordering comes solely from addBeanFactoryPostProcessor. Documented. - Guard plugin-discovery lookup on the LOCAL singleton (getSingleton) so a parent context's discovery can't trigger the early drain in a child context. Validated: grails-core compiles + full :grails-core:test green; app3 deferral integration test passes; app1 (GSP) boots clean.
- Remove the vague "Registered plugin beans..." LOG.debug (leftover from prototype proof-marker debugging) from GrailsBeforeAutoConfigurationPostProcessor, and the now-unused Logger field/imports. - Drop "// no-op by default" inline comments from the GrailsPlugin/GrailsPluginManager default methods — the javadoc already states the default is a no-op.
…issingBean) grailsCorsFilter, urlMappingsErrorPageCustomizer and urlMappingsInfoHandlerAdapter are Grails-specific beans with no Spring Boot equivalent, so adding @ConditionalOnMissingBean is zero-risk: they still register by default (verified: all three "matched" in app1's conditions report) but an app/plugin can now provide its own without an override warning.
…nalOnMissingBean) grailsWebRequestFilter (the FilterRegistrationBean) and webMvcConfig (GrailsWebMvcConfigurer) are Grails-specific, so @ConditionalOnMissingBean is zero-risk (both "matched" in app1's conditions report — created by default, now overridable). Deliberately NOT migrated: dispatcherServlet / dispatcherServletRegistration (GrailsDispatcherServlet intentionally REPLACES Boot's; ControllersAutoConfiguration is only @AutoConfigureBefore WebMvcAutoConfiguration, not DispatcherServletAutoConfiguration, so making it conditional risks deferring to Boot's plain DispatcherServlet) and multipartConfigElement (Boot's MultipartAutoConfiguration provides a @ConditionalOnMissingBean one; same ordering risk would lose Grails' config-driven settings). Those are the "intentional override of a Boot bean" category that legitimately stays.
…filter, search=CURRENT - C1 (complete the earlier listener-leak fix): the throwaway DefaultGrailsApplication was still registering as an ApplicationListener on the real context via setApplicationContext. The drain only needs the app's config, so the early phase now gives it NO context at all (drops setApplicationContext + setMainContext); plugins were already not given the context. Impact was benign (the app listener only sets an inert flag on a GC'd object), but it's the same bug class the earlier review fixed for plugins, now closed on the GrailsApplication path too. - C3 (remove dead code): doRuntimeConfigurationBeforeAutoConfiguration's profile filter was inert — DefaultGrailsPlugin.profiles is never populated (isEnabled() always true), and the runtime profile isn't active this early anyway. Dropped the activeProfiles read entirely; the live supportsCurrentScopeAndEnvironment check + per-plugin error isolation remain. - C4: add search = SearchStrategy.CURRENT to the i18n localeResolver/messageSource/localeChangeInterceptor @ConditionalOnMissingBean guards, matching Boot's own messageSource, so they don't defer to an ancestor context's same-named bean in a parent/child setup. Validated: grails-core compiles + full :grails-core:test green; app3 deferral integration test passes (real drain unaffected by dropping the app context); app1 boots clean.
Addresses the base (apache#13863) review gaps: - GrailsViewResolverAutoConfiguration now honours the legacy spring.gsp.removeDefaultViewResolverBean property (which exists in apache/8.0.x) as a deprecated fallback for grails.web.removeDefaultViewResolverBean, logging a deprecation warning when the old name is used — instead of silently ignoring it. - Adds upgrading80x.adoc section 27 documenting that Boot's WebMvcAutoConfiguration is now active (no auto-injected @EnableWebMvc), what Grails still owns, the defaultViewResolver removal + opt-out flag, and that no action is required for typical apps. The JSON-views path the removal is justified by is already covered by the views-functional-tests example app (JSON/HAL render assertions + CircularSpec for the circular-view-path scenario), which passes on this branch — verified by running :grails-test-examples-views-functional-tests:integrationTest. Note: these harden content from base PR apache#13863; if apache#13863 merges standalone, cherry-pick them there.
- GrailsBeforeAutoConfigurationPostProcessor: remove now-unused grails.core.GrailsApplication / grails.plugins.GrailsPluginManager imports (fully-qualify the javadoc links), and order the springframework import group before the grails group. - DeferrableOverrideWarner / GrailsViewResolverAutoConfiguration: separate the slf4j (*) import group from the springframework group with a blank line. - AbstractGrailsPluginManager: move org.grails.core.exceptions.GrailsConfigurationException to its alphabetical position within the grails import group. - DeferrableOverrideWarner / AbstractGrailsPluginManager: put the string-concatenation '+' at the end of the line (OperatorWrap) in the new log/exception messages. aggregateStyleViolations now reports zero violations in the changed files.
✅ All tests passed ✅🏷️ Commit: fbbefee Learn more about TestLens at testlens.app. |
jdaugherty
requested changes
Jun 24, 2026
jdaugherty
left a comment
Contributor
There was a problem hiding this comment.
I don't think we should be adding another lifecycle event like this PR does. We have previously discussed this as part of the Groovy Bean DSL retirement. Spring introduced a new interface for bean registration, and we should instead just move to initializing the beans at the same time as we do the config. We didn't do this in 7 due to the impact, and we haven't done it in 8 yet due to higher priorities. If you're willing to do this work, let's discuss and I can point you to the tickets / previous discussions on how this should work.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new plugin lifecycle phase,
doWithSpringBeforeAutoConfiguration(), that lets a Grails plugin register Spring beans before Spring Boot auto-configuration runs. Boot beans guarded by@ConditionalOnMissingBeanthen defer to the plugin's bean, instead of the plugin having to override or remove the Boot bean afterwards (the only thing the existingdoWithSpring()can do, because it runs after auto-configuration).This is the root-cause fix for an entire class of friction: the "Overriding bean definition for bean 'localeResolver'" warnings, and the
removeBeanDefinition/alias hacks Grails uses to bend Boot's beans into its own model.Why
GrailsApplicationPostProcessor(which drainsdoWithSpring) is an un-orderedBeanDefinitionRegistryPostProcessor; Boot'sConfigurationClassPostProcessorisPriorityOrderedand expands every@AutoConfigurationfirst. So everydoWithSpringbean is registered after auto-config — a plugin can only override or remove a Boot bean, never make@ConditionalOnMissingBeanback off. That late override is also wasteful: the Boot bean is built, then thrown away.How
A second
BeanDefinitionRegistryPostProcessor(GrailsBeforeAutoConfigurationPostProcessor) is added viaaddBeanFactoryPostProcessor(manually-registered BDRPPs run before registry-discovered ones such asConfigurationClassPostProcessor). It drains each plugin'sdoWithSpringBeforeAutoConfigurationclosure into the registry ahead of auto-config. A narrow, opt-out warning (DeferrableOverrideWarner) nudges the one anti-pattern this removes — a latedoWithSpringbean overriding a name-guarded@ConditionalOnMissingBeanauto-config bean that would have deferred.Two permanent phases (not modern-vs-legacy)
doWithSpringis not deprecated. It stays the correct phase for: decorating/wrapping a bean auto-config already created; aggregating/inspecting the full registry; artefact-driven beans (one per controller/service/taglib — those need the loadedGrailsApplicationand stay late); and intentionally overriding an unconditional Boot bean. Only "register late solely to override a deferrable conditional" moves to the new phase.What's included
doWithSpringBeforeAutoConfiguration()lifecycle method (default no-op onGrailsApplicationLifeCycle/Plugin), the drain plumbing, the BDRPP + initializer (spring.factories).DeferrableOverrideWarner, gated ongrails.plugins.warnOnDeferrableOverride(default on), precise to name-guarded@ConditionalOnMissingBeanoverrides.localeResolver,localeChangeInterceptor,messageSource→@ConditionalOnMissingBean. This is exactly what Make localeResolver overridable via @ConditionalOnMissingBean #15751 attempted and could not achieve alone — it works here because Stop @EnableWebMvc from being automatically added to Grails Applications #13863 removed@EnableWebMvc, so grails-i18n's@AutoConfigureBefore(WebMvcAutoConfiguration)wins the race.grailsCorsFilter,urlMappingsErrorPageCustomizer,urlMappingsInfoHandlerAdapter,grailsWebRequestFilterregistration,webMvcConfig).dispatcherServlet/multipartConfigElementare deliberately left as intentional Boot replacements (they'd need@AutoConfigureBefore(DispatcherServlet/MultipartAutoConfiguration)to stay safe).@ConditionalOnMissingBeandefer (+ control); aDeferrableOverrideWarnerprecision test; and an@Integrationtest (app3) proving a real plugin's bean wins over an app's@ConditionalOnMissingBeandefault through a real boot.Design notes
ApplicationListeneris registered only once.GrailsApplicationacross the two phases. It's fragile —setApplication()doesn't update the fieldDefaultGrailsPluginbindsdoWithSpring'sapplicationfrom, so rebinding breaks artefact handling (tagLibClasses). The isolated lightweight load is the robust design; the second (cheap) plugin instantiation is benign.@Configuration, and Boot processes user config before auto-config, so a plain@Beanalready registers ahead of auto-config. The phase is plugin-scoped.GrailsApplicationPostProcessorruntime-plugin-load +BeanBuildermechanism.Validation
Full
:grails-core:testgreen; app1 (GSP) and app3 (Hibernate) boot clean; the app3 integration test proves deferral end-to-end; every migration verified via the conditions report (bean still created by default, Boot's defers).Suggested rollout
Ship opt-in + the warning → migrate framework beans incrementally (i18n + the Tier-1 set done here) → make the modern phase the default registration timing → remove the legacy override path. Each conversion deletes one Boot-fighting site.