Skip to content

Register plugin beans before Spring Boot auto-configuration (doWithSpringBeforeAutoConfiguration)#15755

Open
codeconsole wants to merge 20 commits into
apache:8.0.xfrom
codeconsole:feature/plugin-before-autoconfig-phase
Open

Register plugin beans before Spring Boot auto-configuration (doWithSpringBeforeAutoConfiguration)#15755
codeconsole wants to merge 20 commits into
apache:8.0.xfrom
codeconsole:feature/plugin-before-autoconfig-phase

Conversation

@codeconsole

Copy link
Copy Markdown
Contributor

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 @ConditionalOnMissingBean then defer to the plugin's bean, instead of the plugin having to override or remove the Boot bean afterwards (the only thing the existing doWithSpring() 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.

Depends on #13863 ("Stop @EnableWebMvc from being automatically added"). This branch is stacked on it, so the first 4 commits here are from that PR — the diff will shrink to just this feature once #13863 merges. The feature requires #13863: Boot's WebMvcAutoConfiguration must be active for @ConditionalOnMissingBean to have anything to defer.

Why

GrailsApplicationPostProcessor (which drains doWithSpring) is an un-ordered BeanDefinitionRegistryPostProcessor; Boot's ConfigurationClassPostProcessor is PriorityOrdered and expands every @AutoConfiguration first. So every doWithSpring bean is registered after auto-config — a plugin can only override or remove a Boot bean, never make @ConditionalOnMissingBean back off. That late override is also wasteful: the Boot bean is built, then thrown away.

How

A second BeanDefinitionRegistryPostProcessor (GrailsBeforeAutoConfigurationPostProcessor) is added via addBeanFactoryPostProcessor (manually-registered BDRPPs run before registry-discovered ones such as ConfigurationClassPostProcessor). It drains each plugin's doWithSpringBeforeAutoConfiguration closure into the registry ahead of auto-config. A narrow, opt-out warning (DeferrableOverrideWarner) nudges the one anti-pattern this removes — a late doWithSpring bean overriding a name-guarded @ConditionalOnMissingBean auto-config bean that would have deferred.

Two permanent phases (not modern-vs-legacy)

doWithSpring is 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 loaded GrailsApplication and 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

  • Mechanism: the doWithSpringBeforeAutoConfiguration() lifecycle method (default no-op on GrailsApplicationLifeCycle/Plugin), the drain plumbing, the BDRPP + initializer (spring.factories).
  • Warning: DeferrableOverrideWarner, gated on grails.plugins.warnOnDeferrableOverride (default on), precise to name-guarded @ConditionalOnMissingBean overrides.
  • First migrations to the overridable pattern (each still wins by default; now overridable without a warning):
    • grails-i18n: 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.
    • grails-url-mappings / grails-controllers: the Grails-specific beans (grailsCorsFilter, urlMappingsErrorPageCustomizer, urlMappingsInfoHandlerAdapter, grailsWebRequestFilter registration, webMvcConfig). dispatcherServlet/multipartConfigElement are deliberately left as intentional Boot replacements (they'd need @AutoConfigureBefore(DispatcherServlet/MultipartAutoConfiguration) to stay safe).
  • Tests: a unit test proving the ordering makes @ConditionalOnMissingBean defer (+ control); a DeferrableOverrideWarner precision test; and an @Integration test (app3) proving a real plugin's bean wins over an app's @ConditionalOnMissingBean default through a real boot.
  • Docs: a "Registering Beans Before Auto-Configuration" section in the plugins guide.

Design notes

  • The contract (documented on the API): the closure must define bean definitions only and be side-effect-free — the phase runs an isolated lightweight plugin load, and the real lifecycle re-instantiates plugins, so side effects would run twice. The early phase deliberately does not propagate the application context to its throwaway plugin instances, so a plugin implementing ApplicationListener is registered only once.
  • Rejected: sharing one plugin manager/GrailsApplication across the two phases. It's fragile — setApplication() doesn't update the field DefaultGrailsPlugin binds doWithSpring's application from, so rebinding breaks artefact handling (tagLibClasses). The isolated lightweight load is the robust design; the second (cheap) plugin instantiation is benign.
  • Application classes don't need this phase: an app class is a @Configuration, and Boot processes user config before auto-config, so a plain @Bean already registers ahead of auto-config. The phase is plugin-scoped.
  • AOT/native: introduces no new risk — it mirrors the existing GrailsApplicationPostProcessor runtime-plugin-load + BeanBuilder mechanism.

Validation

Full :grails-core:test green; 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.

@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.
@testlens-app

testlens-app Bot commented Jun 23, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: fbbefee
▶️ Tests: 49033 executed
⚪️ Checks: 46/46 completed


Learn more about TestLens at testlens.app.

@codeconsole codeconsole requested review from jamesfredley, jdaugherty, matrei and sbglasius and removed request for jamesfredley and sbglasius June 23, 2026 22:58

@jdaugherty jdaugherty left a comment

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.

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.

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.

2 participants