Skip to content

Add @GrailsBeans: compile bean-wiring DSL into real @AutoConfiguration classes - #16019

Open
codeconsole wants to merge 78 commits into
apache:8.0.xfrom
codeconsole:spike/grails-beans-dsl
Open

Add @GrailsBeans: compile bean-wiring DSL into real @AutoConfiguration classes#16019
codeconsole wants to merge 78 commits into
apache:8.0.xfrom
codeconsole:spike/grails-beans-dsl

Conversation

@codeconsole

@codeconsole codeconsole commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

New Auto Configuration Bean DSL

@CompileStatic
class Application extends GrailsAutoConfiguration {
    static void main(String[] args) {
        GrailsApp.run(Application, args)
    }

    def beans = {  
        // insert autoconfig bean dsl here
    }
}
@CompileStatic
class MyGrailsPlugin extends Plugin {
    def version = GrailsUtil.getGrailsVersion()

    def beans = {
        // insert autoconfig bean dsl here
    }
}

Plugin.beanRegistrar() gives Grails a Spring-native, statically-compilable way to register beans, but it can't express fine-grained ordering against other @AutoConfiguration classes (Grails' own or third-party), and BeanRegistry.Spec has only one conditional primitive (fallback()) — nothing like @ConditionalOnProperty, @ConditionalOnClass, @ConditionalOnBean, or a custom Condition.

