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 78 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.
Selective cleanup across the four conversions: - sitemesh3 decoratorSelector and url-mappings' errorPageCustomizer and mail's JNDI factory build their beans with tap - the i18n message source uses tap with cacheSeconds/fileCacheSeconds captured into locals first - the message source has properties of the same names, which inside tap would shadow the injected configuration fields (delegate-first resolution) - grailsLinkGenerator computes a local useCache instead of assigning the derived default back into the injected cacheUrls field - grails.util.Environment is import-aliased as GrailsEnvironment where Spring's Environment import forced fully-qualified references, and property-style access replaces getter calls Left alone deliberately: i18n's fixedLocale() helper and locale resolver switch, and the single-constructor mail beans - already in their clearest form.
…ugin The hand-written CoreAutoConfiguration.java is deleted and its @value field and three @primary beans move into a beans block on the plugin descriptor. This is the first conversion where the plugin keeps its legacy doWithSpring(): the dynamic bean-builder closure is isolated with @CompileDynamic so the class itself can carry @CompileStatic, which the transform copies onto the generated sibling - preserving the deleted Java class's static compilation. Verified with javap: the generated CoreAutoConfiguration has the same three method signatures, the same @Bean/@primary pairs, the same @value("${grails.spring.placeholder.prefix:#{null}}") field, and no invokedynamic call sites; the beans closure does not survive on the plugin class. CoreGrailsPlugin lives in org.grails.plugins rather than org.grails.plugins.core, and the generated sibling always lands in the plugin's own package, so the class becomes org.grails.plugins.CoreAutoConfiguration and the single AutoConfiguration.imports entry moves with it. Nothing else referenced the old package, and the org.grails prefix filter in grails-testing-support-core still matches. grails-core's test classpath gains spring-boot-test and assertj-core. The latter is not optional: ApplicationContextRunner's signatures reach AssertJ through ApplicationContextAssertProvider, and without it on the classpath Groovy cannot introspect the runner at all - its metaclass degrades to Object's methods and every call fails to dispatch. The autoconfiguration previously had no test coverage; the new CoreAutoConfigurationSpec asserts the three beans, the class loader identity, @primary winning over a competing ClassLoader candidate, the config properties read-through, Boot's PropertyPlaceholderAutoConfiguration ordering after it and backing off, and placeholder resolution. One test pins the fact that grails.spring.placeholder.prefix currently has no effect: the configurer is a BeanFactoryPostProcessor, so the configuration class is instantiated before @value injection is active and the field is never populated. The deleted class behaved identically; fixing it is a change in its own right.
grails.spring.placeholder.prefix never took effect. The configurer bean is a BeanFactoryPostProcessor, so its configuration class is created during invokeBeanFactoryPostProcessors - before AutowiredAnnotationBeanPostProcessor is registered - and the @value("${grails.spring.placeholder.prefix:#{null}}") field it read was therefore always null, leaving GrailsPlaceholderConfigurer on the default ${ prefix. Confirmed by reflection against a refreshed context: the field held null and the configurer's placeholderPrefix stayed ${. The bean is now contributed by a static factory method taking the Environment and reading the property from it directly. A static @bean method is Spring's documented shape for a BeanFactoryPostProcessor precisely because it needs no enclosing instance, and the Environment is a resolvable dependency at that point in the lifecycle, so the prefix arrives. It is also what Boot's own PropertyPlaceholderAutoConfiguration does. The @value field is gone; javap confirms the generated method is public static and takes an Environment. The two assertions this bug previously made impossible are restored in CoreAutoConfigurationSpec - a configured prefix now resolves @{foo.bar}, and it displaces the default so ${foo.bar} is left literal - replacing the test that pinned the broken behavior. Spring's Environment is imported directly and grails.util.Environment aliased to GrailsEnvironment in doWithSpring, matching UrlMappingsGrailsPlugin.
|
The Before, @AutoConfiguration(before = { PropertyPlaceholderAutoConfiguration.class })
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
public class CoreAutoConfiguration {
@Value("${" + Settings.SPRING_PLACEHOLDER_PREFIX + ":#{null}}")
private String placeholderPrefix;
@Bean
@Primary
public ClassLoader classLoader(GrailsApplication grailsApplication) {
return grailsApplication.getClassLoader();
}
@Bean
@Primary
public ConfigProperties grailsConfigProperties(GrailsApplication grailsApplication) {
return new ConfigProperties(grailsApplication.getConfig());
}
@Bean
@Primary
PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() {
GrailsPlaceholderConfigurer grailsPlaceholderConfigurer = new GrailsPlaceholderConfigurer();
if (placeholderPrefix != null) {
grailsPlaceholderConfigurer.setPlaceholderPrefix(placeholderPrefix);
}
return grailsPlaceholderConfigurer;
}
}After — @CompileStatic
@GrailsBeans
@AutoConfiguration(before = [PropertyPlaceholderAutoConfiguration])
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
class CoreGrailsPlugin extends Plugin {
// ... pre-existing plugin metadata properties, unchanged ...
def beans = {
bean(ClassLoader).primary() { GrailsApplication grailsApplication ->
grailsApplication.classLoader
}
bean('grailsConfigProperties', ConfigProperties).primary() { GrailsApplication grailsApplication ->
new ConfigProperties(grailsApplication.config)
}
bean(PropertySourcesPlaceholderConfigurer).primary().staticMethod() { Environment environment ->
def configurer = new GrailsPlaceholderConfigurer()
String prefix = environment.getProperty(Settings.SPRING_PLACEHOLDER_PREFIX)
if (prefix != null) {
configurer.placeholderPrefix = prefix
}
configurer
}
}
The The auto-configuration previously had no test coverage at all; the new |
…GrailsPlugin The hand-written GrailsCacheAutoConfiguration.groovy is deleted and its @value field and two beans move into a beans block on the plugin descriptor, alongside the pre-existing beanRegistrar() the two auto-configured beans are gated on. The class-level @autoConfiguration, @ConditionalOnBooleanProperty and @ConditionalOnBean move onto the generated sibling at compile time, and @CompileStatic is copied onto it so the generated configuration keeps the deleted class's static compilation. The deleted class's design documentation now lives on the plugin, next to the registrar it refers to. The generated sibling is named by the *GrailsPlugin -> *AutoConfiguration convention, so the class becomes CacheAutoConfiguration rather than GrailsCacheAutoConfiguration. The alternative was pinning the old name with @GrailsBeans(autoConfigurationName = ...), which would have baked a naming inconsistency in permanently: the majority of the framework's plugin-paired auto-configurations already follow the convention (UrlMappings, Sitemesh3, Mail, I18n, Controllers), and the redundant Grails prefix here is the outlier. That attribute is meant for a name that genuinely cannot move, not for preserving legacy naming. The rename is contained. The only references were the module's own tests, so GrailsCacheAutoConfigurationSpec is renamed to CacheAutoConfigurationSpec and CacheGrailsPluginSpec's description updated; the AutoConfiguration.imports entry names the new class. It is still a public FQCN change for anyone excluding the auto-configuration via @EnableAutoConfiguration(exclude = ...) or spring.autoconfigure.exclude, which 8.0 being a major release is the right place to take. The full grails-cache suite passes unmodified against the generated class, including CacheAutoConfigurationSpec's direct registration of it.
… DomainClassGrailsPlugin The hand-written GrailsDomainClassAutoConfiguration.groovy is deleted and its three lazy beans move into a beans block on the plugin descriptor, alongside the pre-existing beanRegistrar(). The class-level @autoConfiguration(afterName = ...) and @ConditionalOnWebApplication move onto the generated sibling at compile time, and @CompileStatic is copied onto it. The one structural change: the deleted class held grailsApplication and messageSources as fields populated by an @Autowired constructor, and the generated sibling always has a no-arg constructor. Both are taken as bean method parameters instead, which Spring resolves identically - and only when the bean that needs them is created, rather than when the configuration class is instantiated. Declaring them as fields would not have worked: field('messageSources', List) is a raw List, which Spring cannot resolve without an element type. javap confirms the generic parameter signatures survive - List<MessageSource> and List<ConstraintFactory> are distinct injection points - and that the two @qualifier('grailsDomainClassMappingContext') MappingContext parameters carry their annotation through from the closure. The generated sibling is named by the *GrailsPlugin -> *AutoConfiguration convention, so the class becomes DomainClassAutoConfiguration rather than GrailsDomainClassAutoConfiguration, for the same reason as the cache conversion: pinning the old name with autoConfigurationName would bake in a naming inconsistency the rest of the framework does not share. Unlike the cache rename this one is referenced across modules. ControllersAutoConfiguration's after = {...} ordering follows the rename by class literal: the generated sibling is emitted into the module's jar, so it resolves from a dependent module's Java compile like any other class, which keeps the reference type-safe and makes a future rename a compile error rather than a silently stale string. The 8.0 upgrade guide's grails-domain-class row gains a note naming the new class and telling anyone with an @EnableAutoConfiguration(exclude = ...) or spring.autoconfigure.exclude entry to update it; the row's existing reference to the old name is left as-is, since it documents the removed constraintsEvaluator API rather than the current class. grails-domain-class, grails-controllers, grails-cache and the uber and web test suites all pass.
|
The Before, @AutoConfiguration
@ConditionalOnBooleanProperty(name = 'grails.cache.enabled', matchIfMissing = true)
@ConditionalOnBean(CachePluginConfiguration)
@CompileStatic
class GrailsCacheAutoConfiguration {
@Value('${grails.cache.cacheManager:}')
String cacheManagerType
@Bean
@ConditionalOnMissingBean(name = 'customCacheKeyGenerator')
CustomCacheKeyGenerator customCacheKeyGenerator() {
new CustomCacheKeyGenerator()
}
@Bean
@ConditionalOnMissingBean(name = 'grailsCacheManager')
GrailsCacheManager grailsCacheManager(CachePluginConfiguration grailsCacheConfiguration) {
if (cacheManagerType == 'GrailsConcurrentLinkedMapCacheManager') {
return new GrailsConcurrentLinkedMapCacheManager(configuration: grailsCacheConfiguration)
}
new GrailsConcurrentMapCacheManager(configuration: grailsCacheConfiguration)
}
}After — @Slf4j
@CompileStatic
@AutoConfiguration
@ConditionalOnBooleanProperty(name = 'grails.cache.enabled', matchIfMissing = true)
@ConditionalOnBean(CachePluginConfiguration)
class CacheGrailsPlugin extends Plugin {
// ... pre-existing plugin metadata properties and isCachingEnabled(), unchanged ...
def beans = {
field('cacheManagerType', String).value('grails.cache.cacheManager', '')
bean(CustomCacheKeyGenerator).conditionalOnMissingBeanName()
bean(GrailsCacheManager).conditionalOnMissingBeanName() { CachePluginConfiguration grailsCacheConfiguration ->
if (cacheManagerType == 'GrailsConcurrentLinkedMapCacheManager') {
return new GrailsConcurrentLinkedMapCacheManager(configuration: grailsCacheConfiguration)
}
new GrailsConcurrentMapCacheManager(configuration: grailsCacheConfiguration)
}
}This conversion sits next to a The rename is contained: the only references were the module's own tests, so |
|
The Before, @CompileStatic
@AutoConfiguration(afterName = ['org.grails.plugins.i18n.I18nAutoConfiguration'])
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
class GrailsDomainClassAutoConfiguration {
GrailsApplication grailsApplication
List<MessageSource> messageSources
@Autowired
GrailsDomainClassAutoConfiguration(GrailsApplication grailsApplication, List<MessageSource> messageSources) {
this.grailsApplication = grailsApplication
this.messageSources = messageSources
}
@Lazy
@Bean(name = 'grailsDomainClassMappingContext')
DefaultMappingContextFactoryBean grailsDomainClassMappingContext(List<ConstraintFactory> factories) {
new DefaultMappingContextFactoryBean(grailsApplication, messageSources).tap {
constraintFactories = factories ?: []
}
}
@Lazy
@Bean
DefaultConstraintEvaluatorFactoryBean validateableConstraintsEvaluator(@Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext) {
new DefaultConstraintEvaluatorFactoryBean(messageSources, mappingContext, grailsApplication)
}
@Lazy
@Bean
ValidatorRegistryFactoryBean gormValidatorRegistry(@Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext) {
new ValidatorRegistryFactoryBean().tap {
it.mappingContext = mappingContext
}
}
}After — @CompileStatic
@AutoConfiguration(afterName = ['org.grails.plugins.i18n.I18nAutoConfiguration'])
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
class DomainClassGrailsPlugin extends Plugin {
// ... pre-existing plugin metadata properties, unchanged ...
def beans = {
bean('grailsDomainClassMappingContext', DefaultMappingContextFactoryBean).lazy() { GrailsApplication grailsApplication, List<MessageSource> messageSources, List<ConstraintFactory> factories ->
new DefaultMappingContextFactoryBean(grailsApplication, messageSources).tap {
constraintFactories = factories ?: []
}
}
bean('validateableConstraintsEvaluator', DefaultConstraintEvaluatorFactoryBean).lazy() { GrailsApplication grailsApplication, List<MessageSource> messageSources, @Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext ->
new DefaultConstraintEvaluatorFactoryBean(messageSources, mappingContext, grailsApplication)
}
bean('gormValidatorRegistry', ValidatorRegistryFactoryBean).lazy() { @Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext ->
new ValidatorRegistryFactoryBean().tap {
it.mappingContext = mappingContext
}
}
}The one structural change is the constructor. The deleted class held
Unlike the cache rename, this class is referenced across modules, so two call sites move with it.
|
…ngGrailsPlugin The hand-written DataBindingConfiguration.java is deleted and its seven beans move into a beans block on the plugin descriptor, which until now held nothing but a version property. @autoConfiguration, @AutoConfigureOrder, @EnableConfigurationProperties and @ImportAutoConfiguration all move onto the generated sibling at compile time, and the plugin gains @CompileStatic so the generated configuration keeps the deleted Java class's static compilation. The configurationProperties field was constructor-injected, and the generated sibling always has a no-arg constructor. Only grailsWebDataBinder reads it, so that one bean takes DataBindingConfigurationProperties as a parameter; the other six are unchanged. dataBindingSourceRegistry's DataBindingSourceCreator... parameter is declared as an array, which Spring resolves identically - Groovy gives the generated method the varargs flag back anyway, since a trailing array parameter on a closure compiles to varargs. The sibling is named by the *GrailsPlugin -> *AutoConfiguration convention, so the class becomes DataBindingAutoConfiguration. That is a rename in both directions: it drops the naming inconsistency the cache and domain-class conversions also dropped, and it corrects a class that was registered in AutoConfiguration.imports while being named only *Configuration. The imports entry is the only reference; the grails-databinding module has no others, and the 7.0 upgrade guide's mention of the original DataBindingGrailsPlugin -> DataBindingConfiguration move is left alone as an accurate record of the 7.x change. The generated method bodies keep four explicit array casts on GrailsArrayUtils.concat that the Java original did not need. Groovy's @CompileStatic cannot infer T[] from concat's <T> T[] signature the way javac does; without the casts it emits more runtime coercion, not less (nine invokedynamic sites rather than five), and an explicit type witness does not help either. The five that remain are all invokedynamic cast, not dynamic dispatch, in a lazily created bean method. grails-databinding, grails-controllers, grails-web-common and the uber and web test suites all pass.
A bean whose definition is nothing but its own no-argument construction
had to be spelled out as bean(Type) { new Type() }, restating the type to
say nothing. Five of the seven beans in the grails-databinding conversion
had that shape, and it is the most common one across the framework's
auto-configurations.
The trailing closure is now optional. bean(Type) and bean('name', Type)
generate a parameterless factory method returning new Type(), and chain
the existing qualifiers exactly as the closure form does -
bean(Widget).primary().lazy().conditionalOnMissingBean() compiles the same
way it would with an empty body. The bean name still comes from the
explicit name or the decapitalized type name, unchanged.
The declared type is the one constructed, so the bodyless form is
rejected at compile time for an interface or abstract class with a
message pointing at the fix, rather than deferring to a confusing error
inside generated code. A missing no-argument constructor is left to the
compiler, which already reports it against the generated call.
The where-block case that asserted the old "must end with a factory
closure" error is replaced by the uninstantiable-type case, since the
shape it covered is now legal. Documented in the plugin development
guide's bean(...) list.
The five DataBindingSourceCreator beans were each three lines restating their own type to construct it. Each type's JavaBeans-derived name is exactly the bean name the hand-written class declared, so dropping both the explicit name and the factory closure changes nothing about the generated configuration - verified by reading the @bean values back out of the bytecode, where all seven names are unchanged. grailsWebDataBinder keeps its closure because its body is real logic, and dataBindingSourceRegistry keeps its own because it declares the DataBindingSourceRegistry interface while constructing DefaultDataBindingSourceRegistry and then initializing it - exactly the case the bodyless form rejects.
Six bean(...) statements across the core, cache and databinding conversions passed a name identical to the one the DSL derives from the type, restating it for no benefit. They now rely on the derived name: ClassLoader, PropertySourcesPlaceholderConfigurer, CustomCacheKeyGenerator, GrailsCacheManager, GrailsWebDataBinder and DataBindingSourceRegistry all decapitalize to exactly the name that was being spelled out. Reading the @bean values back out of the bytecode confirms all twelve bean names across the three generated configurations are unchanged. The four remaining explicit names stay because the type does not produce them: grailsConfigProperties from ConfigProperties, and the domain-class trio of grailsDomainClassMappingContext, validateableConstraintsEvaluator and gormValidatorRegistry from their respective FactoryBean types. An explicit name pins the bean name against a future class rename, which was the reason for keeping them, but that protection is not worth the noise: renaming a type leaves a pinned name describing something that no longer exists, and code looking a bean up by string is better fixed by looking it up by type.
|
The Before, @AutoConfiguration
@AutoConfigureOrder
@EnableConfigurationProperties(DataBindingConfigurationProperties.class)
@ImportAutoConfiguration(DefaultConvertersConfiguration.class)
public class DataBindingConfiguration {
private final DataBindingConfigurationProperties configurationProperties;
public DataBindingConfiguration(DataBindingConfigurationProperties configurationProperties) {
this.configurationProperties = configurationProperties;
}
@Lazy
@Bean("grailsWebDataBinder")
protected GrailsWebDataBinder grailsWebDataBinder(
GrailsApplication grailsApplication,
ValueConverter[] valueConverters,
FormattedValueConverter[] formattedValueConverters,
TypedStructuredBindingEditor[] structuredBindingEditors,
DataBindingListener[] dataBindingListeners) {
GrailsWebDataBinder dataBinder = new GrailsWebDataBinder(grailsApplication);
dataBinder.setConvertEmptyStringsToNull(configurationProperties.isConvertEmptyStringsToNull());
// ... converter/editor/listener merging with the main context, elided ...
return dataBinder;
}
@Bean("xmlDataBindingSourceCreator")
protected XmlDataBindingSourceCreator xmlDataBindingSourceCreator() {
return new XmlDataBindingSourceCreator();
}
@Bean("jsonDataBindingSourceCreator")
protected JsonDataBindingSourceCreator jsonDataBindingSourceCreator() {
return new JsonDataBindingSourceCreator();
}
@Bean("halJsonDataBindingSourceCreator")
protected HalJsonDataBindingSourceCreator halJsonDataBindingSourceCreator() {
return new HalJsonDataBindingSourceCreator();
}
@Bean("halXmlDataBindingSourceCreator")
protected HalXmlDataBindingSourceCreator halXmlDataBindingSourceCreator() {
return new HalXmlDataBindingSourceCreator();
}
@Bean("jsonApiDataBindingSourceCreator")
protected JsonApiDataBindingSourceCreator jsonApiDataBindingSourceCreator() {
return new JsonApiDataBindingSourceCreator();
}
@Bean("dataBindingSourceRegistry")
protected DataBindingSourceRegistry dataBindingSourceRegistry(DataBindingSourceCreator... creators) {
final DefaultDataBindingSourceRegistry registry = new DefaultDataBindingSourceRegistry();
registry.setDataBindingSourceCreators(creators);
registry.initialize();
return registry;
}
}After — @CompileStatic
@AutoConfiguration
@AutoConfigureOrder
@EnableConfigurationProperties(DataBindingConfigurationProperties)
@ImportAutoConfiguration(DefaultConvertersConfiguration)
class DataBindingGrailsPlugin extends Plugin {
def version = GrailsUtil.getGrailsVersion()
def beans = {
bean(GrailsWebDataBinder).lazy() { GrailsApplication grailsApplication,
DataBindingConfigurationProperties configurationProperties,
ValueConverter[] valueConverters,
FormattedValueConverter[] formattedValueConverters,
TypedStructuredBindingEditor[] structuredBindingEditors,
DataBindingListener[] dataBindingListeners ->
GrailsWebDataBinder dataBinder = new GrailsWebDataBinder(grailsApplication)
dataBinder.convertEmptyStringsToNull = configurationProperties.convertEmptyStringsToNull
dataBinder.trimStrings = configurationProperties.trimStrings
dataBinder.autoGrowCollectionLimit = configurationProperties.autoGrowCollectionLimit
ApplicationContext mainContext = grailsApplication.mainContext
ValueConverter[] allValueConverters = (valueConverters + mainContext.getBeansOfType(ValueConverter).values()) as ValueConverter[]
AnnotationAwareOrderComparator.sort(allValueConverters)
dataBinder.valueConverters = allValueConverters
dataBinder.formattedValueConverters =
(formattedValueConverters + mainContext.getBeansOfType(FormattedValueConverter).values()) as FormattedValueConverter[]
dataBinder.structuredBindingEditors =
(structuredBindingEditors + mainContext.getBeansOfType(TypedStructuredBindingEditor).values()) as TypedStructuredBindingEditor[]
dataBinder.dataBindingListeners =
(dataBindingListeners + mainContext.getBeansOfType(DataBindingListener).values()) as DataBindingListener[]
dataBinder.messageSource = mainContext.getBean('messageSource', MessageSource)
dataBinder
}
bean(XmlDataBindingSourceCreator)
bean(JsonDataBindingSourceCreator)
bean(HalJsonDataBindingSourceCreator)
bean(HalXmlDataBindingSourceCreator)
bean(JsonApiDataBindingSourceCreator)
bean(DataBindingSourceRegistry) { DataBindingSourceCreator[] creators ->
new DefaultDataBindingSourceRegistry().tap {
dataBindingSourceCreators = creators
initialize()
}
}
}
}The bodyless No explicit names. Each of these seven types decapitalizes to exactly the bean name the hand-written class declared, so passing the name would only restate it. Reading the Two structural notes. The class is renamed |
The bean body transliterated the deleted Java class line for line,
including three intermediate mainContextXxx arrays and four
GrailsArrayUtils.concat calls with explicit casts. Groovy expresses the
same merge as array + collection, so each pair collapses to one line and
the intermediates and the GrailsArrayUtils dependency both disappear.
The bytecode is unchanged in cost: five invokedynamic cast sites before
and after. Those are not dynamic dispatch and not a failure to infer
concat's <T> T[] - they are how Groovy's @CompileStatic encodes a checked
cast where javac would emit checkcast. The fifth site makes that plain:
it comes from getBean('messageSource', MessageSource), an unambiguous
generic call the Java original needed a cast for too.
One deliberate difference: GrailsArrayUtils.concat null-guarded both
arguments, and array + collection does not. Spring cannot pass null for
these parameters - a required array dependency with no candidates raises
NoSuchBeanDefinitionException rather than injecting null - so the guard
was unreachable, and DefaultConvertersConfiguration is imported here
precisely to guarantee converters exist.
Four more beans across the url-mappings, sitemesh3 and cache conversions had a body that was nothing but new <declared type>(), left behind when the bodyless form landed because only the databinding block was swept at the time. Auditing every converted plugin turns these up and nothing else: the two grailsUrlConverter variants construct implementations of the declared UrlConverter interface rather than the interface itself, and the rest have real bodies or parameters. The two sitemesh3 beans keep their explicit names, which their types do not produce - siteMeshViewResolverBeanPostProcessor from GrailsSiteMeshViewResolverBeanPostProcessor, contentProcessor from CaptureAwareContentProcessor - so only the bodies go. This also exercises .staticMethod() against the bodyless form for the first time: javap confirms siteMeshViewResolverBeanPostProcessor is still generated public static, and the eleven bean names across the three generated configurations are unchanged.
Two readability changes from review. The DataBindingSourceRegistry bean constructed into a named local only to configure and return it, which tap expresses directly and which matches how the other conversions already build their beans. It costs one invokedynamic cast that the explicit local did not - tap's receiver comes back through a generic signature - which is not worth weighing against the clarity in a bean factory that runs once. The three chained url-mapping definitions ran to 195, 174 and 223 characters on one line, the least readable definitions in the converted set. Breaking before .annotate(...) and indenting the trailing closure to match brings the longest to 172 and keeps CodeNarc's Indentation rule satisfied, which a naive wrap does not.
The @GrailsBeans example in the Spring chapter is where an application developer lands, and it showed only the explicit-name-with-body form, predating bean(Type) gaining a bodyless form. The concise shape was documented in the plugin chapter alone. Also states what the placement buys: beans declared on the Application class are ordinary user beans as far as Spring Boot is concerned - Boot processes auto-configurations through a deferred import selector, after regular configuration classes - so an auto-configuration guarded by @ConditionalOnMissingBean backs off in their favour, the same way it does for a bean defined in resources.groovy.
@GrailsBeans created its generated sibling before looking at any statement, so a Plugin subclass with an empty beans block produced a bean-less auto-configuration class - and, because the class-level annotations are moved rather than copied, that empty class also took the plugin's @autoConfiguration and @conditional* with it, leaving the plugin stripped of them. Confirmed by compiling one: the sibling had only getMetaClass/setMetaClass, carried @autoConfiguration and @ConditionalOnWebApplication, and the plugin was left with @GrailsBeans alone. Returning before the sibling is created removes the problem without failing the build. Having nothing to declare is not an authoring error - an empty @configuration class is legal in Spring and an empty resources.groovy is legal in Grails - and erroring would break a build for something as ordinary as commenting the beans out while debugging. The DSL scaffolding is still stripped either way, so the annotation remains inert rather than half-applied. Two tests cover it: a plugin keeps its own annotations and generates no sibling, and a plain configuration class contributes no @bean methods.
A plugin descriptor's beans property is now compiled automatically, the same way doWithSpring, watchedResources and dependsOn are conventions rather than annotated members. The same applies to an application's Application class, detected as a GrailsAutoConfiguration subclass. GlobalGrailsClassInjectorTransformation is the hook: it already runs at CANONICALIZATION - the phase @GrailsBeans uses - already identifies *GrailsPlugin classes, and already mutates them to add a version property. It now also invokes GrailsBeansASTTransformation on any such class that declares a beans property. The transformation is invoked directly rather than by adding the annotation. Annotation-driven transformations are collected during semantic analysis, so an annotation added at CANONICALIZATION is inert metadata and would never fire. This mirrors how GrailsBeansASTTransformation already applies StaticCompileTransformation to the sibling it generates. grails-beans-dsl is loaded reflectively and a ClassNotFoundException leaves the beans property alone, so a build without it on the classpath is unaffected; grails-core's dependency moves from implementation to api so applications inherit it. Two guards keep this from touching anything it should not: a class with no beans property returns immediately, which is 50 of the framework's 60 plugin descriptors, and a class already carrying @GrailsBeans is skipped because its own transformation has run. The explicit annotation therefore still works exactly as before, and remains the only option for a standalone @autoConfiguration class, which is neither a plugin descriptor nor an application class. This depends on an empty beans block being a no-op rather than an error: auto-application would otherwise fail every plugin that has the property but nothing in it.
Seven of the eight converted descriptors rely on the annotation being implicit: i18n, url-mappings, sitemesh3, mail, cache, domain-class and databinding. Each still generates the same sibling with the same bean names and the same class-level annotations moved onto it, verified by compiling each module and reading the generated classes back. CoreGrailsPlugin keeps its explicit @GrailsBeans. The global transformation that would apply it implicitly lives in grails-core and is discovered from META-INF/services on the compile classpath, so it is not active while grails-core itself is being compiled - removing the annotation there fails the build with no sibling generated. That is worth stating rather than leaving as a puzzle for whoever tidies it next.
CacheGrailsPlugin's beanRegistrar() contributed grailsCacheAdminService and grailsCacheConfiguration, and the auto-configuration was gated on @ConditionalOnBean(CachePluginConfiguration) to stay in lockstep with it. Both beans now live in the beans block and the registrar is gone, leaving the plugin with only doWithApplicationContext for its post-refresh cache warming. That condition was guarding a state a Grails application cannot reach. CacheGrailsPlugin declares no profiles and no scopes or environments, so isEnabled(activeProfiles) and supportsCurrentScopeAndEnvironment() are both unconditionally true: whenever the jar is on the classpath the plugin loads and the registrar ran, so CachePluginConfiguration always existed and the condition always passed. The only context where it had an effect is a bare Spring Boot context holding grails-cache without the Grails plugin manager, which is what CacheAutoConfigurationSpec constructs and is not a supported application configuration. grails.cache.enabled still gates everything, now through the single class-level @ConditionalOnBooleanProperty rather than that plus the registrar's own check, and spring.autoconfigure.exclude still works. The bean names are unchanged: grailsCacheAdminService, grailsCacheConfiguration, customCacheKeyGenerator and grailsCacheManager, verified from the generated bytecode. The spec that asserted the unreachable back-off is deleted, and CacheGrailsPluginSpec goes with it since all three of its tests drove beanRegistrar() directly. Their coverage moves to CacheAutoConfigurationSpec, which gains assertions for the two relocated beans and for all four disappearing when caching is disabled.
…-processor Sitemesh3GrailsPlugin's beanRegistrar() existed to register grailsRenderViewMutator only when the SiteMesh 2 module (grails-layout) is absent. That guard is a classpath-presence check, which is exactly what @ConditionalOnMissingClass expresses, so the bean moves into the beans block carrying the same marker class the registrar tested through Sitemesh3EnvironmentPostProcessor.SITEMESH2_MARKER_CLASS - named as a literal because an .annotate(...) attribute must be an inline constant. The registrar is gone and the generated Sitemesh3AutoConfiguration now declares all five beans, verified from the bytecode. Two defects in Sitemesh3EnvironmentPostProcessor surfaced while reading that guard. It was registered under org.springframework.boot.env.EnvironmentPostProcessor. Spring Boot 4 moved the interface to org.springframework.boot, deprecating the old one, and both EnvironmentPostProcessorApplicationListener and ReflectionEnvironmentPostProcessorsFactory load only the new key - so the post-processor was never discovered. grails-core and grails-controllers already register under the new key; grails-sitemesh3 was the only module left on the old one. The key and the implemented interface both move. Its warning could not have been seen even once discovery is fixed. An EnvironmentPostProcessor runs before Spring Boot initialises logging - EnvironmentPostProcessorApplicationListener orders at HIGHEST_PRECEDENCE + 10 against LoggingApplicationListener's + 20 - so the static @slf4j logger wrote into a logging system that did not exist yet. It now takes Spring Boot's DeferredLogFactory, whose records are replayed once logging is up, keeping a no-argument constructor so the existing spec still builds one directly. The message is now logged at error rather than warn, and says so: two mutually exclusive modules on one classpath is a broken build, not a tolerated configuration.
DomainClassGrailsPlugin's beanRegistrar() registered no beans at all. It defaulted grails.gorm.autoTimestampCacheAnnotations to off in development mode, so that reloading a domain class picks up changed annotations, by calling Config.put on the Grails config. That default never reached anything. Both consumers - AutoTimestampEventListener and DomainModelServiceImpl - read the setting through a @value("${...:true}") placeholder, which resolves against the Spring environment. Config.put writes only to NavigableMapConfig's own map, so the value was not in the environment; and GrailsPlaceholderConfigurer, which would otherwise fold the Grails config into placeholder resolution, never receives that configuration, because it is a BeanFactoryPostProcessor created before the BeanPostProcessor that supplies it through GrailsConfigurationAware. Both paths were verified closed by characterisation tests before this change: the environment did not hold the key, and a placeholder fell through to its :true default. Development mode therefore cached annotations, the opposite of the intent. A DomainClassEnvironmentPostProcessor contributes the default to the environment instead, which is what @value reads and what the Grails config is built from. It is appended with lowest precedence and skips the key when already set, so application configuration still wins. The registrar is gone and the plugin now has no bean-definition hook at all. AutoTimestampCacheDefaultSpec replaces DomainClassGrailsPluginSpec, whose two tests drove the registrar directly. It asserts the default, an explicit value winning, and - the part that was broken - that a property placeholder now resolves to the contributed value rather than falling back.
…rConfigurer
GrailsPlaceholderConfigurer declared itself GrailsConfigurationAware and
branched on the resulting Config in loadProperties, but that branch could
never run. The interface is populated by GrailsApplicationAwareBeanPostProcessor,
a BeanPostProcessor, and this class is a BeanFactoryPostProcessor created
during invokeBeanFactoryPostProcessors - before any BeanPostProcessor is
registered - so setConfiguration was never called and the field stayed
null. Both the placeholder-prefix bug fixed earlier in this branch and the
auto-timestamp cache default traced back to code assuming otherwise.
Removing it loses nothing. In production the class is constructed with the
no-argument constructor, so the properties field is null as well and
loadProperties contributes nothing either way; resolution falls through to
PropertySourcesPlaceholderConfigurer's own environment-backed sources. The
Grails config is itself built from those sources, so a ${...} placeholder
naming a key from application.yml resolves through the environment, which
is what the dead branch was duplicating.
The two-argument constructor taking an explicit Properties is untouched
and still feeds loadProperties, which is the path GrailsPlaceholderConfigurer's
own specs exercise; all five placeholder specs pass unmodified. Config
stays imported because doProcessProperties still uses it to keep the bean
definition visitor from walking the whole Grails config.
GrailsConfigurationAware itself is unaffected and remains public,
documented API. It is populated correctly for ordinary beans; only classes
instantiated ahead of the BeanPostProcessor phase - which no longer
includes any framework class - cannot receive it.
grails.spring.bean.packages registered nothing whenever the JVM's working
directory did not contain grails-app. BuildSettings resolves CLASSES_DIR
from a working-directory-relative check -
new File('grails-app').exists() || new File('Application.groovy').exists()
- leaving it null anywhere else, and the scanner wrapped its entire
resource lookup in "if (BuildSettings.CLASSES_DIR != null)", so it
returned an empty array and no candidates were ever read.
Reproduced on released 8.0.0-M4 with one jar run from two directories:
started from the project root the scanned @component was registered,
started from an empty directory it was not, same jar and same
application.yml. That covers most real deployments - a systemd unit, a
container with its own WORKDIR, an app server - while sparing the obvious
local check of java -jar build/libs/app.jar from the project root, which
is presumably why it survived. The same code is on 7.1.x.
The custom resource resolution is replaced by PathMatchingResourcePatternResolver's
own, which reads the classpath rather than a directory guessed from the
process's location. Two behaviours are kept: the AntPathMatcher that skips
class files whose name contains $, since a Groovy closure compiles to a
class that is never a component candidate, and the plugin-contributed
TypeFilters. ParentOnlyGetResourcesClassLoader goes with the rest - it
reflected into ClassLoader.findResources to walk only parent loaders,
which predates the current packaging and is what standard scanning already
handles. The class drops from 251 lines to 100.
SpringBeanPackagesSpec states the behaviour from an application's point of
view - set the property, get the bean - and fails without this change,
because the Gradle test JVM's working directory has no grails-app and so
reproduces the deployed case exactly.
This comment has been minimized.
This comment has been minimized.
CoreGrailsPlugin's doWithSpring declared the grails.spring.bean.packages scan through the grailsContext:component-scan element, which needs the XML namespace handler and so can only be written in the bean builder DSL. It is now contributed by beanRegistrar(), which registers a GrailsComponentScanPostProcessor holding the configured packages. The new post-processor is the programmatic equivalent of what ClosureClassIgnoringComponentScanBeanDefinitionParser builds, and keeps the same two behaviours: class files whose name contains $ are skipped, since a Groovy closure compiles to a class that is never a component candidate, and the plugin manager's TypeFilters are applied as include filters. The XML parser stays, because the namespace is registered in META-INF/spring.handlers and an application can still use the element in its own resources.xml. SpringBeanPackagesSpec drives the registrar rather than doWithSpring and no longer needs the bean builder at all. Applying the registrar before refresh matters: a BeanDefinitionRegistryPostProcessor registered afterwards never runs, which is what a first attempt at the spec did. doWithSpring keeps abstractGrailsResourceLocator, an abstract parent bean definition that GroovyPagesGrailsPlugin inherits from through bean.parent and which BeanRegistry.Spec cannot express, so retiring the hook entirely still depends on changing grails-gsp.
CoreGrailsPlugin declared twelve beans through the deprecated bean builder DSL. Eleven of them move: the four that benefit from Boot's condition and ordering machinery - classLoader, grailsConfigProperties, grailsResourceLocator and the placeholder configurer ordered before PropertyPlaceholderAutoConfiguration - into the beans block, and the seven that depend on the plugin's own runtime state into beanRegistrar(): the auto proxy creator variant, the two aware bean post processors, customEditors, proxyHandler, grailsBeanOverrideConfigurer and the development shutdown hook. abstractGrailsResourceLocator stays behind, and doWithSpring stays with it. It is an abstract parent definition, a bean builder concept with no equivalent in BeanRegistry.Spec or on a @bean method, and it is part of the plugin's public surface: third-party plugins inherit from it with bean.parent, asset-pipeline's assetResourceLocator among them. The move exposed a gap in the legacy unit-test harnesses rather than in the framework. AbstractGrailsTagTests calls doWithRuntimeConfiguration(springConfig) and stops there, running only the first of the two halves the runtime runs for a plugin, so any plugin contributing through beanRegistrar() 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 as GrailsApplicationPostProcessor. CoreGrailsPluginTests follows the split, but it extends GroovyTestCase and there is no vintage engine on the test runtime classpath, so it does not execute. The registrar half therefore gets executable coverage of its own in CoreGrailsPluginRegistrarSpec: both auto proxy creator branches, the branch taken when AspectJ is off the application class loader, the custom editors, the proxy handler, GrailsApplicationAware injection and the beans config block override. Reaching the AspectJ branch needs aspectjweaver on the test runtime classpath, since the framework itself takes it compileOnly. GrailsPlaceHolderConfigurerCorePluginRuntimeSpec called plugin.doWithSpring() directly for a configurer that now comes from the beans block. Its GRAILS-10130 regression - a system property resolving in a bean definition placeholder - moves to CoreAutoConfigurationSpec, where it runs against the real auto-configured configurer.
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. 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 declaring the annotation explicitly still works. 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 eleven of them without it: the four that benefit from Boot's condition and ordering machinery —classLoader,grailsConfigProperties,grailsResourceLocator, and the placeholder configurer orderedbefore = PropertyPlaceholderAutoConfiguration— in thebeansblock, and the seven that depend on the plugin's own runtime state — the auto-proxy-creator variant, the two awareBeanPostProcessors,customEditors,proxyHandler,grailsBeanOverrideConfigurer, and the development shutdown hook — throughbeanRegistrar(). The twelfth,abstractGrailsResourceLocator, keepsdoWithSpringalive on its own; 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-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.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 be simple compile-time constants (strings, numbers, booleans, class literals, enum constants, or arrays of these) — an attribute that itself takes a nested annotation as its value isn't supported.field(...)/method(...)can't declare a nested type either (no inner enums/classes).ref('beanName')lookup.BeanRegistry.Spec, whoseregisterBeanalways takes a class. This is the one thing that keepsdoWithSpringonCoreGrailsPlugin:abstractGrailsResourceLocatoris a classless definition carrying a single property, and it is public surface — third-party plugins inherit from it withbean.parent = 'abstractGrailsResourceLocator', asset-pipeline'sassetResourceLocatoramong them.@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).