Add @GrailsBeans: compile bean-wiring DSL into real @AutoConfiguration classes - #16019
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.
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.
…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.
# Conflicts: # grails-doc/src/en/guide/upgrading/upgrading80x.adoc
This comment has been minimized.
This comment has been minimized.
matrei
left a comment
There was a problem hiding this comment.
I think this is a fantastic feature, well done!
# Conflicts: # grails-doc/src/en/guide/upgrading/upgrading80x.adoc
The upgrade guide numbers its sections by hand, and two numbers had been issued twice: 34 covered both the GORM query safety check and the extension API notes, and 38 covered both the spring-security-ui rewrite and the stack trace filterer change. A reader following a number from a release note or a support thread landed on whichever of the two came first. Renumber from the second 34 onward so the sequence runs 1 to 44 with no repeats and no gaps. Only the heading lines change. The three numeric cross-references in the guide point at sections 7.2, 7.4 and 10, all below the first shifted heading, so they still resolve; references from elsewhere in the docs use the xref:upgrading#upgrading80x page anchor rather than a section number.
…nt comment The application lifecycle chapter said an Application class defines beans at compile time "by annotating it with @GrailsBeans". It does not need the annotation: the global transform compiles a DSL-shaped beans property on any GrailsAutoConfiguration subclass, which is what Application extends, so beans is a convention there exactly as doWithSpring is. The plugin and Spring chapters already said so; this one contradicted them. That path had no test. The three existing implicit-convention cases all use *GrailsPlugin.groovy sources, which reach the transform through a different branch and a different filename rule, so the Application branch was covered nowhere. Add a case that compiles an Application from grails-app/init, where a generated project puts it and where the transform's project-source check actually admits it. Also drop the comment above DomainClassGrailsPlugin's beans block. It described the constructor of a class this PR deletes, which tells a future reader nothing about the code in front of them.
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).