@GrailsBeans closes that gap: an AST transformation that compiles a doWithSpring()-style beans = { bean(["name", ] Type) { ... } } closure into real @Bean factory methods on a plain @AutoConfiguration class. 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, so bean(XmlDataBindingSourceCreator) says everything bean('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 @Bean method 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 several bean(...) statements when every declaration carries its own discriminating condition — the standard autoconfiguration pattern for mutually exclusive variants of one bean, e.g. UrlMappingsAutoConfiguration's two grailsUrlConverter beans selected by @ConditionalOnProperty; an unconditioned duplicate (which Spring would silently skip in favour of the first definition) stays a compile-time error. field(...) and method(...) 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 @Configuration class's own fields and methods play. Nothing DSL-specific survives into the compiled bytecode — verified with javap — so the result gets the entire @Conditional* family and Boot's own before=/after= ordering for free, correctly sequenced in Boot's deferred condition-evaluation pipeline.

Usage

Standalone class:

@GrailsBeans
@AutoConfiguration(before = [MessageSourceAutoConfiguration, WebMvcAutoConfiguration])
class MyBeans {
    def beans = {
        // a bean that is nothing but its own no-argument construction needs neither a
        // name (it is derived from the type) nor a body
        bean(FancyGreeter).conditionalOnMissingBean().primary()

        bean(SlowGreeter).lazy().scope('prototype')

        // a body is only needed when there is something to say
        bean(Greeter) {
            new Greeter('hello')
        }

        bean(LoudGreeter) { Greeter greeter ->
            new LoudGreeter(greeter)
        }

        bean(SpecialGreeter).annotate(Order, value: 1) { @Qualifier('special') Greeter greeter ->
            new SpecialGreeter(greeter)
        }

        // an explicit name is for when the type does not produce the name you need.
        // 'legacy-greeter' isn't a valid Java identifier either, so the generated method gets a
        // synthesized name (greeter$0) - an implementation detail; Spring resolves the bean
        // by its @Bean("legacy-greeter") value, never by the method name
        bean('legacy-greeter', Greeter) {
            new Greeter('bonjour')
        }
    }
}

The standalone form works on any class Spring Boot processes as a configuration source — including an application's own Application class, where no imports-file registration is needed because Boot reads @Bean methods directly off the class it is launched with.

Directly on a *GrailsPlugin.groovy class, so bean definitions can live in the familiar plugin descriptor instead of a separate file — and there the annotation is implicit. A beans property on a plugin descriptor, or on an application's Application class, is compiled automatically, in the same way doWithSpring, watchedResources and dependsOn are conventions rather than annotated members; GlobalGrailsClassInjectorTransformation already runs at the same compile phase and already identifies these classes. @GrailsBeans is written out only for a standalone @AutoConfiguration class, which is neither. A class with no beans property is untouched, and declaring the annotation explicitly still works. This is the actual conversion now live in this repository's own grails-i18n module — 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, replaces grails-url-mappings' hand-written UrlMappingsAutoConfiguration the same way — including its two mutually exclusive grailsUrlConverter variants 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-class and grails-databinding.

grails-core is the module where the conversion goes furthest. CoreGrailsPlugin declared 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 ordered before = PropertyPlaceholderAutoConfiguration — in the beans block, and the seven that depend on the plugin's own runtime state — the auto-proxy-creator variant, the two aware BeanPostProcessors, customEditors, proxyHandler, grailsBeanOverrideConfigurer, and the development shutdown hook — through beanRegistrar(). The twelfth, abstractGrailsResourceLocator, keeps doWithSpring alive on its own; see Limitations.

That 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.

The first four kept their exact class identity, because the *GrailsPlugin*AutoConfiguration convention 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: GrailsCacheAutoConfigurationCacheAutoConfiguration, GrailsDomainClassAutoConfigurationDomainClassAutoConfiguration, and DataBindingConfigurationDataBindingAutoConfiguration (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.CoreAutoConfigurationorg.grails.plugins.CoreAutoConfiguration. All four are FQCN changes for anyone excluding them via @EnableAutoConfiguration(exclude = ...) or spring.autoconfigure.exclude; autoConfigurationName remains 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):

@AutoConfiguration(before = { MessageSourceAutoConfiguration.class, WebMvcAutoConfiguration.class })
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class I18nAutoConfiguration {

    @Value("${" + Settings.GSP_VIEW_ENCODING + ":UTF-8}")
    private String encoding;

    @Value("${" + Settings.GSP_ENABLE_RELOAD + ":false}")
    private boolean gspEnableReload;

    @Value("${" + Settings.I18N_CACHE_SECONDS + ":5}")
    private int cacheSeconds;

    @Value("${" + Settings.I18N_FILE_CACHE_SECONDS + ":5}")
    private int fileCacheSeconds;

    @Value("${" + Settings.I18N_LOCALE_RESOLVER + ":session}")
    private String localeResolverType;

    @Value("${grails.i18n.default.locale:}")
    private String defaultLocale;

    enum LocaleResolverStrategy { SESSION, COOKIE, ACCEPT_HEADER, FIXED }

    static LocaleResolverStrategy resolveStrategy(String value) {
        String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT).replaceAll("[^a-z]", "");
        return switch (normalized) {
            case "cookie" -> LocaleResolverStrategy.COOKIE;
            case "acceptheader", "header", "accept" -> LocaleResolverStrategy.ACCEPT_HEADER;
            case "fixed" -> LocaleResolverStrategy.FIXED;
            default -> LocaleResolverStrategy.SESSION;
        };
    }

    @Bean(DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME)
    @ConditionalOnMissingBean(name = DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME, search = SearchStrategy.CURRENT)
    public LocaleResolver localeResolver() {
        return switch (resolveStrategy(localeResolverType)) {
            case COOKIE -> new CookieLocaleResolver("locale");
            case ACCEPT_HEADER -> new AcceptHeaderLocaleResolver();
            case FIXED -> new FixedLocaleResolver(fixedLocale());
            case SESSION -> new SessionLocaleResolver();
        };
    }

    private Locale fixedLocale() {
        if (StringUtils.hasText(defaultLocale)) {
            Locale parsed = StringUtils.parseLocale(defaultLocale);
            if (parsed != null) {
                return parsed;
            }
        }
        return Locale.getDefault();
    }

    @Bean
    @ConditionalOnMissingBean(name = "localeChangeInterceptor", search = SearchStrategy.CURRENT)
    public LocaleChangeInterceptor localeChangeInterceptor() {
        ParamsAwareLocaleChangeInterceptor localeChangeInterceptor = new ParamsAwareLocaleChangeInterceptor();
        localeChangeInterceptor.setParamName("lang");
        return localeChangeInterceptor;
    }

    @Bean(AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME)
    @ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME, search = SearchStrategy.CURRENT)
    public MessageSource messageSource(GrailsApplication grailsApplication, GrailsPluginManager pluginManager) {
        PluginAwareResourceBundleMessageSource messageSource = new PluginAwareResourceBundleMessageSource(grailsApplication, pluginManager);
        messageSource.setDefaultEncoding(encoding);
        messageSource.setFallbackToSystemLocale(false);
        if (Environment.getCurrent().isReloadEnabled() || gspEnableReload) {
            messageSource.setCacheSeconds(cacheSeconds);
            messageSource.setFileCacheSeconds(fileCacheSeconds);
        }
        return messageSource;
    }

    @Bean
    @ConditionalOnMissingBean(AvailableLocaleResolver.class)
    public AvailableLocaleResolver availableLocaleResolver(GrailsApplication grailsApplication,
            @Value("${grails.i18n.availableLocales.includePlugins:true}") boolean includePlugins) {
        return new AvailableLocaleResolver(grailsApplication.getClassLoader(), fixedLocale(), includePlugins);
    }
}

