Add @GrailsBeans: compile bean-wiring DSL into real @AutoConfiguration classes - #16019
Add @GrailsBeans: compile bean-wiring DSL into real @AutoConfiguration classes#16019codeconsole wants to merge 102 commits into
Conversation
classes Plugin.beanRegistrar() (apache#15994) plus the before-autoconfiguration retiming (apache#15934) already give Grails a Spring-native, statically-compilable bean-registration mechanism that runs before Boot evaluates its @ConditionalOnMissingBean defaults. That solves bean registration winning against Boot's own conditional defaults, but beanRegistrar() output is inserted as a single hard cut point ahead of all Boot auto-configuration, ordered only relative to other Grails plugins by name. It has no way to express "run before this specific @autoConfiguration but after that one" the way a real @autoConfiguration(before = ..., after = ...) class can, against any other auto-configuration on the classpath, Grails-authored or third-party. @GrailsBeans closes that gap: a Groovy AST transformation compiles a doWithSpring()-style `beans = { bean(Type[, "name"]) { ... } }` closure into public @bean factory methods on a plain @autoConfiguration class, with `.conditionalOnMissingBean(Type...)` chaining and typed closure parameters becoming constructor-style method parameters. No closures, and nothing DSL-specific, survive into the compiled bytecode. Because the result is an ordinary @autoConfiguration class, it must be registered in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports like any other. Rather than hand-maintaining that file, this adds org.apache.grails.buildsrc.autoconfiguration-imports, a build-logic convention plugin that generates it by scanning a module's own compiled classes for @autoConfiguration - the same way META-INF/grails-plugin.xml is already generated from scanned *GrailsPlugin classes elsewhere in this build. - grails-beans-dsl: the @GrailsBeans annotation (grails.compiler.beans, alongside grails.compiler.GrailsCompileStatic) and GrailsBeansASTTransformation, with unit tests covering every generated-bytecode behaviour and every malformed-DSL error path. - grails-beans-dsl-example: a worked example (Greeter / FancyGreeter / LoudGreeter), including an end-to-end test that boots a bare @EnableAutoConfiguration application with zero explicit reference to the example class and confirms the beans are discovered purely from the generated imports file. - build-logic: the autoconfiguration-imports convention plugin, with fixture-compiling unit tests of its own. - grails-doc: documents @GrailsBeans alongside the existing beanRegistrar()/doWithSpring coverage, explaining when to reach for each.
d97f8c1 to
a8a12d5
Compare
Supplements the standalone-class form: when @GrailsBeans is applied to a class extending grails.plugins.Plugin, the compiled @bean methods land on a generated sibling <PluginClassName>AutoConfiguration class in the same package instead of on the plugin class itself. A Plugin subclass is instantiated by DefaultGrailsPlugin via plain reflection, never as a Spring bean, so it cannot carry @bean methods or a meaningful @autoConfiguration annotation of its own - any @autoConfiguration found on the plugin class moves onto the generated sibling, since that is the only place it has any effect. This lets a plugin author keep bean definitions in the familiar *GrailsPlugin.groovy file while every other Plugin lifecycle hook (doWithApplicationContext, onChange, watchedResources, etc.) keeps working exactly as it does today. Omitting @autoConfiguration on the plugin class is a compile-time error, not a silent runtime gap, since the generated sibling would otherwise never be processed by Boot. - grails-beans-dsl: superclass detection by fully-qualified name (no new dependency on grails-core from the main sourceSet), sibling class generation, and annotation relocation, plus unit tests covering the generated sibling and the missing-@autoConfiguration error path. grails-core is a test-only dependency, needed only so fixtures can genuinely extend Plugin. - grails-beans-dsl-plugin-example: a new, deliberately separate module (rather than extending grails-beans-dsl-example) - grails-core being on a module's classpath at all activates Grails' own plugin-bootstrap ApplicationContextInitializer unconditionally via spring.factories, which would have changed grails-beans-dsl-example's existing test behaviour. Includes an end-to-end test proving the generated sibling is discovered with zero explicit reference, the same way the standalone-class case already is. - grails-doc: documents the Plugin-embedded form alongside the standalone-class form already covered.
bf23ad5 to
e361649
Compare
Confirms empirically (not just by inspection) that @GrailsBeans works correctly under @CompileStatic/@GrailsCompileStatic on both the standalone-class and Plugin-subclass forms - relevant since real *GrailsPlugin.groovy classes in this codebase already combine Plugin with @CompileStatic (e.g. RestResponderGrailsPlugin). This isn't accidental: GrailsBeansASTTransformation runs at CompilePhase.CANONICALIZATION, while Groovy's own StaticCompileTransformation (backing @CompileStatic) is registered at CompilePhase.INSTRUCTION_SELECTION, confirmed by inspecting its bytecode annotation directly. The beans DSL is always rewritten into concretely-typed @bean methods before static type checking examines the class, so the type checker never sees the dynamic-looking bean(...) calls at all.
…on, silent failures
An external agent review of this PR surfaced four findings. All four
are addressed here.
- grails-beans-dsl was never actually published: it applied neither
buildsrc.publish nor buildsrc.sbom, and was absent from
publishedProjects in gradle/publish-root-config.gradle, so the
org.apache.grails:grails-beans-dsl coordinate the guide tells
external users to depend on would not have existed. Registered the
module and re-applied both plugins; verified a real POM now
generates with the correct coordinates.
- The review flagged that @CompileStatic on a Plugin class was not
propagated to the generated sibling AutoConfiguration. Attempted
the fix (copying the annotation across), then verified via a real
bytecode-level test (disassembling the compiled sibling and
checking for invokedynamic) that copying the annotation achieves
nothing: Groovy schedules static compilation by scanning for
@CompileStatic before this transform's own callback runs, so a
class created during that callback is never a candidate for it
regardless of what it's annotated with. Reverted the non-functional
copy - keeping it would have been actively misleading, claiming
static compilation the bytecode doesn't have - and corrected the
docs/javadoc to state the real, now-verified boundary: the
standalone-class form gets genuine static dispatch, the
Plugin-subclass sibling never does. Both are now pinned by
bytecode-level regression tests.
- bean(...) argument validation only checked the first argument and
read the second opportunistically: bean(String, someVariable) {}
silently discarded the variable and fell back to a decapitalized
type name, bean(String, 'x', 'unexpected') {} silently ignored the
extra argument, and a non-String constant (e.g. bean(String, 42))
would have been stringified into an invalid generated method name.
Rewrote the validation to strictly require (Type) or (Type, String)
before the factory closure, reject non-String/non-constant names
with a clear error, and validate the resulting name is a legal Java
identifier. Fixing this surfaced a real regression in my own first
attempt - the common bean(Type, 'name') { ... } shape was briefly
broken because the trailing closure is embedded in bean(...)'s own
argument list when there is no .conditionalOnMissingBean(...)
qualifier - caught by the existing test suite before it went
anywhere.
- GenerateAutoConfigurationImportsTask caught every Throwable while
loading scan candidates and did nothing with it, so a class that
was genuinely annotated @autoConfiguration but failed to load for a
real reason would simply vanish from the generated imports file
with no signal at all - its beans would never be registered and
nothing would say why. Added an injectable callback, defaulting to
a build warning in production; verified with a test that
deliberately breaks a compiled class's superclass reference and
confirms the callback fires with the right class name.
Verified: full test suites for grails-beans-dsl (22 tests, up from
17), grails-beans-dsl-example, grails-beans-dsl-plugin-example, and
build-logic (5 tests) all green; a third full-repo
`clean aggregateViolations :grails-test-report:check --continue` run
shows zero violations across CHECKSTYLE/CODENARC/PMD/SPOTBUGS with
fresh timestamps and the same pre-existing Docker-dependent failures
as the prior two runs, none in the touched modules; rat and
validateDependencyVersions both clean.
Closes the @CompileStatic gap identified in review: the previous attempt at this (copying the annotation onto the generated sibling) was reverted after bytecode disassembly proved it achieved nothing, because Groovy schedules static compilation by scanning for @CompileStatic before this transform's own CANONICALIZATION-phase callback runs, so a class created inside that callback was never a candidate for it regardless of what it was annotated with. This time GrailsBeansASTTransformation implements CompilationUnitAware to get a handle on the CompilationUnit, then - after generating the sibling and its @bean methods - constructs Groovy's own StaticCompileTransformation directly and invokes its visit(...) method against the sibling explicitly, passing the same (annotation, class) pair Groovy's own scheduling would pass if it had discovered the sibling early enough. This sidesteps the "was this class known at scan time" problem entirely, since it never relies on Groovy's automatic discovery for the sibling at all. Independently re-verified, not just trusted: reran the full test suite from a clean build (the first run hit an unrelated stale Gradle incremental-build state issue in grails-core, resolved by rebuilding that module), then manually disassembled a fresh, from- scratch compiled fixture with javap outside the test framework and eyeballed the raw bytecode for the generated sibling's greeting() method - confirmed direct invokevirtual calls with no invokedynamic call sites, for both @CompileStatic and @GrailsCompileStatic on the plugin class. Updated the existing dispatch-mode regression tests (previously asserting the opposite, now-corrected behaviour) and added a dedicated @GrailsCompileStatic sibling-dispatch test.
The guide described what the beans{} DSL does but never what it
doesn't: no imperative logic in the closure (every statement must be
a literal bean(...) call), no per-bean qualifier beyond
.conditionalOnMissingBean(), and no ref()-style bean lookup by name.
beanRegistrar()'s BeanRegistry.Spec already supports primary/lazy/
scope per bean; @GrailsBeans had no equivalent, only
conditionalOnMissingBean(). Closes that gap: any combination of
.conditionalOnMissingBean(Type...), .primary(), .lazy(), and
.scope("name") can now be chained onto bean(...), in any order,
compiling to @Primary/@Lazy/@scope on the generated method alongside
@ConditionalOnMissingBean.
Chain-walking is generalized to collect an arbitrary sequence of
qualifier calls back to the root bean(...) call rather than a single
hard-coded conditionalOnMissingBean special case, with validation for
unrecognised qualifiers, a qualifier chained more than once, and
argument-shape mismatches (primary()/lazy() take no arguments,
scope(...) requires exactly one non-empty String literal).
Docs updated accordingly, including removing the now-resolved
limitation that per-bean primary/lazy/scope wasn't possible.
The limitations note claimed @ConditionalOnProperty/@ConditionalOnClass/ @ConditionalOnBean/a custom Condition can only be applied at the whole AutoConfiguration class level. That's wrong: a hand-written @autoConfiguration class can put any of these directly on an individual @bean method, which is standard Spring Boot practice (Boot's own autoconfigurations do this throughout). The real gap is narrower: this DSL just has no chained syntax for it yet.
The four named qualifiers (conditionalOnMissingBean/primary/lazy/scope) were a hand-picked shortlist, not parity with what a hand-written @bean method can carry. A normal Spring @autoConfiguration class can put any @conditional* (built-in or a custom Condition), @order, @dependsOn, or any other single-valued annotation directly on an individual @bean method - that's standard practice, not something confined to the whole class. .annotate(AnnotationType[, attr: value, ...]) closes that gap generically: an annotation class literal plus optional Groovy named-argument attributes, turned into an AnnotationNode with those members set. Unlike the other four qualifiers it's repeatable, so several different annotations can be chained onto one bean (.annotate(Order, value: 1).annotate(ConditionalOnWebApplication)). Attaching the same annotation type twice - whether via two annotate() calls or an annotate() colliding with one of the named qualifiers (.primary().annotate(Primary)) - is now a compile error rather than silently emitting a duplicate annotation, via a shared addAnnotationIfAbsent() check all five qualifiers route through. Also confirmed and pinned with a test: parameter-level annotations (e.g. @qualifier on a closure parameter) already carry through to the generated method for free, since the DSL reuses the closure's own Parameter AST nodes directly - no new syntax needed for that part. Docs updated to describe .annotate(...) and drop the now-resolved "only four qualifiers" limitation, replacing it with the one that remains: attribute values must be compile-time constants, so a nested annotation as an attribute value isn't supported.
@GrailsBeans could only generate isolated public @bean methods, with no way to declare shared state or logic - the real org.grails.plugins.i18n.I18nAutoConfiguration needs both (six @Value-injected fields, a strategy-selecting helper method) and couldn't have been expressed in the DSL at all. field(Type[, "name"]), optionally chained (repeatably) with .annotate(...), declares a private field on the generated class - the usual case is field(String, 'x').annotate(Value, value: '${...}'). method(Type[, "name"]) { ... }, with the same chaining, declares a private helper method the same way bean(...) declares a public one. Both are ordinary private members: a bean(...) closure reads a field(...) or calls a method(...) by simple name, exactly like a hand-written @bean method referencing a sibling field or method on its own @configuration class - this works because they're relocated onto the same generated class as real members before Groovy's static type checker ever examines it, not because of any special-casing. Generalizes the qualifier machinery to make this possible without duplicating it: processStatement() now classifies each top-level "beans" statement (bean(...)/field(...)/method(...)) and validates its qualifier chain against a kind-appropriate allowed set before dispatching, parseTypeAndName() shares the Type[, name] validation all three need, and applyGenericAnnotation()/addAnnotationIfAbsent() now operate on AnnotatedNode (MethodNode and FieldNode's common superclass) so .annotate(...) and duplicate-annotation detection work identically for fields and methods, not just beans. Verified against the real i18n shape two ways: a spec fixture compiling and exercising a close structural analog (field-driven strategy selection in a method(), a second bean built from a different field via a different helper, both backing off an existing same-named bean via .annotate(ConditionalOnMissingBean, name: ..., search: SearchStrategy.CURRENT)), and an actual end-to-end example (GreetingGrailsPlugin in grails-beans-dsl-plugin-example) that boots a real Spring context and confirms the @value placeholder resolves from genuine application properties, not just structurally. Also confirmed field(...)/method(...) resolve correctly under @CompileStatic/@GrailsCompileStatic, on both the standalone form and the Plugin-subclass sibling - implicit this.field/this.method() access from a relocated closure body is new territory versus the existing parameter-binding cross-references, so this needed its own verification rather than assuming the existing mechanism covered it. Docs updated: a new DSL section, and a revised limitations note clarifying that the "no imperative logic" restriction is about beans{}'s own top-level statements, not the bodies of bean(...)/method(...) closures, which already accept arbitrary Groovy.
I18nAutoConfiguration.java was a hand-written @Configuration-style class: six @Value-injected fields, a strategy-selecting helper method, and four beans each backing off an existing same-named bean via @ConditionalOnMissingBean(name = ..., search = SearchStrategy.CURRENT). It's exactly the shape field(...)/method(...) were built for, and now serves as the real-world proof: not a synthetic example, the actual production autoconfiguration this framework ships. I18nGrailsPlugin.groovy gains @GrailsBeans + @autoConfiguration(before = [MessageSourceAutoConfiguration, WebMvcAutoConfiguration]) + @ConditionalOnWebApplication and a beans block covering all four beans (localeResolver, localeChangeInterceptor, messageSource, availableLocaleResolver) - the plugin's existing lifecycle methods (doWithApplicationContext, onChange, isChildOfFile) are untouched. I18nAutoConfiguration.java is deleted; its logic now compiles onto the generated I18nGrailsPluginAutoConfiguration sibling. Two simplifications versus the original, both already-established patterns for this DSL and functionally identical: bean names are literal strings ('localeResolver', 'messageSource') rather than references to DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME / AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME (bean(Type, name) requires a String literal, and the constants' values are exactly those strings); and the original's LocaleResolverStrategy enum plus its resolveStrategy(String) helper are inlined as a plain switch on a normalized string, since the DSL can't declare a nested type. Two other files had a real compile-time dependency on the now-deleted class as an ordering target: - GrailsLocaleResolverAutoConfiguration's @autoConfiguration(before = I18nAutoConfiguration.class) becomes beforeName = "...I18nGrailsPlugin AutoConfiguration" - a plain string, sidestepping any question of whether Java source can hold a compile-time class-literal reference to a Groovy-AST-transform-generated sibling from another module. - grails-domain-class's @autoConfiguration(after = [I18nAutoConfiguration]) becomes afterName = [...] for the same reason, cross-module this time. Both @autoConfiguration's before()/beforeName() and after()/afterName() attributes exist precisely for this: ordering against a class you can't (or, here, structurally cannot) hold a compile-time reference to. The hand-maintained AutoConfiguration.imports file is updated to list the generated class name instead (grails-i18n doesn't use the autoconfiguration-imports convention plugin, so this isn't auto-generated). grails-i18n gains an implementation dependency on grails-beans-dsl. I18nAutoConfigurationSpec.groovy is renamed to I18nGrailsPluginAutoConfigurationSpec.groovy (it now tests I18nGrailsPluginAutoConfiguration) with its AutoConfigurations.of(...) reference updated; GrailsLocaleResolverAutoConfigurationSpec.groovy's reference is updated the same way. I18nGrailsPluginSpec.groovy needed no changes - it instantiates the plugin directly and only exercises doWithApplicationContext/onChange, which @GrailsBeans doesn't touch. Verified: the full pre-existing grails-i18n test suite (all four spec files - AvailableLocaleResolverSpec, I18nGrailsPluginSpec, the renamed I18nGrailsPluginAutoConfigurationSpec, GrailsLocaleResolverAutoConfig urationSpec) passes unmodified in behavior, including every backing- off, property-driven-strategy, and parent-context-isolation test. grails-domain-class's own tests pass with the afterName change. A full-repo compileGroovy/compileTestGroovy (988 tasks, every module) succeeds, confirming no other file anywhere still references the deleted class. Checkstyle/CodeNarc clean on every touched module.
.annotate(...)'s attribute values were never restricted to literals - unlike bean(Type, name)'s own name, which my transform must resolve to a Java identifier at AST-transform time, an .annotate(...) member is just passed through to Groovy's own annotation machinery. I defaulted the i18n conversion's five @value keys to plain literal strings by analogy with the bean-name case without actually testing whether a grails.config.Settings constant reference would work there too - it does: '${' + Settings.I18N_LOCALE_RESOLVER + ':session}' (String concatenation, same shape the original Java file used) resolves correctly and was an undisclosed gap versus the "two simplifications" already documented in the PR. I18nGrailsPlugin.groovy's five Settings-backed fields now reference the real constants, matching the original file exactly (the sixth, defaultLocale, keeps its literal key - the original never had a Settings constant for it either). Pinned with a permanent regression test proving annotate(...) accepts a constant-concatenation value, not just a literal.
buildLocaleResolver() and buildMessageSource() existed only to have bean(...) immediately call them - the original I18nAutoConfiguration never had separate helpers for these, it put the switch and the messageSource construction directly in localeResolver()'s and messageSource()'s own @bean method bodies. The only helper the original genuinely needed was fixedLocale(), because that one is actually shared between localeResolver() (the 'fixed' case) and availableLocaleResolver(). Inlined both back into their bean(...) closures, matching the original's real structure instead of manufacturing extra method(...) calls just to exercise the feature. fixedLocale() stays as the one genuinely shared method(...).
Inlining buildLocaleResolver() into localeResolver()'s bean(...)
closure left its own leading comment ("Normalizes the configured
strategy...") stacked directly under the unrelated SearchStrategy.CURRENT
rationale comment with no separation. Split them back apart.
…erated sibling, fix acronym decapitalization, reject duplicate bean/field/method names - @GrailsBeans on a Plugin subclass previously moved only @autoConfiguration to the generated sibling; @ConditionalOnWebApplication (and the rest of the @conditional* family, @Import/@ImportAutoConfiguration, @EnableConfigurationProperties, @propertysource, @AutoConfigureOrder/Before/After) stayed on the Plugin class, which Spring never processes as a bean - so the real grails-i18n autoconfiguration's servlet-only guard was silently dropped, meaning the generated I18nGrailsPluginAutoConfiguration would activate unconditionally, including in non-servlet contexts where the original I18nAutoConfiguration backed off. Bytecode-verified fix: these annotations now move too, matched either by an explicit list or by carrying Spring's own @conditional meta-annotation (so future @ConditionalOnXxx annotations are covered automatically). - decapitalize() now delegates to java.beans.Introspector.decapitalize() instead of naively lowercasing the first character, so an acronym-prefixed type name like URLService derives URLService rather than uRLService, matching JavaBeans convention. - bean(...)/field(...)/method(...) statements sharing a generated name - even across categories, e.g. a field and a bean both landing on 'x' - now fail to compile instead of silently producing confusing, colliding members. Extended the Plugin-subclass sibling test to prove @ConditionalOnWebApplication and @propertysource move alongside @autoConfiguration, added a decapitalize regression test, added duplicate-name compile-error cases, and added a non-web ApplicationContextRunner test against the real grails-i18n autoconfiguration proving it now backs off outside a servlet context.
… sibling-annotation-move on @GrailsBeans
The class Javadoc only described bean(Type[, "name"]) { ... } and
.conditionalOnMissingBean(Type...), omitting field(...), method(...),
.primary(), .lazy(), .scope(...), and .annotate(...) - all real, shipped
DSL surface. Also updated the Plugin-subclass section to describe the
broader annotation-move behavior (not just @autoConfiguration) from the
preceding commit.
…notations, add @GrailsBeans(autoConfigurationName = ...) - belongsOnSibling() previously checked only one level of meta-annotation, so a composed annotation built on an existing recognized one - e.g. a project-specific @ConditionalOnFeature meta-annotated with Spring Boot's own @ConditionalOnProperty, rather than @conditional directly - stayed stranded on the Plugin class instead of moving to the sibling. The same gap applied to composed @import annotations (custom @enable... style annotations). Confirmed empirically before fixing: the composed-annotation case reproduced the exact same silent-no-effect failure as the original @ConditionalOnWebApplication regression. Now recurses through the full meta-annotation graph, with cycle detection, so any depth of composition is found. - Added @GrailsBeans(autoConfigurationName = "...") to name the generated sibling explicitly, for converting an existing public @autoConfiguration class into the DSL without changing its class identity (exclude= references, before=/after= ordering from other modules, tests that import it by name) - addresses the concern that grails-i18n's conversion deleted the public I18nAutoConfiguration type that shipped in v8.0.0-M3. Not yet applied to the real i18n conversion since 8.0.0 hasn't reached GA and renaming the generated sibling back would also require updating the beforeName/afterName references in GrailsLocaleResolverAutoConfiguration and GrailsDomainClassAutoConfiguration - a product decision left open rather than made unilaterally. Added a composed-annotation regression test (both @Conditional- and @Import-based) and a dedicated autoConfigurationName test.
… had no effect Confirmed by probe: since createAutoConfigurationSibling() is only invoked for a Plugin subclass, autoConfigurationName was read but never acted on otherwise - a typo'd or misapplied attribute compiled cleanly and changed nothing. Now a compile-time error. Also added tests locking in two behaviors already handled correctly by Groovy's own compiler without any change here: an invalid (non-identifier) autoConfigurationName reports an error and falls back to the default sibling name, and autoConfigurationName colliding with the plugin's own name or another class in the same source is rejected as a plain duplicate class definition.
GlobalGrailsClassInjectorTransformation conflicted. 8.0.x refactored the plugin descriptor branch - the inline version property is now resolvePluginVersion plus addPluginVersionProperty, and updateGrailsFactoriesWithType became the plural updateGrailsFactoriesWithTypes taking ARTEFACT_HANDLER_CLASS and TRAIT_INJECTOR_CLASS together - while this branch had added the beans DSL constants and the compileBeansDsl calls to the same two places. Took 8.0.x's shape and kept both compileBeansDsl calls on it.
Each was reported with a reproducer on the pull request; each now has a test that
fails without the fix.
resolveStringConstant aborted the compilation instead of reporting. getTypeClass()
throws GroovyBugError - an AssertionError, which the catch below could not hold -
for any ClassNode from the compilation unit being compiled, so a .value(...)
constant the AST lookup could not see ended in BUG! exception in phase
'canonicalization' rather than the located message. The lookup could not see
interface-inherited constants either, because ClassNode.getField walks
superclasses only, which is exactly the shape of every grails.config.Settings key.
Now searched through interfaces, with the reflective fallback guarded.
.annotate(...) did not fold its String attribute values, while .value(...) did.
An attribute written as a concatenation is a BinaryExpression at this phase, and
under @CompileStatic the static compiler rewrites it into .plus() before Groovy
folds annotation members - so the shape compiled without @CompileStatic and
failed with it, which is the mode every conversion here uses.
A root statement chained onto another parsed as a single statement. The walk
inward stops at the first bean/field/method name it meets and never looked at the
root call's own receiver, so field('suffix', String).bean('greeter', String) { }
registered greeter and silently dropped suffix.
Convention-derived member names skipped the identifier check that explicit names
get, so field(Boolean) generated a field named 'boolean' and method(Class) one
named 'class' - members no closure body can reference.
The consumed beans property was removed by mutating getFields(), which leaves
ClassNode's fieldIndex entry behind. Another member still reading beans then
compiled to a getfield for a field that is never emitted, failing at runtime with
NoSuchFieldError. removeField clears both, at both call sites.
Only @CompileStatic was propagated to the generated sibling, so @TypeChecked was
dropped and the lifted bodies went unchecked - a silent downgrade, since the same
DSL in the standalone form is checked. Both are propagated now.
No generated node carried a source position, so anything Groovy reported against
one - an .annotate(...) typo, a sibling name clash - was reported at line -1,
column -1. Positions are copied from the DSL statement, and from the plugin class
for the sibling.
Any argument-bearing .conditionalOnMissingBean(...) counted as a discriminating
condition, including .conditionalOnMissingBean(name: ...) - which, where the
duplicates share a name, is as identical on each of them as the bare form the
check already excludes. Only a positional type or a type-bearing attribute
discriminates now; name: and search: do not.
Two defects, both reported on the pull request. The class name was derived with 'relative.replace(File.separator, ".") - ".class"', and Groovy's minus removes the first occurrence rather than a trailing extension. Because the separators have already become dots by then, any package segment starting with 'class' was mangled - org/grails/plugins/classloading/FooAutoConfiguration.class became org.grails.pluginsloading.FooAutoConfiguration.class - which failed to load, was turned into a warning by the catch below and silently left out of the generated file. The extension is now dropped by length. Registering the generated directory on main.output puts it at the same archive path as a hand-maintained src/main/resources copy that processResources also contributes, so a module keeping both fed the resource into the jar twice. The task now fails with the path of the hand-maintained file and what to do about it. The convention plugin also had no production consumer: its only users were the two example modules, so neither the collision above nor the plugin itself was exercised by anything real. grails-databinding now applies it and its hand-maintained imports file is deleted. The generated file is byte-identical to the one removed, the scan reports no unresolvable classes, and the jar carries the resource exactly once.
grails-beans-dsl was declared in three different scopes across nine modules. Only
one of those declarations needs to exist: grails-core is on every Grails project's
classpath and declares it api, which is also what makes the implicit
'def beans = { }' convention reach a third-party plugin author -
GlobalGrailsClassInjectorTransformation.compileBeansDsl loads the transform
reflectively and returns silently when it is absent. That reasoning is now recorded
on the line it depends on, since nothing had written it down.
The seven redundant declarations are removed. Compiling is not enough to prove that
safe, precisely because compileBeansDsl fails silently - a missing transform would
leave the beans block as an untouched property and still compile. Each of the seven
was checked for its generated sibling class instead, including a forced
recompilation of grails-i18n and grails-url-mappings, which reach grails-core only
transitively through grails-web-core.
The two example modules keep their own declaration: grails-beans-dsl-example
deliberately demonstrates the DSL without grails-core on the classpath.
The published group becomes org.apache.grails.beandsl. The repo already partitions
coordinates this way for single-module projects - org.apache.grails.i18n,
.databinding, .codecs, .common, .bootstrap - and the DSL being on every project's
classpath by default is a reason for it to have its own namespace, not to sit in the
flat one. Nothing else references the coordinate: publish-root-config.gradle gates on
project name, and no BOM constraint or document spells it out.
They were the only top-level *-example modules in the build; every other example lives under grails-test-examples/ and follows its conventions. grails-beans-dsl-example -> grails-test-examples/beans-dsl (:grails-test-examples-beans-dsl) grails-beans-dsl-plugin-example -> grails-test-examples/beans-dsl-plugin (:grails-test-examples-beans-dsl-plugin) The rename matters as much as the move: root build.gradle populates testProjects by matching the project name prefix, not the directory, and functional-test-config.gradle's evaluationDependsOn fan-out filters on exactly that set - so a module sitting in the directory under its old name would evaluation-depend on itself. The flat bucket is not auto-scanned like the hibernate5/hibernate7 loops, so both names are listed explicitly with their projectDir. The -example suffix is dropped, since no module in that directory carries one. Conventions adopted: group 'examples' rather than org.apache.grails, which is for published modules; functional-test-config.gradle in place of test-config.gradle, with junit-platform-launcher declared explicitly since the former does not supply it; and grails-code-style and grails-jacoco dropped, which no other module there applies and which would otherwise put example code into the violation and coverage reports. The autoconfiguration-imports plugin stays - it is the point of these modules and is path-independent. One departure from the review note: grails-test-examples/beans-dsl keeps its own grails-beans-dsl declaration. It deliberately has no grails-core on its classpath, so the api that carries the DSL everywhere else does not reach it; the reason is now recorded on the line. beans-dsl-plugin does have grails-core and drops its declaration. All 7 specs run and pass at the new locations, FarewellGrailsPluginAutoDiscoverySpec among them. That one is the only test asserting a generated sibling's FQCN and now sits outside core-only CI, which is covered by grails-databinding applying the convention plugin.
The implicit convention guarded on nothing but "has a beans property" and "does not already declare @GrailsBeans". A pre-existing plugin descriptor with an unrelated 'def beans = [...]' - or a closure of something else entirely - was therefore taken through the transform and failed with "Each 'beans' statement must be a bean(...), field(...), or method(...) call": a source-incompatible change for third-party plugins, arriving with no mention in the upgrade guide. The implicit path now requires a closure whose every top-level statement is rooted in a bean/field/method call, and leaves anything else alone. Writing @GrailsBeans explicitly still opts in to the strict errors, which is right where the author has said what they mean. An empty block is still claimed, so both spellings agree on it. Also records why the ClassNotFoundException branch is allowed to be silent - it is unreachable only because grails-core declares grails-beans-dsl api, so narrowing that scope would turn it into a live path where a DSL-shaped block registers nothing and reports nothing - and restores the javadoc for isIsolatedBuild(), which the earlier insertion of compileBeansDsl had left stranded above a void method as a dangling @return.
UrlMappingsGrailsPlugin's @ConditionalOnProperty attributes were hardcoded literals with a comment claiming a name: attribute must be an inline constant. It already is one: grails.config.Settings is an interface, so Settings.WEB_URL_CONVERTER is a genuine public static final field with no accessor - the shape .annotate(...) folds correctly, @CompileStatic included. What it cannot fold is a concatenation, or a static final declared on a Groovy class where it is a property with a generated getter; neither applies here. The literals matched Settings, but nothing kept them in step, and the two field(...) statements directly above already used the constants, so the file disagreed with itself. GreetingGrailsPlugin's javadoc named the sibling GreetingGrailsPluginAutoConfiguration; the suffix is replaced rather than appended, so it is GreetingAutoConfiguration. FarewellGrailsPlugin had this right and its spec asserts the name, which is why nothing caught it - the Greeting spec never checked. It now does, and also asserts the field and method the DSL declared landed on the sibling rather than the plugin class.
Four fixes to grails-doc, all reported on the pull request. The .value(...) example used grails.gsp.view.encoding, which does not exist anywhere in Grails - the same sentence then offered Settings.GSP_VIEW_ENCODING as its constant form, and that constant is grails.views.gsp.encoding. Readers copying the example got a placeholder that never resolves. The spec fixture carrying the same invented key is corrected too. The bullet on .annotate(...) attribute values was both stricter than reality and silent about the real edge, and it is the bullet that sent UrlMappingsGrailsPlugin to hardcoded property strings. A bare constant reference does fold; what does not is a static final declared on a Groovy class - a property with a generated getter that @CompileStatic rewrites into a call - or a concatenation of constants. Restated in those terms. A limitation was missing, and it is the one most likely to catch someone migrating from doWithSpring: a bean or method body cannot reach members of the Plugin it was written in, because the body is relocated onto a sibling that extends Object and holds no reference to the plugin. The bullet saying bodies accept "arbitrary Groovy" now points at it. Two tests cover it: the @CompileStatic diagnostic, and the shape of the generated class that makes it so. The upgrade guide documented one of the four FQCN changes this branch makes. The other three - CoreAutoConfiguration's package move, GrailsCacheAutoConfiguration -> CacheAutoConfiguration, and DataBindingConfiguration -> DataBindingAutoConfiguration - now get the same treatment, including that Spring Boot only reports an invalid exclude for a class still on the classpath, so a stale entry is ignored in silence and the beans it was meant to suppress register anyway. Documenting rather than teaching autoConfigurationName to take a fully-qualified name is deliberate: these classes should carry their convention name.
The closure declared (GrailsApplication, List<MessageSource>, MappingContext) while DefaultConstraintEvaluatorFactoryBean's constructor takes (List<MessageSource>, MappingContext, GrailsApplication), so the body was reordering them by name. Correct as written, and needlessly easy to get wrong. Reordered to read straight through. No behaviour change: Spring resolves a @bean method's parameters by type and qualifier, never by position, and the generated method still carries @qualifier on its MappingContext parameter.
A bean whose construction is nothing but passing its injected dependencies through had to spell the constructor call out, repeating the type already named in bean(...) and the parameter names already listed in the closure header. Leaving the body empty now means that call: the parameters become the generated method's parameters, in the order written, and the body becomes new Type(p1..pn). The parameter list stays at the declaration, which is the point. It is the only place in Groovy that can carry both a generic type and a parameter annotation - neither List<MessageSource> nor @qualifier(...) is expressible as an argument - so a reader sees every dependency and every qualifier without leaving the file. And because the generated body is the same constructor call the author would have written, which constructor it selects is decided by the compiler from the parameter types. Nothing reads the declared type's constructors, so adding one cannot reach back and change or break a declaration. A test covers exactly that: a type with both a no-argument and an argument-taking constructor, where the parameter list picks between them. bean(Type) { } with no parameters is bean(Type), so the two spellings agree, and the interface/abstract rejection covers both. Five declarations lose a redundant line: the constraint evaluator, the three mail beans and the CORS filter. Their generated @bean signatures are unchanged, verified with javap. A body is still required wherever construction is more than a pass-through, such as new ConfigProperties(grailsApplication.config) or anything followed by .tap { }.
|
Review comments are answered inline, each naming the commit its fix landed in. Four threads note where I did something different from what was suggested.
bean('validateableConstraintsEvaluator', DefaultConstraintEvaluatorFactoryBean).lazy() {
List<MessageSource> messageSources,
@Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext,
GrailsApplication grailsApplication ->
}The parameters are what gets injected; the body is generated as Applied to five declarations: the constraint evaluator, the three mail beans and the CORS filter.
|
The existing test reflected on the generated method and confirmed the annotation had survived the transform, which is not the same as confirming Spring honours it. The new test registers the generated class in an AnnotationConfigApplicationContext with two beans of the dependency type, so the injection point is genuinely ambiguous, and asserts the qualified one is what arrives - once through a closure body and once through the generated constructor call, since the two paths reach the parameters differently. Verified non-vacuous: dropping the qualifier from the fixture fails the test with UnsatisfiedDependencyException rather than quietly passing. Also corrects the class javadoc, which claimed the generated method's "body and parameters are lifted directly from the DSL closure". The parameters always are; the body is, except where the closure body is empty or the closure is omitted, when a new Type(...) call over those parameters is synthesised instead.
The class javadoc's sentence on the generated method's name sat at 115 columns against ~85-100 for the rest of the block. Rewrapped; no wording change. The five parameter-only bean declarations are wrapped too. With the body empty the parameter list is the whole statement, so they had grown to between 124 and 248 columns on a single line - the constraint evaluator's being the worst - and read nothing like the multi-line form the guide and the annotation javadoc both show. Generated @bean signatures are unchanged, checked with javap. Other lines over 120 in these files are left alone: they are pre-existing and carry a closure body, where the file's own convention already puts the header on one line.
ExampleBeans is the module whose job is to demonstrate the DSL, so it should show the current shapes. fancyGreeter's body was new FancyGreeter() with no parameters, which the bodyless form covers; loudGreeter's was a pass-through of its one parameter, which the empty-body form covers. The three generated @bean methods are unchanged. An audit of every bean(...) declaration across the converted plugins found no others eligible: the rest either run more than one statement, use .tap { }, construct a different type than the one declared, or pass an expression such as grailsApplication.config rather than a bare parameter. The PR description's headline example is updated to match, and its comment now says a body is needed when construction is more than passing the parameters through, rather than "when there is something to say".
Concatenated constants do fold in .annotate(...). foldStringValue runs every attribute value through the same resolution .value(...) uses, and a test covers the shape under @CompileStatic - both landed in 9743d3a, before the bullet claiming otherwise was written. What genuinely does not fold is narrower: a static final on a Groovy class, which is a property whose getter @CompileStatic rewrites the reference into a call to. An interface's constants are real fields and fold, which covers every Settings key. "Not by name" was wrong. There is no ref('beanName'), but a parameter annotated @qualifier('beanName') selects by name, and this PR's own conversions depend on it - DomainClassGrailsPlugin resolves its MappingContext that way. The bullet steered readers away from a mechanism the DSL relies on.
The scan loaded every candidate class and, on any failure, warned and skipped it. A genuine @autoConfiguration could therefore drop out of the generated imports file while the build stayed green - its beans never registering, with nothing but a warning in a long log to say so. That is the outcome this task exists to prevent, and the String.minus defect fixed in 62b74b9 was an instance of it that reached review undetected. ASM reads the annotation table straight from the class file, so nothing is loaded and there is no failure to swallow. A class whose supertype is absent is now detected normally, which is what the replaced test asserts - the old one asserted the opposite, that such a class was excluded. Two things fall out of it. The scan classpath input is gone, since resolving supertypes is no longer needed. And the class name comes from the class file rather than from its path, so the whole category of path-to-name mangling is gone with it. The check stays a direct annotation match, not a meta-annotation search, exactly as isAnnotationPresent was. A malformed or unreadable class file now fails the build rather than being skipped quietly. Verified against all three consumers - grails-databinding and both example modules - producing byte-identical imports files, with the jar carrying the resource once.
buildAsmVersion duplicated a version dependencies.gradle already carries, with a comment telling the next person to keep the two in step - the exact smell the single-source policy exists to prevent. gradleBomDependencyVersions had no asm.version key, so reading it needed the key adding first. That map is a version-property lookup table, not a source of constraints: grails-gradle-bom's pomCustomization walks the dependencies already in its dependencyManagement and only asks the map what to call each one's version property. An unused key therefore publishes nothing, which the regenerated pom-default.xml confirms - byte-identical to before. The four asm.version declarations in the per-BOM customBomVersions blocks are left alone; they predate this and are a separate concern.
jdaugherty
left a comment
There was a problem hiding this comment.
I asked AI to take another pass at this, overall I think this is the right solution at this point and I'll approve later today after some further testing.
Second pass, against 297767a.
Everything from the previous round is fixed. I checked each one against the branch rather than the commit messages: the interface walk and GroovyBugError catch, foldStringValue, the chained-root rejection, the derived-name identifier check, removeField, @TypeChecked propagation, the five source positions, the name:/search:-only back-off, the four upgrade-guide rows, the three documentation corrections, GreetingAutoConfiguration, the ASM-based imports scan, the Settings constants in UrlMappingsGrailsPlugin, the single api declaration and the seven deletions, the restored javadoc, the narrowed implicit trigger, and both example modules under grails-test-examples/. The 157 specs in grails-beans-dsl pass, grails-databinding:generateAutoConfigurationImports produces exactly org.grails.plugins.databinding.DataBindingAutoConfiguration, and the i18n specs genuinely exercise the beforeName ordering rather than declaration order. Those threads are resolved.
Six notes from this pass. The first is a compiler crash in the empty-body form added since the last round; the rest are documentation and diagnostics, plus one condition that came off in the cache conversion.
|
|
||
| Parameter[] beanParameters = factory == null || factory.getParameters() == null ? | ||
| Parameter.EMPTY_ARRAY : factory.getParameters(); | ||
| Statement beanBody = constructsDeclaredType ? |
There was a problem hiding this comment.
The synthesized ConstructorCallExpression is the one generated node in this file with no setSourcePosition(...), and it is the node most likely to be the subject of a compile error: the constructor is selected from the closure's parameter types, so a parameter list matching no constructor — after someone adds an argument to the type's constructor, say — is the ordinary failure mode of the empty-body form.
Without a position the failure never reaches the error collector as a located message. Compiling the documented shape:
class NeedsString {
NeedsString(String required) { }
}
@GrailsBeans
@CompileStatic
@AutoConfiguration
class ProbeWrongParams {
def beans = {
bean(NeedsString) { Integer n ->
}
}
}In a normal build (assertions off):
org.codehaus.groovy.GroovyBugError: BUG! exception in phase 'class generation' in source unit '...' at line -1 column -1
On receiver: NeedsString with message: <$constructor$> and arguments: (n)
StaticTypesCallSiteWriter#makeCallSite should not have been called. Call site lacked method target for static compilation.
Please try to create a simple example reproducing this error and file a bug report at https://issues.apache.org/jira/browse/GROOVY
Under -ea — which is what Gradle's test workers run with, so it is what a plugin author sees from ./gradlew test — it degrades further, to a bare java.lang.AssertionError: null out of StaticCompilationVisitor.visitConstructorCallExpression. The bodyless bean(NeedsString) spelling fails identically. Same failure class as the getTypeClass() one: an internal compiler error at line -1 in place of a located message, with an invitation to file a Groovy bug.
The discrepancy is that the hand-written equivalent this is documented as compiling to is diagnosed properly:
[Static type checking] - Cannot find matching constructor NeedsArgsHand(). Please check if the declared type is correct and if the method exists.
@ line 15, column 43.
bean(NeedsArgsHand) { new NeedsArgsHand() }
^
So "resolved from these argument types exactly as it would be for a hand-written new Type(...)" holds for resolution but not for what the author is told when it fails.
Copying the position onto the construction closes it. Verified against the branch:
ConstructorCallExpression construction =
new ConstructorCallExpression(beanType, constructorArguments(beanParameters));
construction.setSourcePosition(baseCall);after which the same fixture reports
[Static type checking] - Cannot find matching constructor NeedsString(java.lang.Integer). Please check if the declared type is correct and if the method exists.
@ line 15, column 21.
bean(NeedsString) { Integer n ->
^
pointing at the bean(...) statement. A test would help: the spec has no case for a constructor mismatch in either the bodyless or the empty-body form, which is why this got through.
There was a problem hiding this comment.
Fixed in a4deb3d2cd. Reproduced exactly as described — BUG! at line -1 for the empty-body form, against a located "Cannot find matching constructor" for the hand-written equivalent.
The position now goes on the whole synthesized construction: the ConstructorCallExpression, its argument list, each VariableExpression, and the enclosing ReturnStatement. Both spellings now report
[Static type checking] - Cannot find matching constructor NeedsString(java.lang.Integer).
@ line 15, column 13.
bean(NeedsString) { Integer n ->
^
Two tests, one per spelling, asserting the message and that it contains neither line -1 nor BUG!.
| The DSL still covers a narrower surface than `doWithSpring(BeanBuilder)`: | ||
|
|
||
| * Each top-level statement inside `beans { }` must be exactly `bean(...)`, `field(...)`, or `method(...)`, with their respective chaining — an `if`, a `for` loop, or any other imperative Groovy directly inside `beans { }` is a compile-time error, not a runtime one. Note that this is about the DSL's own top-level statements, not the bodies of `bean(...)`/`method(...)` closures themselves, which accept arbitrary Groovy, subject only to what is reachable from the generated class (see the last bullet below) — and `field(...)`/`method(...)` already cover the common reasons to want shared state or logic in the first place. | ||
| * `.annotate(...)` attribute values must be compile-time constants: strings, numbers, booleans, class literals, enum constants, or arrays of these. A bare constant *reference* counts, and so does a *concatenation* of them — `.annotate(Value, value: '${' + Settings.I18N_LOCALE_RESOLVER + ':session}')` folds, `@CompileStatic` included, the same way `.value(...)` folds its arguments. The one shape that does not fold is a `static final` declared on a Groovy *class*: that is a property with a generated getter, and `@CompileStatic` rewrites the reference into a call to it. An interface's constants are real `public static final` fields and fold correctly — which is every `grails.config.Settings` key. An attribute that itself takes a nested annotation as its value isn't supported. |
There was a problem hiding this comment.
The exception this bullet still carries is stale in the same way the concatenation one was, and for the same reason: foldStringValue resolves the value during canonicalization, before @CompileStatic's transform runs, so there is no reference left for it to rewrite into a getter call.
A static final on a Groovy class does fold. All four combinations, with @CompileStatic on the annotated class:
| constant owner | .value(KEY, 'x') |
.annotate(Value, value: '${' + KEY + ':y}') |
|---|---|---|
| same-compilation-unit Groovy class | ${probe.key:x} |
${probe.key:y} |
| already-compiled Groovy class | ${precompiled.probe.key:x} |
${precompiled.probe.key:y} |
A bare reference as the whole attribute value folds too — .annotate(Value, value: PrecompiledKeys.KEY) gives precompiled.probe.key.
findStaticFinalField reads the FieldNode, and a Groovy property's backing field is still static final with its initial expression intact, so the property-versus-field distinction the bullet turns on no longer has any effect. As written it steers readers away from a shape that works — which is what the earlier version of this bullet did with Settings.
There was a problem hiding this comment.
You're right, and this bullet has now been wrong twice. The previous correction removed the concatenation claim but kept this exception on the strength of your earlier report — which predated the fix that changed the behaviour. I should have tested it rather than carrying it forward.
Confirmed all three shapes fold with the constant on a Groovy class under @CompileStatic: .value(KEY, 'x') → ${probe.key:x}, the concatenation → ${probe.key:y}, a bare reference → probe.key.
Rewritten in f31d57061f to say what actually holds — values must resolve to a compile-time constant, resolution happens before @CompileStatic runs, so interface-versus-class makes no difference — with three unrolled cases so it cannot drift again. PR description updated to match.
|
|
||
| | `grails-databinding` | ||
| | `org.grails.plugins.databinding.DataBindingConfiguration` | ||
| | Now `org.grails.plugins.databinding.DataBindingAutoConfiguration`. It was registered as an auto-configuration while named only `*Configuration`; the name now says what it is. Update any `@EnableAutoConfiguration(exclude = ...)` or `spring.autoconfigure.exclude` entry that referenced the old name; the same silent-ignore applies as above. |
There was a problem hiding this comment.
These four rows cover every FQCN change, but not the one change in this PR that alters which beans a running application ends up with.
The component-scan fix means an application whose working directory is not the project root — a systemd unit, a container with its own WORKDIR, an app server — starts registering @Components under grails.spring.bean.packages that it silently did not register before. The PR description says so plainly ("Applications that deploy this way will start getting beans they currently miss"); the guide says nothing.
It arguably deserves a row here more than the renames do. A stale exclude entry leaves a name that no longer resolves, which someone can at least search for; this one changes the contents of a running context with nothing to grep for, and in the direction of adding beans — a duplicate registration or an unexpected @Component picking up a bean name is how it will present.
Worth noting while you are here that grails.spring.bean.packages appears nowhere in grails-doc at all, so there is no existing page describing the property for this to cross-reference.
There was a problem hiding this comment.
Agreed it matters more than the renames. Added as section 39 in 4e51a2556a, covering the working-directory probe, which deployment shapes it affected, and the two things to check before upgrading: a duplicate bean name that now fails startup, and an injection point that was unambiguous becoming ambiguous.
Noted that grails.spring.bean.packages appears only in the Application Properties reference, so there is nothing to cross-reference.
The section is numbered 39 after the duplicate 38.s. Those, and a duplicate 34., are both on 8.0.x — I checked — so renumbering belongs in its own PR against 8.0.x rather than as seven unrelated heading changes here.
| String callName, boolean requireValidIdentifier) { | ||
| if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1) instanceof ClassExpression)) { | ||
| if (args.size() == 2 && args.get(0) instanceof ClassExpression) { | ||
| addError(call, source, callName + "(...) takes the name before the type: " + |
There was a problem hiding this comment.
This branch fires for a shape that has nothing to do with argument order, and the message then sends the reader somewhere unrelated. Chaining a qualifier after the factory closure rather than before it:
bean(String) { 'hi' }.lazy()bean(...) takes the name before the type: bean("myGreeter", Greeter), not bean(Greeter, "myGreeter")
@ line 9, column 34.
bean(String) { 'hi' }.lazy()
^
The named spelling avoids the wrong advice but is no more useful:
bean('greeting', String) { 'hi' }.lazy()bean(...) requires a type, optionally preceded by a name, e.g. bean(Greeter) or bean("myGreeter", Greeter)
It arrives here because factory is read off outerCall's arguments, so a closure sitting on baseCall is never stripped and is still in baseArgs at this point. Both spellings are a plausible first guess: the closure reads as belonging to bean(...), and it is where a body goes in every other DSL a Grails author writes. method('name', Type) { ... }.annotate(X) lands the same way, via "method(...) must end with a body closure".
Detecting a trailing ClosureExpression on the base call when qualifiers are present, and saying that qualifiers come before the body, is a one-line diagnostic — and a row in the malformed-statement table.
There was a problem hiding this comment.
Fixed in a4deb3d2cd. bean(String) { 'hi' }.lazy() now reports
the body closure comes last, after every chained qualifier - write bean(...).lazy(...) { ... }
rather than bean(...) { ... }.lazy(...)
Put on the shared statement path rather than in bean(...), so field(...) and method(...) get it too — method('x', String) { 'y' }.annotate(...) was landing on "must end with a body closure" the same way. Three table rows.
| @Slf4j | ||
| @CompileStatic | ||
| @AutoConfiguration | ||
| @ConditionalOnBooleanProperty(name = 'grails.cache.enabled', matchIfMissing = true) |
There was a problem hiding this comment.
@ConditionalOnBean(CachePluginConfiguration) came off in this conversion and nothing replaces it. The deleted GrailsCacheAutoConfiguration carried it with the reason spelled out:
Gated on the
CachePluginConfigurationdefinition contributed by the cache plugin descriptor's registrar (which runs before auto-configuration conditions are evaluated), so the auto-configuration backs off entirely when the plugin is not active — e.g. the jar is on the classpath but the plugin is excluded — keeping it in lockstep with the descriptor.
The gate could not survive verbatim: grailsCacheConfiguration is now declared in this same beans block rather than by the registrar, so it would be conditioning on a bean the same class contributes. But the condition it expressed has gone with it. With only @ConditionalOnBooleanProperty(name = 'grails.cache.enabled') left, a build with grails-cache on the classpath but the plugin not loaded now gets grailsCacheConfiguration, grailsCacheAdminService, customCacheKeyGenerator and grailsCacheManager registered anyway — exactly the case the deleted comment named.
Is that deliberate, i.e. is jar-on-the-classpath now sufficient for the cache beans, the way it is for an ordinary Boot starter? If so the class javadoc should say it: it explains the back-off design carefully but is silent on the lockstep that was dropped. If not, the descriptor no longer contributes anything for the generated configuration to gate on, so restoring it needs a different anchor — either way better decided explicitly than by omission.
Two smaller notes on the same move. environment.getProperty('grails.cache.enabled', Boolean, true) and @ConditionalOnBooleanProperty do not agree on non-canonical values: Spring's Boolean conversion accepts yes/on/1, the condition matches only true/false, so grails.cache.enabled=no used to disable the plugin and now leaves it enabled. And the log.warn('Cache plugin is disabled') that accompanied the old gate is gone, so disabling it is now silent.
There was a problem hiding this comment.
Deliberate, and now stated in the class javadoc (33c9ee771d) rather than left to omission: the jar being present is sufficient, as for an ordinary Boot starter. The gate cannot be restated once grailsCacheConfiguration is declared in this same block — it would condition on a bean the class contributes — and the plugin declares no profiles or environments, so it is active wherever its jar is. That is what makes it a change of mechanism rather than of which applications get the beans. The javadoc says a descriptor that later becomes conditionally inactive needs a new anchor.
On the property: real, but the direction is the other way round. Comparing @ConditionalOnBooleanProperty against config.getProperty(..., Boolean, true):
| value | condition | old conversion |
|---|---|---|
no / off / 0 |
disabled | disabled |
yes / on / 1 |
disabled | enabled |
So =no is unchanged; =yes is the regression. And it was worse than a disagreement: doWithApplicationContext re-read the flag with the relaxed conversion, so on =yes it believed caching was on and asked for grailsCacheConfiguration — which the condition had just declined to register. Startup failure.
That hook is now keyed on applicationContext.containsBean(...), which cannot drift from the condition that decided it. The log.warn is restored with it.
Covered in 9f18a77067: three unrolled cases for yes/on/1 asserting the beans are absent and the hook backs off. Against the previous implementation all three fail with NoSuchBeanDefinitionException, and a fourth case runs the hook on the enabled context so the back-off cannot pass by being taken unconditionally.
| * of them has any effect where the author wrote them. Annotations outside that set can be moved | ||
| * explicitly via {@link #moveAnnotations}. | ||
| */ | ||
| @Retention(RetentionPolicy.RUNTIME) |
There was a problem hiding this comment.
Carrying this forward from the scope thread so it does not get lost with it, since that thread's actual question — one story for the nine declarations — is settled.
Nothing reads @GrailsBeans at runtime. The transform consumes it at canonicalization, and it is not among the annotations moved to the generated sibling, so it stays on the source class where it is inert. RetentionPolicy.CLASS would leave every compile-time use working while keeping it out of the runtime image — which matters slightly more now that grails-core declares grails-beans-dsl as api and so puts it, and its own spring-context/spring-boot-autoconfigure api dependencies, on every application's runtime classpath.
Not a blocker, and I am fine with either answer. If RUNTIME is deliberate — leaving room for a runtime consumer later, say — a line on the annotation saying so is enough to close it.
There was a problem hiding this comment.
Changed to CLASS in 4e51a2556a, with a comment on the annotation recording why.
Nothing reads it: every converted module passes with it invisible, and ExampleBeans — which declares it explicitly — reports only @AutoConfiguration at runtime. It is a new annotation on an unreleased branch, so there is no compatibility cost, and your point about grails-core declaring the module api is what tipped it.
…wrong thing
The synthesized construction carried no source position - the one generated node left
without one, and the node most likely to be the subject of an error, since a parameter
list matching no constructor is the ordinary failure of the bodyless and empty-body
forms. Unlocated, it surfaced under static compilation as
BUG! exception in phase 'class generation' ... at line -1 column -1
StaticTypesCallSiteWriter#makeCallSite should not have been called
Please try to create a simple example ... and file a bug report
where the hand-written equivalent gets "Cannot find matching constructor" against the
statement. Both spellings now report the same located message. Two tests cover it; the
spec had no case for a constructor mismatch in either form, which is how it got through.
A qualifier chained after the body closure sent the reader somewhere unrelated:
bean(String) { 'hi' }.lazy() reported "takes the name before the type", advice that has
nothing to do with the mistake. The closure sits on the root call in that spelling, so
the [name, ] Type parsing found it as an extra argument. It is now named for what it is,
on the shared path so bean(...), field(...) and method(...) all get it, with three table
rows.
It does. foldStringValue resolves the reference while the DSL is compiled, before @CompileStatic could rewrite it into a getter call, so the property-versus-field distinction the bullet turned on has no effect: .value(KEY, 'x'), a concatenation, and a bare reference all fold whether the constant is declared on a class or an interface. This is the second time this bullet has been wrong. The previous correction removed the concatenation claim but kept the Groovy-class exception, on the strength of an earlier report rather than a test - and that report predated the fix that changed the behaviour. Three cases now cover it so the documentation cannot drift from the code again. What actually cannot be used is anything that is not a compile-time constant, which is what the bullet says now.
doWithApplicationContext re-read grails.cache.enabled through config.getProperty(..., Boolean), which accepts yes/on/1, while the @ConditionalOnBooleanProperty deciding whether the beans exist compares the literal strings true/false. On grails.cache.enabled=yes the two disagreed in the direction that fails startup: the hook believed caching was on and asked for grailsCacheConfiguration, which the condition had just declined to register. Asking the context what it has cannot drift from the condition that decided it. The warning that accompanied the old gate is restored with it - disabling the plugin had become silent. Also records the decision the conversion made by omission. The deleted GrailsCacheAutoConfiguration carried @ConditionalOnBean(CachePluginConfiguration), which tied it to the descriptor's registrar; that cannot be restated now that grailsCacheConfiguration is declared in this same beans block, since the condition would gate on a bean the class itself contributes. The plugin declares no profiles or environments, so it is active wherever its jar is, which is what makes this a change of mechanism rather than of which applications get the beans - stated in the javadoc so a descriptor that later becomes conditionally inactive is known to need a new anchor.
…o CLASS retention The upgrade guide covered the four FQCN renames but not the one change here that alters which beans a running application ends up with. An application whose working directory is not the project root - a systemd unit, a container with its own WORKDIR, an application server - registered none of the @components under grails.spring.bean.packages and now registers all of them. A stale exclude entry leaves a name someone can grep for; this changes the contents of a live context in the direction of adding beans, so it gets a section naming what to check: a duplicate bean name now failing startup, and an injection point that was unambiguous becoming ambiguous. @GrailsBeans becomes CLASS retention. The transform consumes it at canonicalization and it is not among the annotations moved to the generated sibling, so nothing reads it at runtime - confirmed by every converted module passing with it invisible, and by ExampleBeans, which declares it explicitly, reporting only @autoConfiguration at runtime. It is a new annotation on an unreleased branch, so there is no compatibility cost, and grails-core declaring grails-beans-dsl api puts this module on every application's runtime classpath.
…ke/grails-beans-dsl
The spec exercised grails.cache.enabled=false, which never disagreed. The values that do - yes, on and 1, which @ConditionalOnBooleanProperty reads as disabled and config.getProperty(..., Boolean) as enabled - had no test, so the regression the fix was written for was not covered. Three unrolled cases assert the beans are absent and that doWithApplicationContext backs off rather than throwing. Against the previous implementation all three fail with NoSuchBeanDefinitionException, which is the production failure: the hook read the property back, resolved it as true, and asked for a bean the condition had just declined to register. Wiring the plugin with a GrailsApplication carrying the same configuration is what makes that real. Without it the old implementation failed these tests too, but on a NullPointerException from an unset grailsApplication - an artefact of the harness rather than the path being tested. A fourth case runs the hook on the enabled context and asserts it creates the default caches, so the back-off above cannot pass by being taken unconditionally.
✅ All tests passed ✅🏷️ Commit: 9f18a77 Learn more about TestLens at testlens.app. |
New Auto Configuration Bean DSL
Plugin.beanRegistrar()gives Grails a Spring-native, statically-compilable way to register beans, but it can't express fine-grained ordering against other@AutoConfigurationclasses (Grails' own or third-party), andBeanRegistry.Spechas only one conditional primitive (fallback()) — nothing like@ConditionalOnProperty,@ConditionalOnClass,@ConditionalOnBean, or a customCondition.@GrailsBeanscloses that gap: an AST transformation that compiles adoWithSpring()-stylebeans = { bean(["name", ] Type) { ... } }closure into real@Beanfactory methods on a plain@AutoConfigurationclass. Both the name and the body are optional: the name defaults to the type's JavaBeans-decapitalized simple name, and a bean that is nothing but its own no-argument construction needs no body at all, sobean(XmlDataBindingSourceCreator)says everythingbean('xmlDataBindingSourceCreator', XmlDataBindingSourceCreator) { new XmlDataBindingSourceCreator() }does. Leaving the body empty means the same for a bean with dependencies —bean('validateableConstraintsEvaluator', DefaultConstraintEvaluatorFactoryBean).lazy() { List<MessageSource> messageSources, @Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext, GrailsApplication grailsApplication -> }compiles to a method with exactly those parameters whose body is that constructor call, in the order written. The parameter list stays at the declaration deliberately: it is the only place in Groovy that can carry both a generic type and a parameter annotation, and because the generated body is the constructor call you would have written, the compiler selects the constructor from the parameter types — nothing reads the declared type's constructors, so adding one cannot change what an existing bean injects. Chaining.conditionalOnMissingBean(...)(positional types, the annotation's own named attributes, or bare to back off by return type),.conditionalOnMissingBeanName(...)(backs off by this bean's own name, stated once),.primary(),.lazy(),.scope("name"), and (repeatably).annotate(AnnotationType[, attr: value, ...])covers everything a hand-written@Beanmethod could carry — any@Conditional*(built-in or custom),@Order,@DependsOn, anything — and typed closure parameters (including their own annotations, e.g.@Qualifier) become constructor-style method parameters. One bean name may even be declared by severalbean(...)statements when every declaration carries its own discriminating condition — the standard autoconfiguration pattern for mutually exclusive variants of one bean, e.g.UrlMappingsAutoConfiguration's twograilsUrlConverterbeans selected by@ConditionalOnProperty; an unconditioned duplicate (which Spring would silently skip in favour of the first definition) stays a compile-time error.field(...)andmethod(...)round it out with private fields and helper methods on the generated class -field(...).value(Settings.SOME_KEY, 'default')compiles straight to a@Value("${...}")injection, bare constant keys included, and single-argument.value('app.some.key')auto-wraps a bare key into@Value("${app.some.key}")(strings already carrying${...}/#{...}pass through verbatim) - for injected config and shared logic across beans - the same role a hand-written@Configurationclass's own fields and methods play. Nothing DSL-specific survives into the compiled bytecode — verified withjavap— so the result gets the entire@Conditional*family and Boot's ownbefore=/after=ordering for free, correctly sequenced in Boot's deferred condition-evaluation pipeline.Usage
Standalone class:
The standalone form works on any class Spring Boot processes as a configuration source — including an application's own
Applicationclass, where no imports-file registration is needed because Boot reads@Beanmethods directly off the class it is launched with.Directly on a
*GrailsPlugin.groovyclass, so bean definitions can live in the familiar plugin descriptor instead of a separate file — and there the annotation is implicit. Abeansproperty on a plugin descriptor, or on an application'sApplicationclass, is compiled automatically, in the same waydoWithSpring,watchedResourcesanddependsOnare conventions rather than annotated members;GlobalGrailsClassInjectorTransformationalready runs at the same compile phase and already identifies these classes.@GrailsBeansis written out only for a standalone@AutoConfigurationclass, which is neither. A class with nobeansproperty is untouched, and so is one whosebeansproperty isn't DSL-shaped — a pre-existing descriptor withdef beans = [...], or a closure of something else, is left alone rather than failed with a DSL error it never asked for. Declaring the annotation explicitly still works, and opts back in to the strict errors. This is the actual conversion now live in this repository's owngrails-i18nmodule — not an illustration; imports and comments are trimmed here for brevity; each block's linked filename is the verbatim, commit-pinned source. A second real conversion,UrlMappingsGrailsPlugin.groovy, replacesgrails-url-mappings' hand-writtenUrlMappingsAutoConfigurationthe same way — including its two mutually exclusivegrailsUrlConvertervariants sharing one bean name, selected by@ConditionalOnProperty.Eight of the framework's own auto-configurations are now authored this way, most with their own before/after comment below:
grails-i18n,grails-url-mappings,grails-sitemesh3,grails-mail,grails-core,grails-cache,grails-domain-classandgrails-databinding.grails-coreis the module where the conversion goes furthest:CoreGrailsPlugindeclared twelve beans through the deprecated bean builder DSL and now declares none that way — it has nodoWithSpringat all. The four that benefit from Boot's condition and ordering machinery —classLoader,grailsConfigProperties,grailsResourceLocator, and the placeholder configurer orderedbefore = PropertyPlaceholderAutoConfiguration— moved into thebeansblock. The other eight, which depend on the plugin's own runtime state, moved tobeanRegistrar(): the auto-proxy-creator variant, the two awareBeanPostProcessors,customEditors,proxyHandler,grailsBeanOverrideConfigurer, the development shutdown hook, andabstractGrailsResourceLocator— the last of these through aBeanDefinitionRegistryPostProcessor, since it is an abstract parent definition (see Limitations).That move exposed a gap in the legacy unit-test harnesses rather than in the framework.
AbstractGrailsTagTestscallsdoWithRuntimeConfiguration(springConfig)and stops there, running only the first of the two halves the runtime runs for a plugin, so any plugin contributing throughbeanRegistrar()was invisible to it. Both copies of that harness now apply the registrars after the DSL flush, in the same order and with the same precedence asGrailsApplicationPostProcessor.The first four kept their exact class identity, because the
*GrailsPlugin→*AutoConfigurationconvention already produced the deleted class's name. The other four did not, and rather than pin the old names with@GrailsBeans(autoConfigurationName = ...)they take the convention name:GrailsCacheAutoConfiguration→CacheAutoConfiguration,GrailsDomainClassAutoConfiguration→DomainClassAutoConfiguration, andDataBindingConfiguration→DataBindingAutoConfiguration(which was registered as an auto-configuration while named only*Configuration).grails-core's keeps its name but changes package, since the generated sibling is emitted beside its plugin —org.grails.plugins.core.CoreAutoConfiguration→org.grails.plugins.CoreAutoConfiguration. All four are FQCN changes for anyone excluding them via@EnableAutoConfiguration(exclude = ...)orspring.autoconfigure.exclude;autoConfigurationNameremains for a name that genuinely cannot move.Before,
I18nAutoConfiguration.java(since deleted) — a hand-written@Configuration-style class, six@Value-injected fields, a strategy-selecting helper method, four beans each backing off an existing same-named bean via@ConditionalOnMissingBean(name = ..., search = SearchStrategy.CURRENT):After —
I18nGrailsPlugin.groovy's class declaration andbeansblock (the rest of the file —doWithApplicationContext(),onChange(),isChildOfFile()— is pre-existing plugin-lifecycle logic, untouched by this conversion):Two simplifications versus the original, both functionally identical: bean names are the literal strings
'localeResolver'/'messageSource'rather than references toDispatcherServlet.LOCALE_RESOLVER_BEAN_NAME/AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME(bean(name, Type)requires a String literal, and those constants' values are exactly those strings); and the original'sLocaleResolverStrategyenum plus itsresolveStrategy(String)helper are inlined as a plainswitchon a normalized string, since the DSL can't declare a nested type.The bean name argument itself is optional, same as it is on
field(...)/method(...): all four beans derive their Spring names from their types viaIntrospector.decapitalize()(LocaleResolver→'localeResolver',MessageSource→'messageSource', ...) — exactly the values the original file'sDispatcherServlet.LOCALE_RESOLVER_BEAN_NAME/AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAMEconstants carry. For the three name-guarded beans,.conditionalOnMissingBeanName(...)then feeds that same derived name into the condition'snamemember, so the bean name and the back-off name are one statement that cannot diverge;availableLocaleResolver's bare.conditionalOnMissingBean()is type-based, with Spring inferring the type from the method's return type. The derived names remain contractual — external code (e.g.grails-test-suite-uber'sPluginTests.java) referenceslocaleChangeInterceptorby literal string — so a bean's type rename would change its derived name and break those references.The sibling's name derives from the plugin-descriptor convention: a
*GrailsPluginclass swaps that suffix forAutoConfiguration, so bare@GrailsBeansonI18nGrailsPluginregenerates exactly the class identityI18nAutoConfigurationhad before this conversion — anything referencing it by name (AutoConfiguration.imports, another auto-configuration'sbeforeName/afterName, a test importing it directly) keeps working unchanged, with no attribute needed. A class not ending inGrailsPluginappendsAutoConfigurationinstead, and@GrailsBeans(autoConfigurationName = "...")overrides either derivation.What the DSL above actually compiles to — the generated
I18nAutoConfigurationsibling, shown here as its Java equivalent (verified viajavap: nothing DSL-specific survives into the real bytecode — thebeansclosure itself is gone, and the onetapblock in the messageSource body compiles the waytapdoes in any statically-compiled Groovy class: an inner closure class, no dynamic dispatch):This isn't just plausible-looking: the full pre-existing
grails-i18ntest suite (conditional back-off by name andSearchStrategy.CURRENT, property-driven strategy selection, parent-context isolation, ordering againstGrailsLocaleResolverAutoConfiguration) passes unmodified against the generated class, now renamed back toI18nAutoConfigurationSpec.groovyto match.A
Pluginsubclass is instantiated byDefaultGrailsPluginvia plain reflection, never as a Spring bean, so it can't itself carry@Beanmethods or a meaningful@AutoConfiguration/@Conditional*annotation. In this form, the compiled members land on a generated sibling class in the same package, named by the plugin-descriptor convention — a*GrailsPluginclass swaps that suffix forAutoConfiguration(I18nGrailsPlugin→I18nAutoConfigurationhere), any other name appends it, and@GrailsBeans(autoConfigurationName = "...")overrides both. Every annotation that only makes sense on whatever Spring Boot actually evaluates moves onto that sibling, not just@AutoConfiguration: the whole@Conditional*family,@Import/@ImportAutoConfiguration/@ImportResource,@ComponentScan,@EnableConfigurationProperties,@PropertySource/@PropertySources,@AutoConfigureOrder/Before/After— including a composed annotation built on top of any of these (e.g. a project-specific@ConditionalOnFeaturemeta-annotated with Spring Boot's own@ConditionalOnProperty, found by walking the meta-annotation chain rather than requiring an exact match). An annotation outside that set stays put, since no automatic determination from metadata can cover annotations this transform has never heard of — name those explicitly with@GrailsBeans(moveAnnotations = [SomeVendorAnnotation])and they move the same way.@AutoConfigurationis required on the plugin class (even bare) whenever this form is used — omitting it is a compile-time error, not a silent runtime gap, since the generated sibling would otherwise never be processed by Boot.Registration
Because the result is an ordinary
@AutoConfigurationclass, it must be registered inMETA-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importslike any other. Apply the neworg.apache.grails.buildsrc.autoconfiguration-importsconvention plugin to generate that file automatically at build time by scanning the module's compiled classes for@AutoConfiguration— no hand-maintained imports file:plugins { id 'org.apache.grails.buildsrc.autoconfiguration-imports' }grails-databindingis converted to it here, so its hand-maintained imports file is deleted; the task fails if a module keeps both, since the two copies land at the same path in the jar.(
grails-i18npredates this convention plugin and hand-maintains its imports file instead — either way works, the convention plugin just saves remembering to update it.)Bugs found while converting
Converting the framework's own auto-configurations surfaced five defects, all silent and none caught by existing tests. Each is a separate commit and can be reviewed — or cherry-picked to a maintenance line — on its own.
Component scanning ignored
grails.spring.bean.packagesoutside the project directory (0161f9d). This is the one worth reviewing on its own merits, and it is present on 7.1.x and 7.2.x as well.BuildSettingsresolvesCLASSES_DIRfrom a working-directory-relative probe:and the scanner wrapped its entire resource lookup in
if (BuildSettings.CLASSES_DIR != null), returning an empty array everywhere else. Reproduced on released 8.0.0-M4 by running one jar from two directories — started from the project root the scanned@Componentregistered, started from an empty directory it did not, same jar and sameapplication.yml. That covers a systemd unit, a container with its ownWORKDIR, or an app server, while sparingjava -jar build/libs/app.jarfrom the project root, which is presumably why it survived. The custom resolution is replaced byPathMatchingResourcePatternResolver's own; the closure-class filter and pluginTypeFilters are kept. Applications that deploy this way will start getting beans they currently miss. Documented in the 8.0 upgrade guide, with the two things worth checking before upgrading: a duplicate bean name that now fails startup, and an injection point that was unambiguous becoming ambiguous.grails.spring.placeholder.prefixnever applied (100b9db). The configurer is aBeanFactoryPostProcessor, so its configuration class is built before@Valueinjection is active and the injected field was always null.grails.gorm.autoTimestampCacheAnnotationsnever reached its consumers (1544bd7). The default was written withConfig.put, butAutoTimestampEventListenerandDomainModelServiceImplread it through@Value("${...}"), which resolves against the environment. Development mode cached annotations — the opposite of the intent.Sitemesh3EnvironmentPostProcessornever ran (da28b4f), registered underorg.springframework.boot.env.EnvironmentPostProcessor, which Spring Boot 4 deprecatedforRemovaland no longer loads. Its warning could not have printed anyway, logging from a phase that precedes Boot's logging initialisation; it now takesDeferredLogFactoryand logs at error.Three of those trace to one cause, since removed (2930502):
GrailsPlaceholderConfigureradvertisedGrailsConfigurationAwarewhile being created before theBeanPostProcessorthat populates it, so itsConfigwas always null.Limitations
The DSL still covers a narrower surface than
doWithSpring(BeanBuilder):beans { }must be exactlybean(...),field(...), ormethod(...)with their respective chaining — anif, aforloop, or any other imperative Groovy directly insidebeans { }is a compile-time error. This is about the DSL's own top-level statements, not the bodies ofbean(...)/method(...)closures, which accept arbitrary Groovy —field(...)/method(...)already cover the common reasons to want shared state or logic in the first place..annotate(...)attribute values must resolve to a constant at compile time: a literal, a reference to astatic finalfield, or a concatenation of those. Resolution happens while the DSL is compiled, before@CompileStaticruns, so it makes no difference whether the constant is declared on an interface or a class. What can't be used is anything that isn't a compile-time constant — a method call, a local, a value read from configuration. An attribute that itself takes a nested annotation isn't supported, andfield(...)/method(...)can't declare a nested type (no inner enums/classes).ref('beanName')lookup. Where the type alone is ambiguous, select by name with a parameter annotation —@Qualifier('beanName'), asDomainClassGrailsPlugindoes for itsMappingContext.bean(...)/method(...)body can't reach members of thePluginit was written in. The body is relocated onto the generated sibling, which extendsObjectand holds no reference to the plugin, so readingconfig,grailsApplicationorapplicationContextfrom a body fails — at compile time under@CompileStatic, otherwise not until Spring invokes the factory method. This is the habit worth unlearning when converting fromdoWithSpring, whose closures read plugin state freely: take injected configuration throughfield(...), and aGrailsApplication(or any other bean) through a typed closure parameter.beanRegistrar()'sBeanRegistry.Spec, whoseregisterBeanoverloads all take a class and which has noparent/abstractsetting. A classless definition existing only to be inherited therefore has to be contributed one level down, from aBeanDefinitionRegistryPostProcessorthat reaches the underlying registry.CoreGrailsPlugindoes exactly that forabstractGrailsResourceLocator— public surface, since third-party plugins inherit from it withbean.parent, asset-pipeline'sassetResourceLocatoramong them — so this costs a small post-processor rather than keeping the bean builder DSL alive.@GrailsBeansis independent ofPlugin/beanRegistrar()/doWithSpring— reach for it specifically when a bean's registration needs ordering or conditioning against a particular other auto-configuration;beanRegistrar()remains the recommended way to register beans from aPluginotherwise. Both are documented side by side ingrails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc, and application usage — including directly on theApplicationclass — in the Grails-and-Spring chapter (spring/springdslAdditional.adoc).