After — I18nGrailsPlugin.groovy's class declaration and beans block (the rest of the file — doWithApplicationContext(), onChange(), isChildOfFile() — is pre-existing plugin-lifecycle logic, untouched by this conversion):

@Slf4j
@CompileStatic
@AutoConfiguration(before = [MessageSourceAutoConfiguration, WebMvcAutoConfiguration])
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
class I18nGrailsPlugin extends Plugin {

    String baseDir = 'grails-app/i18n'
    String version = GrailsUtil.getGrailsVersion()
    String watchedResources = "file:./${baseDir}/**/*.properties".toString()

    static final String AVAILABLE_LOCALES_ATTRIBUTE = 'availableLocales'

    def beans = {
        field('encoding', String).value(Settings.GSP_VIEW_ENCODING, 'UTF-8')
        field('gspEnableReload', boolean).value(Settings.GSP_ENABLE_RELOAD, 'false')
        field('cacheSeconds', int).value(Settings.I18N_CACHE_SECONDS, '5')
        field('fileCacheSeconds', int).value(Settings.I18N_FILE_CACHE_SECONDS, '5')
        field('localeResolverType', String).value(Settings.I18N_LOCALE_RESOLVER, 'session')
        field('defaultLocale', String).value('grails.i18n.default.locale', '')

        method('fixedLocale', Locale) {
            if (StringUtils.hasText(defaultLocale)) {
                Locale parsed = StringUtils.parseLocale(defaultLocale)
                if (parsed != null) {
                    return parsed
                }
            }
            Locale.getDefault()
        }

        bean(LocaleResolver).conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) {
            String normalized = localeResolverType == null ? '' :
                    localeResolverType.toLowerCase(Locale.ROOT).replaceAll('[^a-z]', '')
            switch (normalized) {
                case 'cookie':
                    return new CookieLocaleResolver('locale')
                case 'acceptheader':
                case 'header':
                case 'accept':
                    return new AcceptHeaderLocaleResolver()
                case 'fixed':
                    return new FixedLocaleResolver(fixedLocale())
                default:
                    return new SessionLocaleResolver()
            }
        }

        bean(LocaleChangeInterceptor).conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) {
            new ParamsAwareLocaleChangeInterceptor(paramName: 'lang')
        }

        bean(MessageSource).conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) { GrailsApplication grailsApplication, GrailsPluginManager pluginManager ->
            // Captured before tap: the message source has cacheSeconds/fileCacheSeconds
            // properties of its own, which inside tap would shadow these injected fields.
            int configuredCacheSeconds = cacheSeconds
            int configuredFileCacheSeconds = fileCacheSeconds
            new PluginAwareResourceBundleMessageSource(grailsApplication, pluginManager).tap {
                defaultEncoding = encoding
                fallbackToSystemLocale = false
                if (Environment.current.reloadEnabled || gspEnableReload) {
                    cacheSeconds = configuredCacheSeconds
                    fileCacheSeconds = configuredFileCacheSeconds
                }
            }
        }

        bean(AvailableLocaleResolver).conditionalOnMissingBean() { GrailsApplication grailsApplication,
                @Value('${grails.i18n.availableLocales.includePlugins:true}') boolean includePlugins ->
            new AvailableLocaleResolver(grailsApplication.classLoader, fixedLocale(), includePlugins)
        }
    }

Two simplifications versus the original, both functionally identical: bean names are the literal strings 'localeResolver'/'messageSource' rather than references to DispatcherServlet.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'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.

The bean name argument itself is optional, same as it is on field(...)/method(...): all four beans derive their Spring names from their types via Introspector.decapitalize() (LocaleResolver'localeResolver', MessageSource'messageSource', ...) — exactly the values the original file's DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME/AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME constants carry. For the three name-guarded beans, .conditionalOnMissingBeanName(...) then feeds that same derived name into the condition's name member, 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's PluginTests.java) references localeChangeInterceptor by 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 *GrailsPlugin class swaps that suffix for AutoConfiguration, so bare @GrailsBeans on I18nGrailsPlugin regenerates exactly the class identity I18nAutoConfiguration had before this conversion — anything referencing it by name (AutoConfiguration.imports, another auto-configuration's beforeName/afterName, a test importing it directly) keeps working unchanged, with no attribute needed. A class not ending in GrailsPlugin appends AutoConfiguration instead, and @GrailsBeans(autoConfigurationName = "...") overrides either derivation.

What the DSL above actually compiles to — the generated I18nAutoConfiguration sibling, shown here as its Java equivalent (verified via javap: nothing DSL-specific survives into the real bytecode — the beans closure itself is gone, and the one tap block in the messageSource body compiles the way tap does in any statically-compiled Groovy class: an inner closure class, no dynamic dispatch):

@AutoConfiguration(before = {MessageSourceAutoConfiguration.class, WebMvcAutoConfiguration.class})
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class I18nAutoConfiguration {

    @Value("${grails.views.gsp.encoding:UTF-8}")
    private String encoding;

    @Value("${grails.gsp.enable.reload:false}")
    private boolean gspEnableReload;

    @Value("${grails.i18n.cache.seconds:5}")
    private int cacheSeconds;

    @Value("${grails.i18n.filecache.seconds:5}")
    private int fileCacheSeconds;

    @Value("${grails.i18n.localeResolver:session}")
    private String localeResolverType;

    @Value("${grails.i18n.default.locale:}")
    private String defaultLocale;

    private Locale fixedLocale() {
        if (StringUtils.hasText(defaultLocale)) {
            Locale parsed = StringUtils.parseLocale(defaultLocale);
            if (parsed != null) {
                return parsed;
            }
        }
        return Locale.getDefault();
    }

    @Bean("localeResolver")
    @ConditionalOnMissingBean(name = "localeResolver", search = SearchStrategy.CURRENT)
    public LocaleResolver localeResolver() {
        String normalized = localeResolverType == null ? "" :
                localeResolverType.toLowerCase(Locale.ROOT).replaceAll("[^a-z]", "");
        switch (normalized) {
            case "cookie":
                return new CookieLocaleResolver("locale");
            case "acceptheader":
            case "header":
            case "accept":
                return new AcceptHeaderLocaleResolver();
            case "fixed":
                return new FixedLocaleResolver(fixedLocale());
            default:
                return new SessionLocaleResolver();
        }
    }

    @Bean("localeChangeInterceptor")
    @ConditionalOnMissingBean(name = "localeChangeInterceptor", search = SearchStrategy.CURRENT)
    public LocaleChangeInterceptor localeChangeInterceptor() {
        ParamsAwareLocaleChangeInterceptor localeChangeInterceptor = new ParamsAwareLocaleChangeInterceptor();
        localeChangeInterceptor.setParamName("lang");
        return localeChangeInterceptor;
    }

    @Bean("messageSource")
    @ConditionalOnMissingBean(name = "messageSource", search = SearchStrategy.CURRENT)
    public MessageSource messageSource(GrailsApplication grailsApplication, GrailsPluginManager pluginManager) {
        int configuredCacheSeconds = this.cacheSeconds;
        int configuredFileCacheSeconds = this.fileCacheSeconds;
        PluginAwareResourceBundleMessageSource source = new PluginAwareResourceBundleMessageSource(grailsApplication, pluginManager);
        source.setDefaultEncoding(encoding);
        source.setFallbackToSystemLocale(false);
        if (Environment.getCurrent().isReloadEnabled() || gspEnableReload) {
            source.setCacheSeconds(configuredCacheSeconds);
            source.setFileCacheSeconds(configuredFileCacheSeconds);
        }
        return source;
    }

    @Bean
    @ConditionalOnMissingBean
    public AvailableLocaleResolver availableLocaleResolver(GrailsApplication grailsApplication,
            @Value("${grails.i18n.availableLocales.includePlugins:true}") boolean includePlugins) {
        return new AvailableLocaleResolver(grailsApplication.getClassLoader(), fixedLocale(), includePlugins);
    }
}

This isn't just plausible-looking: the full pre-existing grails-i18n test suite (conditional back-off by name and SearchStrategy.CURRENT, property-driven strategy selection, parent-context isolation, ordering against GrailsLocaleResolverAutoConfiguration) passes unmodified against the generated class, now renamed back to I18nAutoConfigurationSpec.groovy to match.

A Plugin subclass is instantiated by DefaultGrailsPlugin via plain reflection, never as a Spring bean, so it can't itself carry @Bean methods 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 *GrailsPlugin class swaps that suffix for AutoConfiguration (I18nGrailsPluginI18nAutoConfiguration here), 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 @ConditionalOnFeature meta-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. @AutoConfiguration is 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 @AutoConfiguration class, it must be registered in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports like any other. Apply the new org.apache.grails.buildsrc.autoconfiguration-imports convention 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-i18n predates 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.packages outside 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. BuildSettings resolves CLASSES_DIR from a working-directory-relative probe:

boolean grailsAppDirPresent = new File('grails-app').exists() || new File('Application.groovy').exists()
if (!grailsAppDirPresent) { CLASSES_DIR = null }

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 @Component registered, started from an empty directory it did not, same jar and same application.yml. That covers a systemd unit, a container with its own WORKDIR, or an app server, while sparing java -jar build/libs/app.jar from the project root, which is presumably why it survived. The custom resolution is replaced by PathMatchingResourcePatternResolver's own; the closure-class filter and plugin TypeFilters are kept. Applications that deploy this way will start getting beans they currently miss.

grails.spring.placeholder.prefix never applied (100b9db). The configurer is a BeanFactoryPostProcessor, so its configuration class is built before @Value injection is active and the injected field was always null.

grails.gorm.autoTimestampCacheAnnotations never reached its consumers (1544bd7). The default was written with Config.put, but AutoTimestampEventListener and DomainModelServiceImpl read it through @Value("${...}"), which resolves against the environment. Development mode cached annotations — the opposite of the intent.

Sitemesh3EnvironmentPostProcessor never ran (da28b4f), registered under org.springframework.boot.env.EnvironmentPostProcessor, which Spring Boot 4 deprecated forRemoval and no longer loads. Its warning could not have printed anyway, logging from a phase that precedes Boot's logging initialisation; it now takes DeferredLogFactory and logs at error.

Three of those trace to one cause, since removed (2930502): GrailsPlaceholderConfigurer advertised GrailsConfigurationAware while being created before the BeanPostProcessor that populates it, so its Config was always null.

Limitations

The DSL still covers a narrower surface than doWithSpring(BeanBuilder):

  • Each top-level statement inside beans { } must be exactly bean(...), field(...), or method(...) with their respective chaining — an if, a for loop, or any other imperative Groovy directly inside beans { } is a compile-time error. This is about the DSL's own top-level statements, not the bodies of bean(...)/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).
  • Dependencies on other beans are expressed only through typed closure parameters (autowired by type), not by name — no ref('beanName') lookup.
  • Abstract parent bean definitions have no equivalent — neither in the DSL nor in BeanRegistry.Spec, whose registerBean always takes a class. This is the one thing that keeps doWithSpring on CoreGrailsPlugin: abstractGrailsResourceLocator is a classless definition carrying a single property, and it is public surface — third-party plugins inherit from it with bean.parent = 'abstractGrailsResourceLocator', asset-pipeline's assetResourceLocator among them.

@GrailsBeans is independent of Plugin/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 a Plugin otherwise. Both are documented side by side in grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc, and application usage — including directly on the Application class — in the Grails-and-Spring chapter (spring/springdslAdditional.adoc).

 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.
@codeconsole
codeconsole force-pushed the spike/grails-beans-dsl branch from d97f8c1 to a8a12d5 Compare July 19, 2026 21:40
@codeconsole codeconsole changed the title [Spike] @GrailsBeans: compile bean-wiring DSL into real @AutoConfiguration classes Add @GrailsBeans: compile bean-wiring DSL into real @AutoConfiguration classes Jul 19, 2026
@codeconsole
codeconsole marked this pull request as ready for review July 19, 2026 21:40
@codecov

codecov Bot commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.71429% with 121 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.6926%. Comparing base (3c5b1b9) to head (2930502).

Files with missing lines Patch % Lines
...s/compiler/beans/GrailsBeansASTTransformation.java 84.8485% 39 Missing and 46 partials ⚠️
...ion/GlobalGrailsClassInjectorTransformation.groovy 6.6667% 13 Missing and 1 partial ⚠️
...vy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy 74.0741% 3 Missing and 4 partials ⚠️
...sitemesh3/Sitemesh3EnvironmentPostProcessor.groovy 42.8571% 4 Missing ⚠️
...ails/plugins/domain/DomainClassGrailsPlugin.groovy 40.0000% 2 Missing and 1 partial ⚠️
...ils/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy 76.9231% 0 Missing and 3 partials ⚠️
...plugins/web/mapping/UrlMappingsGrailsPlugin.groovy 70.0000% 0 Missing and 3 partials ⚠️
.../groovy/org/grails/plugins/CoreGrailsPlugin.groovy 88.8889% 0 Missing and 1 partial ⚠️
.../domain/DomainClassEnvironmentPostProcessor.groovy 85.7143% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16019        +/-   ##
==================================================
+ Coverage     51.4814%   51.6926%   +0.2113%     
- Complexity      17775      17971       +196     
==================================================
  Files            2039       2043         +4     
  Lines           95586      96151       +565     
  Branches        16591      16757       +166     
==================================================
+ Hits            49209      49703       +494     
- Misses          39066      39095        +29     
- Partials         7311       7353        +42     
Files with missing lines Coverage Δ
...rc/main/groovy/beandsl/example/ExampleBeans.groovy 100.0000% <100.0000%> (ø)
...rc/main/groovy/beandsl/example/FancyGreeter.groovy 100.0000% <100.0000%> (ø)
...ple/src/main/groovy/beandsl/example/Greeter.groovy 100.0000% <100.0000%> (ø)
...src/main/groovy/beandsl/example/LoudGreeter.groovy 100.0000% <100.0000%> (ø)
...main/groovy/beandsl/example/plugin/Farewell.groovy 100.0000% <100.0000%> (ø)
...beandsl/example/plugin/FarewellGrailsPlugin.groovy 100.0000% <100.0000%> (ø)
...main/groovy/beandsl/example/plugin/Greeting.groovy 100.0000% <100.0000%> (ø)
...beandsl/example/plugin/GreetingGrailsPlugin.groovy 100.0000% <100.0000%> (ø)
...roovy/grails/plugin/cache/CacheGrailsPlugin.groovy 17.6471% <100.0000%> (-7.3529%) ⬇️
.../web/controllers/ControllersAutoConfiguration.java 95.0820% <ø> (ø)
... and 12 more

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.
@codeconsole
codeconsole force-pushed the spike/grails-beans-dsl branch 2 times, most recently from bf23ad5 to e361649 Compare July 20, 2026 03:02
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.
@codeconsole
codeconsole requested review from jdaugherty, matrei and sbglasius and removed request for jdaugherty July 21, 2026 04:25
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.
@codeconsole

codeconsole commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

The grails-core conversion (6986f0c, with the follow-up fix in 100b9db), same pattern. This is the first one where the plugin keeps its legacy doWithSpring() — the bean builder closure is isolated with @CompileDynamic so the class itself can carry @CompileStatic, which the transform copies onto the generated sibling — and the first where the sibling does not land on the deleted class's exact package. Imports and comments are trimmed here; each linked filename is the verbatim, commit-pinned source.

Before, CoreAutoConfiguration.java (since deleted):

@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 — CoreGrailsPlugin.groovy's class declaration and beans block (the plugin's metadata properties between them are elided below, unchanged by the conversion; doWithSpring() and onChange() further down the file are pre-existing plugin logic, untouched):

@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
        }
    }

CoreGrailsPlugin lives in org.grails.plugins while the deleted class was in org.grails.plugins.core, and the sibling is always emitted into the plugin's own package, so the class becomes org.grails.plugins.CoreAutoConfiguration and the single hand-maintained AutoConfiguration.imports entry moves with it. Nothing else in the repository referenced the old package, and the org.grails prefix filter in grails-testing-support-core still matches. @AutoConfiguration(before = ...) and @AutoConfigureOrder move onto the sibling at compile time, and javap confirms the rest is equivalent to the deleted Java class: the same three method signatures, the same @Bean/@Primary pairs, and no invokedynamic call sites, with the beans closure not surviving on the plugin class.

The .staticMethod() on the placeholder configurer is a behaviour fix rather than a like-for-like port, and it is worth calling out because the DSL made it visible. grails.spring.placeholder.prefix never had any effect: the configurer is a BeanFactoryPostProcessor, so its configuration class is instantiated during invokeBeanFactoryPostProcessors, before AutowiredAnnotationBeanPostProcessor is registered, and the @Value field the original instance method read was therefore always null — confirmed by reflection against a refreshed context, where the field held null and the configurer kept the default ${ prefix. A static factory method needs no enclosing instance and can take the Environment directly, which is the shape Boot's own PropertyPlaceholderAutoConfiguration uses, so the configured prefix now actually applies.

The auto-configuration previously had no test coverage at all; the new CoreAutoConfigurationSpec asserts the three beans and their types, the class loader identity, @Primary winning against a competing ClassLoader candidate, the config properties read-through to the application config, Boot's PropertyPlaceholderAutoConfiguration ordering after this one and backing off, default-prefix placeholder resolution in bean definitions, an unresolvable placeholder being left in place rather than failing the context, and — newly possible — a configured prefix both resolving @{foo.bar} and displacing the default so ${foo.bar} is left literal. One dependency note for anyone writing similar specs: spring-boot-test alone is not enough, because ApplicationContextRunner's signatures reach AssertJ through ApplicationContextAssertProvider; without assertj-core on the test classpath Groovy cannot introspect the runner at all — its metaclass degrades to Object's methods and every call fails to dispatch with MissingMethodException.

…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.
@codeconsole

codeconsole commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

The grails-cache conversion (311fb9a), same pattern. This is the first one where the generated sibling is deliberately not given the deleted class's name: CacheGrailsPlugin derives CacheAutoConfiguration, and pinning the old GrailsCacheAutoConfiguration with @GrailsBeans(autoConfigurationName = ...) would have baked in a naming inconsistency the rest of the framework does not share — UrlMappingsAutoConfiguration, Sitemesh3AutoConfiguration, MailAutoConfiguration, I18nAutoConfiguration and ControllersAutoConfiguration all already follow the *GrailsPlugin -> *AutoConfiguration convention, and the redundant Grails prefix is the outlier. That attribute is for a name that genuinely cannot move, not for preserving legacy naming. Imports and comments are trimmed here; each linked filename is the verbatim, commit-pinned source.

Before, GrailsCacheAutoConfiguration.groovy (since deleted):

@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 — CacheGrailsPlugin.groovy's class declaration and beans block (the plugin's metadata properties between them are elided below, unchanged by the conversion; beanRegistrar() and doWithApplicationContext() further down the file are pre-existing plugin logic, untouched):

@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 beanRegistrar() that the two auto-configured beans are gated on: @ConditionalOnBean(CachePluginConfiguration) refers to the grailsCacheConfiguration definition the registrar contributes, and the registrar runs before auto-configuration conditions are evaluated, so the pair backs off entirely when the plugin is inactive — the jar on the classpath but the plugin excluded. The deleted class's design documentation now lives on the plugin, next to the registrar it describes. @AutoConfiguration, @ConditionalOnBooleanProperty and @ConditionalOnBean all move onto the sibling at compile time, and @CompileStatic is copied onto it so the generated configuration keeps the deleted class's static compilation.

The rename is contained: the only references were the module's own tests, so GrailsCacheAutoConfigurationSpec becomes CacheAutoConfigurationSpec and CacheGrailsPluginSpec's description is updated. It remains a public FQCN change for anyone excluding the auto-configuration through @EnableAutoConfiguration(exclude = ...) or spring.autoconfigure.exclude, which a major release is the right place to take. The full grails-cache suite passes unmodified against the generated class, including CacheAutoConfigurationSpec's direct context.register(CacheAutoConfiguration).

@codeconsole

codeconsole commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

The grails-domain-class conversion (700cf9c), same pattern and the same naming decision as the cache one: the sibling takes the convention name DomainClassAutoConfiguration rather than being pinned to GrailsDomainClassAutoConfiguration. This one also exercises two things the earlier conversions did not — replacing constructor injection, and a rename that other modules reference. Imports and comments are trimmed here; each linked filename is the verbatim, commit-pinned source.

Before, GrailsDomainClassAutoConfiguration.groovy (since deleted):

@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 — DomainClassGrailsPlugin.groovy's class declaration and beans block (the plugin's metadata properties between them are elided below, unchanged by the conversion; beanRegistrar() further down the file is pre-existing plugin logic, untouched):

@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 grailsApplication and messageSources as fields populated by an @Autowired constructor, and the generated sibling always has a no-arg constructor, so neither could be reproduced as-is. Declaring them as field(...) instead would not have worked either: a field('messageSources', List) is a raw List, and Spring cannot resolve that injection point without an element type. Taking both as bean method parameters keeps the generics, and Spring resolves them identically — only when the bean that needs them is created, rather than when the configuration class is instantiated. Verified with javap:

public DefaultMappingContextFactoryBean grailsDomainClassMappingContext(GrailsApplication, List<MessageSource>, List<ConstraintFactory>)

List<MessageSource> and List<ConstraintFactory> survive as distinct injection points, and the two @Qualifier('grailsDomainClassMappingContext') MappingContext parameters carry their annotation through from the closure.

Unlike the cache rename, this class is referenced across modules, so two call sites move with it. ControllersAutoConfiguration orders itself after this one by class literal, and that reference simply follows the rename — worth stating explicitly, since the generated sibling is emitted into the module's jar and therefore resolves from a dependent module's Java compile like any other class. (Verified rather than assumed: swapping the reference to after = {DomainClassAutoConfiguration.class} compiles cleanly.) That keeps the reference type-safe, so a future rename is a compile error rather than a silently stale string. The 8.0 upgrade guide's grails-domain-class row also 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 mention of the old name is left alone, since it documents the removed constraintsEvaluator API rather than the current class.

grails-domain-class, grails-controllers, grails-cache and the uber, web and persistence test suites all pass.

…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.
@codeconsole

codeconsole commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

The grails-databinding conversion (623ce97), which drove a DSL addition: bean(Type) no longer requires a factory closure (4dab384). Five of these seven beans were nothing but their own no-argument construction, and spelling that out as bean('xmlDataBindingSourceCreator', XmlDataBindingSourceCreator) { new XmlDataBindingSourceCreator() } restates the type three times to say nothing. Imports and comments are trimmed here; each linked filename is the verbatim, commit-pinned source.

Before, DataBindingConfiguration.java (since deleted):

@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 — DataBindingGrailsPlugin.groovy's class declaration and beans block. The plugin previously held nothing but a version property, so this is the whole file:

@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 bean(Type) form. 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. Because it constructs the declared type, it 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. That is precisely why dataBindingSourceRegistry above keeps its closure: it declares the DataBindingSourceRegistry interface while constructing DefaultDataBindingSourceRegistry and then initializing it.

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 @Bean values back out of the bytecode confirms all seven are unchanged. The same cleanup was applied to the earlier grails-core and grails-cache conversions (bab7b05), which had carried the same redundancy; the names that remain across all conversions are the ones a type genuinely does not produce — grailsUrlConverter from UrlConverter, mailSession from JndiObjectFactoryBean, grailsDomainClassMappingContext from DefaultMappingContextFactoryBean, and so on.

Two structural notes. configurationProperties was a constructor-injected field, and the generated sibling always has a no-arg constructor; only grailsWebDataBinder reads it, so that one bean takes it as a parameter. And dataBindingSourceRegistry's DataBindingSourceCreator... parameter is written as an array — Spring resolves both identically, and javap shows Groovy hands the generated method the varargs flag back anyway, since a trailing array parameter on a closure compiles to varargs.

The class is renamed DataBindingConfigurationDataBindingAutoConfiguration by the *GrailsPlugin*AutoConfiguration convention. Beyond dropping the inconsistency, it corrects a class that was registered in AutoConfiguration.imports while named only *Configuration. The imports entry was its only reference in the repository; the 7.0 upgrade guide's note about the original DataBindingGrailsPluginDataBindingConfiguration move is left alone as an accurate record of the 7.x change.

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

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

3 participants