Skip to content

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

Open
codeconsole wants to merge 102 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 102 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. Leaving the body empty means the same for a bean with dependencies — bean('validateableConstraintsEvaluator', DefaultConstraintEvaluatorFactoryBean).lazy() { List<MessageSource> messageSources, @Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext, GrailsApplication grailsApplication -> } compiles to a method with exactly those parameters whose body is that constructor call, in the order written. The parameter list stays at the declaration deliberately: it is the only place in Groovy that can carry both a generic type and a parameter annotation, and because the generated body is the constructor call you would have written, the compiler selects the constructor from the parameter types — nothing reads the declared type's constructors, so adding one cannot change what an existing bean injects. Chaining .conditionalOnMissingBean(...) (positional types, the annotation's own named attributes, or bare to back off by return type), .conditionalOnMissingBeanName(...) (backs off by this bean's own name, stated once), .primary(), .lazy(), .scope("name"), and (repeatably) .annotate(AnnotationType[, attr: value, ...]) covers everything a hand-written @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')

        // with dependencies, the parameters say what gets injected and the body stays empty:
        // the constructor call is generated from them, in the order written
        bean(LoudGreeter) { Greeter greeter ->
        }

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

        // a body is only needed when construction is more than passing the parameters through
        bean(Greeter) {
            new Greeter('hello')
        }

        // 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 so is one whose beans property isn't DSL-shaped — a pre-existing descriptor with def beans = [...], or a closure of something else, is left alone rather than failed with a DSL error it never asked for. Declaring the annotation explicitly still works, and opts back in to the strict errors. This is the actual conversion now live in this repository's 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 none that way — it has no doWithSpring at all. The four that benefit from Boot's condition and ordering machinery — classLoader, grailsConfigProperties, grailsResourceLocator, and the placeholder configurer ordered before = PropertyPlaceholderAutoConfiguration — moved into the beans block. The other eight, which depend on the plugin's own runtime state, moved to beanRegistrar(): the auto-proxy-creator variant, the two aware BeanPostProcessors, customEditors, proxyHandler, grailsBeanOverrideConfigurer, the development shutdown hook, and abstractGrailsResourceLocator — the last of these through a BeanDefinitionRegistryPostProcessor, since it is an abstract parent definition (see Limitations).

That move exposed a gap in the legacy unit-test harnesses rather than in the framework. 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-databinding is converted to it here, so its hand-maintained imports file is deleted; the task fails if a module keeps both, since the two copies land at the same path in the jar.

(grails-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. Documented in the 8.0 upgrade guide, with the two things worth checking before upgrading: a duplicate bean name that now fails startup, and an injection point that was unambiguous becoming ambiguous.

grails.spring.placeholder.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 resolve to a constant at compile time: a literal, a reference to a static final field, or a concatenation of those. Resolution happens while the DSL is compiled, before @CompileStatic runs, so it makes no difference whether the constant is declared on an interface or a class. What can't be used is anything that isn't a compile-time constant — a method call, a local, a value read from configuration. An attribute that itself takes a nested annotation isn't supported, and field(...)/method(...) can't declare a nested type (no inner enums/classes).
  • Dependencies on other beans are expressed through typed closure parameters, autowired by type; there is no ref('beanName') lookup. Where the type alone is ambiguous, select by name with a parameter annotation — @Qualifier('beanName'), as DomainClassGrailsPlugin does for its MappingContext.
  • A bean(...)/method(...) body can't reach members of the Plugin it was written in. The body is relocated onto the generated sibling, which extends Object and holds no reference to the plugin, so reading config, grailsApplication or applicationContext from a body fails — at compile time under @CompileStatic, otherwise not until Spring invokes the factory method. This is the habit worth unlearning when converting from doWithSpring, whose closures read plugin state freely: take injected configuration through field(...), and a GrailsApplication (or any other bean) through a typed closure parameter.
  • Abstract parent bean definitions can't be stated directly — not in the DSL, and not through beanRegistrar()'s BeanRegistry.Spec, whose registerBean overloads all take a class and which has no parent/abstract setting. A classless definition existing only to be inherited therefore has to be contributed one level down, from a BeanDefinitionRegistryPostProcessor that reaches the underlying registry. CoreGrailsPlugin does exactly that for abstractGrailsResourceLocator — public surface, since third-party plugins inherit from it with bean.parent, asset-pipeline's assetResourceLocator among them — so this costs a small post-processor rather than keeping the bean builder DSL alive.

@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 83.01246% with 150 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.1890%. Comparing base (57883c4) to head (9f18a77).

Files with missing lines Patch % Lines
...s/compiler/beans/GrailsBeansASTTransformation.java 86.2559% 30 Missing and 57 partials ⚠️
...ion/GlobalGrailsClassInjectorTransformation.groovy 66.6667% 4 Missing and 8 partials ⚠️
.../groovy/org/grails/plugins/CoreGrailsPlugin.groovy 86.2069% 3 Missing and 5 partials ⚠️
...t/annotation/GrailsComponentScanPostProcessor.java 61.9048% 5 Missing and 3 partials ⚠️
...roovy/grails/plugin/cache/CacheGrailsPlugin.groovy 65.0000% 4 Missing and 3 partials ⚠️
...vy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy 74.0741% 3 Missing and 4 partials ⚠️
...lassIgnoringComponentScanBeanDefinitionParser.java 0.0000% 5 Missing ⚠️
...sitemesh3/Sitemesh3EnvironmentPostProcessor.groovy 42.8571% 4 Missing ⚠️
...ils/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy 75.0000% 1 Missing and 3 partials ⚠️
...ails/plugins/domain/DomainClassGrailsPlugin.groovy 40.0000% 2 Missing and 1 partial ⚠️
... and 3 more
Additional details and impacted files

Impacted file tree graph

@@               Coverage Diff                @@
##             8.0.x     #16019         +/-   ##
================================================
+ Coverage         0   52.1890%   +52.1890%     
- Complexity       0      18360      +18360     
================================================
  Files            0       2044       +2044     
  Lines            0      96915      +96915     
  Branches         0      16910      +16910     
================================================
+ Hits             0      50579      +50579     
- Misses           0      38893      +38893     
- Partials         0       7443       +7443     
Files with missing lines Coverage Δ
.../web/controllers/ControllersAutoConfiguration.java 95.0820% <ø> (ø)
...g/context/support/GrailsPlaceholderConfigurer.java 88.2353% <100.0000%> (ø)
...plugins/databinding/DataBindingGrailsPlugin.groovy 100.0000% <100.0000%> (ø)
...ns/i18n/GrailsLocaleResolverAutoConfiguration.java 66.6667% <ø> (ø)
.../domain/DomainClassEnvironmentPostProcessor.groovy 85.7143% <85.7143%> (ø)
...ng/beans/AbstractResourceLocatorPostProcessor.java 83.3333% <83.3333%> (ø)
...plugins/web/mapping/UrlMappingsGrailsPlugin.groovy 47.0588% <81.8182%> (ø)
...ails/plugins/domain/DomainClassGrailsPlugin.groovy 40.0000% <40.0000%> (ø)
...sitemesh3/Sitemesh3EnvironmentPostProcessor.groovy 76.0000% <42.8571%> (ø)
...ils/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy 75.0000% <75.0000%> (ø)
... and 7 more

... and 2027 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.
GlobalGrailsClassInjectorTransformation conflicted. 8.0.x refactored the plugin
descriptor branch - the inline version property is now resolvePluginVersion plus
addPluginVersionProperty, and updateGrailsFactoriesWithType became the plural
updateGrailsFactoriesWithTypes taking ARTEFACT_HANDLER_CLASS and
TRAIT_INJECTOR_CLASS together - while this branch had added the beans DSL
constants and the compileBeansDsl calls to the same two places. Took 8.0.x's
shape and kept both compileBeansDsl calls on it.
Each was reported with a reproducer on the pull request; each now has a test that
fails without the fix.

resolveStringConstant aborted the compilation instead of reporting. getTypeClass()
throws GroovyBugError - an AssertionError, which the catch below could not hold -
for any ClassNode from the compilation unit being compiled, so a .value(...)
constant the AST lookup could not see ended in BUG! exception in phase
'canonicalization' rather than the located message. The lookup could not see
interface-inherited constants either, because ClassNode.getField walks
superclasses only, which is exactly the shape of every grails.config.Settings key.
Now searched through interfaces, with the reflective fallback guarded.

.annotate(...) did not fold its String attribute values, while .value(...) did.
An attribute written as a concatenation is a BinaryExpression at this phase, and
under @CompileStatic the static compiler rewrites it into .plus() before Groovy
folds annotation members - so the shape compiled without @CompileStatic and
failed with it, which is the mode every conversion here uses.

A root statement chained onto another parsed as a single statement. The walk
inward stops at the first bean/field/method name it meets and never looked at the
root call's own receiver, so field('suffix', String).bean('greeter', String) { }
registered greeter and silently dropped suffix.

Convention-derived member names skipped the identifier check that explicit names
get, so field(Boolean) generated a field named 'boolean' and method(Class) one
named 'class' - members no closure body can reference.

The consumed beans property was removed by mutating getFields(), which leaves
ClassNode's fieldIndex entry behind. Another member still reading beans then
compiled to a getfield for a field that is never emitted, failing at runtime with
NoSuchFieldError. removeField clears both, at both call sites.

Only @CompileStatic was propagated to the generated sibling, so @TypeChecked was
dropped and the lifted bodies went unchecked - a silent downgrade, since the same
DSL in the standalone form is checked. Both are propagated now.

No generated node carried a source position, so anything Groovy reported against
one - an .annotate(...) typo, a sibling name clash - was reported at line -1,
column -1. Positions are copied from the DSL statement, and from the plugin class
for the sibling.

Any argument-bearing .conditionalOnMissingBean(...) counted as a discriminating
condition, including .conditionalOnMissingBean(name: ...) - which, where the
duplicates share a name, is as identical on each of them as the bare form the
check already excludes. Only a positional type or a type-bearing attribute
discriminates now; name: and search: do not.
Two defects, both reported on the pull request.

The class name was derived with 'relative.replace(File.separator, ".") - ".class"',
and Groovy's minus removes the first occurrence rather than a trailing extension.
Because the separators have already become dots by then, any package segment
starting with 'class' was mangled -
org/grails/plugins/classloading/FooAutoConfiguration.class became
org.grails.pluginsloading.FooAutoConfiguration.class - which failed to load, was
turned into a warning by the catch below and silently left out of the generated
file. The extension is now dropped by length.

Registering the generated directory on main.output puts it at the same archive
path as a hand-maintained src/main/resources copy that processResources also
contributes, so a module keeping both fed the resource into the jar twice. The
task now fails with the path of the hand-maintained file and what to do about it.

The convention plugin also had no production consumer: its only users were the two
example modules, so neither the collision above nor the plugin itself was exercised
by anything real. grails-databinding now applies it and its hand-maintained imports
file is deleted. The generated file is byte-identical to the one removed, the scan
reports no unresolvable classes, and the jar carries the resource exactly once.
grails-beans-dsl was declared in three different scopes across nine modules. Only
one of those declarations needs to exist: grails-core is on every Grails project's
classpath and declares it api, which is also what makes the implicit
'def beans = { }' convention reach a third-party plugin author -
GlobalGrailsClassInjectorTransformation.compileBeansDsl loads the transform
reflectively and returns silently when it is absent. That reasoning is now recorded
on the line it depends on, since nothing had written it down.

The seven redundant declarations are removed. Compiling is not enough to prove that
safe, precisely because compileBeansDsl fails silently - a missing transform would
leave the beans block as an untouched property and still compile. Each of the seven
was checked for its generated sibling class instead, including a forced
recompilation of grails-i18n and grails-url-mappings, which reach grails-core only
transitively through grails-web-core.

The two example modules keep their own declaration: grails-beans-dsl-example
deliberately demonstrates the DSL without grails-core on the classpath.

The published group becomes org.apache.grails.beandsl. The repo already partitions
coordinates this way for single-module projects - org.apache.grails.i18n,
.databinding, .codecs, .common, .bootstrap - and the DSL being on every project's
classpath by default is a reason for it to have its own namespace, not to sit in the
flat one. Nothing else references the coordinate: publish-root-config.gradle gates on
project name, and no BOM constraint or document spells it out.
They were the only top-level *-example modules in the build; every other example
lives under grails-test-examples/ and follows its conventions.

grails-beans-dsl-example        -> grails-test-examples/beans-dsl        (:grails-test-examples-beans-dsl)
grails-beans-dsl-plugin-example -> grails-test-examples/beans-dsl-plugin (:grails-test-examples-beans-dsl-plugin)

The rename matters as much as the move: root build.gradle populates testProjects by
matching the project name prefix, not the directory, and
functional-test-config.gradle's evaluationDependsOn fan-out filters on exactly that
set - so a module sitting in the directory under its old name would evaluation-depend
on itself. The flat bucket is not auto-scanned like the hibernate5/hibernate7 loops,
so both names are listed explicitly with their projectDir. The -example suffix is
dropped, since no module in that directory carries one.

Conventions adopted: group 'examples' rather than org.apache.grails, which is for
published modules; functional-test-config.gradle in place of test-config.gradle,
with junit-platform-launcher declared explicitly since the former does not supply it;
and grails-code-style and grails-jacoco dropped, which no other module there applies
and which would otherwise put example code into the violation and coverage reports.
The autoconfiguration-imports plugin stays - it is the point of these modules and is
path-independent.

One departure from the review note: grails-test-examples/beans-dsl keeps its own
grails-beans-dsl declaration. It deliberately has no grails-core on its classpath, so
the api that carries the DSL everywhere else does not reach it; the reason is now
recorded on the line. beans-dsl-plugin does have grails-core and drops its
declaration.

All 7 specs run and pass at the new locations, FarewellGrailsPluginAutoDiscoverySpec
among them. That one is the only test asserting a generated sibling's FQCN and now
sits outside core-only CI, which is covered by grails-databinding applying the
convention plugin.
The implicit convention guarded on nothing but "has a beans property" and "does not
already declare @GrailsBeans". A pre-existing plugin descriptor with an unrelated
'def beans = [...]' - or a closure of something else entirely - was therefore taken
through the transform and failed with "Each 'beans' statement must be a bean(...),
field(...), or method(...) call": a source-incompatible change for third-party
plugins, arriving with no mention in the upgrade guide.

The implicit path now requires a closure whose every top-level statement is rooted in
a bean/field/method call, and leaves anything else alone. Writing @GrailsBeans
explicitly still opts in to the strict errors, which is right where the author has
said what they mean. An empty block is still claimed, so both spellings agree on it.

Also records why the ClassNotFoundException branch is allowed to be silent - it is
unreachable only because grails-core declares grails-beans-dsl api, so narrowing that
scope would turn it into a live path where a DSL-shaped block registers nothing and
reports nothing - and restores the javadoc for isIsolatedBuild(), which the earlier
insertion of compileBeansDsl had left stranded above a void method as a dangling
@return.
UrlMappingsGrailsPlugin's @ConditionalOnProperty attributes were hardcoded literals
with a comment claiming a name: attribute must be an inline constant. It already is
one: grails.config.Settings is an interface, so Settings.WEB_URL_CONVERTER is a
genuine public static final field with no accessor - the shape .annotate(...) folds
correctly, @CompileStatic included. What it cannot fold is a concatenation, or a
static final declared on a Groovy class where it is a property with a generated
getter; neither applies here. The literals matched Settings, but nothing kept them in
step, and the two field(...) statements directly above already used the constants, so
the file disagreed with itself.

GreetingGrailsPlugin's javadoc named the sibling GreetingGrailsPluginAutoConfiguration;
the suffix is replaced rather than appended, so it is GreetingAutoConfiguration.
FarewellGrailsPlugin had this right and its spec asserts the name, which is why nothing
caught it - the Greeting spec never checked. It now does, and also asserts the field
and method the DSL declared landed on the sibling rather than the plugin class.
Four fixes to grails-doc, all reported on the pull request.

The .value(...) example used grails.gsp.view.encoding, which does not exist anywhere
in Grails - the same sentence then offered Settings.GSP_VIEW_ENCODING as its constant
form, and that constant is grails.views.gsp.encoding. Readers copying the example got
a placeholder that never resolves. The spec fixture carrying the same invented key is
corrected too.

The bullet on .annotate(...) attribute values was both stricter than reality and
silent about the real edge, and it is the bullet that sent UrlMappingsGrailsPlugin to
hardcoded property strings. A bare constant reference does fold; what does not is a
static final declared on a Groovy class - a property with a generated getter that
@CompileStatic rewrites into a call - or a concatenation of constants. Restated in
those terms.

A limitation was missing, and it is the one most likely to catch someone migrating
from doWithSpring: a bean or method body cannot reach members of the Plugin it was
written in, because the body is relocated onto a sibling that extends Object and holds
no reference to the plugin. The bullet saying bodies accept "arbitrary Groovy" now
points at it. Two tests cover it: the @CompileStatic diagnostic, and the shape of the
generated class that makes it so.

The upgrade guide documented one of the four FQCN changes this branch makes. The other
three - CoreAutoConfiguration's package move, GrailsCacheAutoConfiguration ->
CacheAutoConfiguration, and DataBindingConfiguration -> DataBindingAutoConfiguration -
now get the same treatment, including that Spring Boot only reports an invalid exclude
for a class still on the classpath, so a stale entry is ignored in silence and the
beans it was meant to suppress register anyway. Documenting rather than teaching
autoConfigurationName to take a fully-qualified name is deliberate: these classes
should carry their convention name.
The closure declared (GrailsApplication, List<MessageSource>, MappingContext) while
DefaultConstraintEvaluatorFactoryBean's constructor takes
(List<MessageSource>, MappingContext, GrailsApplication), so the body was reordering
them by name. Correct as written, and needlessly easy to get wrong. Reordered to read
straight through.

No behaviour change: Spring resolves a @bean method's parameters by type and qualifier,
never by position, and the generated method still carries @qualifier on its
MappingContext parameter.
A bean whose construction is nothing but passing its injected dependencies through had
to spell the constructor call out, repeating the type already named in bean(...) and the
parameter names already listed in the closure header. Leaving the body empty now means
that call: the parameters become the generated method's parameters, in the order written,
and the body becomes new Type(p1..pn).

The parameter list stays at the declaration, which is the point. It is the only place in
Groovy that can carry both a generic type and a parameter annotation - neither
List<MessageSource> nor @qualifier(...) is expressible as an argument - so a reader sees
every dependency and every qualifier without leaving the file. And because the generated
body is the same constructor call the author would have written, which constructor it
selects is decided by the compiler from the parameter types. Nothing reads the declared
type's constructors, so adding one cannot reach back and change or break a declaration.
A test covers exactly that: a type with both a no-argument and an argument-taking
constructor, where the parameter list picks between them.

bean(Type) { } with no parameters is bean(Type), so the two spellings agree, and the
interface/abstract rejection covers both.

Five declarations lose a redundant line: the constraint evaluator, the three mail beans
and the CORS filter. Their generated @bean signatures are unchanged, verified with javap.
A body is still required wherever construction is more than a pass-through, such as
new ConfigProperties(grailsApplication.config) or anything followed by .tap { }.
@codeconsole

codeconsole commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Review comments are answered inline, each naming the commit its fix landed in. Four threads note where I did something different from what was suggested.

d8f2a7b78b adds one shape to the DSL that did not come from the review: a bean closure with parameters and an empty body constructs the declared type from those parameters.

bean('validateableConstraintsEvaluator', DefaultConstraintEvaluatorFactoryBean).lazy() {
        List<MessageSource> messageSources,
        @Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext,
        GrailsApplication grailsApplication ->
}

The parameters are what gets injected; the body is generated as new DefaultConstraintEvaluatorFactoryBean(messageSources, mappingContext, grailsApplication). A body is still needed when construction is more than passing the parameters through — new ConfigProperties(grailsApplication.config), or anything followed by .tap { }. Documented in the guide and on @GrailsBeans.

Applied to five declarations: the constraint evaluator, the three mail beans and the CORS filter.

grails.util.GrailsUtilStackFiltererSpec and org.apache.grails.core.GrailsBootstrapRegistryInitializerSpec fail on 8.0.x independently of this branch.

The existing test reflected on the generated method and confirmed the annotation had
survived the transform, which is not the same as confirming Spring honours it. The new
test registers the generated class in an AnnotationConfigApplicationContext with two
beans of the dependency type, so the injection point is genuinely ambiguous, and asserts
the qualified one is what arrives - once through a closure body and once through the
generated constructor call, since the two paths reach the parameters differently.

Verified non-vacuous: dropping the qualifier from the fixture fails the test with
UnsatisfiedDependencyException rather than quietly passing.

Also corrects the class javadoc, which claimed the generated method's "body and
parameters are lifted directly from the DSL closure". The parameters always are; the body
is, except where the closure body is empty or the closure is omitted, when a
new Type(...) call over those parameters is synthesised instead.
The class javadoc's sentence on the generated method's name sat at 115 columns against
~85-100 for the rest of the block. Rewrapped; no wording change.

The five parameter-only bean declarations are wrapped too. With the body empty the
parameter list is the whole statement, so they had grown to between 124 and 248 columns
on a single line - the constraint evaluator's being the worst - and read nothing like the
multi-line form the guide and the annotation javadoc both show. Generated @bean
signatures are unchanged, checked with javap.

Other lines over 120 in these files are left alone: they are pre-existing and carry a
closure body, where the file's own convention already puts the header on one line.
ExampleBeans is the module whose job is to demonstrate the DSL, so it should show the
current shapes. fancyGreeter's body was new FancyGreeter() with no parameters, which the
bodyless form covers; loudGreeter's was a pass-through of its one parameter, which the
empty-body form covers. The three generated @bean methods are unchanged.

An audit of every bean(...) declaration across the converted plugins found no others
eligible: the rest either run more than one statement, use .tap { }, construct a different
type than the one declared, or pass an expression such as grailsApplication.config rather
than a bare parameter.

The PR description's headline example is updated to match, and its comment now says a body
is needed when construction is more than passing the parameters through, rather than "when
there is something to say".
@codeconsole
codeconsole requested a review from jdaugherty July 30, 2026 14:50
Concatenated constants do fold in .annotate(...). foldStringValue runs every attribute
value through the same resolution .value(...) uses, and a test covers the shape under
@CompileStatic - both landed in 9743d3a, before the bullet claiming otherwise was
written. What genuinely does not fold is narrower: a static final on a Groovy class,
which is a property whose getter @CompileStatic rewrites the reference into a call to.
An interface's constants are real fields and fold, which covers every Settings key.

"Not by name" was wrong. There is no ref('beanName'), but a parameter annotated
@qualifier('beanName') selects by name, and this PR's own conversions depend on it -
DomainClassGrailsPlugin resolves its MappingContext that way. The bullet steered readers
away from a mechanism the DSL relies on.
The scan loaded every candidate class and, on any failure, warned and skipped it. A
genuine @autoConfiguration could therefore drop out of the generated imports file while
the build stayed green - its beans never registering, with nothing but a warning in a
long log to say so. That is the outcome this task exists to prevent, and the
String.minus defect fixed in 62b74b9 was an instance of it that reached review
undetected.

ASM reads the annotation table straight from the class file, so nothing is loaded and
there is no failure to swallow. A class whose supertype is absent is now detected
normally, which is what the replaced test asserts - the old one asserted the opposite,
that such a class was excluded.

Two things fall out of it. The scan classpath input is gone, since resolving supertypes
is no longer needed. And the class name comes from the class file rather than from its
path, so the whole category of path-to-name mangling is gone with it.

The check stays a direct annotation match, not a meta-annotation search, exactly as
isAnnotationPresent was. A malformed or unreadable class file now fails the build rather
than being skipped quietly.

Verified against all three consumers - grails-databinding and both example modules -
producing byte-identical imports files, with the jar carrying the resource once.
buildAsmVersion duplicated a version dependencies.gradle already carries, with a comment
telling the next person to keep the two in step - the exact smell the single-source policy
exists to prevent.

gradleBomDependencyVersions had no asm.version key, so reading it needed the key adding
first. That map is a version-property lookup table, not a source of constraints:
grails-gradle-bom's pomCustomization walks the dependencies already in its
dependencyManagement and only asks the map what to call each one's version property. An
unused key therefore publishes nothing, which the regenerated pom-default.xml confirms -
byte-identical to before.

The four asm.version declarations in the per-BOM customBomVersions blocks are left alone;
they predate this and are a separate concern.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked AI to take another pass at this, overall I think this is the right solution at this point and I'll approve later today after some further testing.

Second pass, against 297767a.

Everything from the previous round is fixed. I checked each one against the branch rather than the commit messages: the interface walk and GroovyBugError catch, foldStringValue, the chained-root rejection, the derived-name identifier check, removeField, @TypeChecked propagation, the five source positions, the name:/search:-only back-off, the four upgrade-guide rows, the three documentation corrections, GreetingAutoConfiguration, the ASM-based imports scan, the Settings constants in UrlMappingsGrailsPlugin, the single api declaration and the seven deletions, the restored javadoc, the narrowed implicit trigger, and both example modules under grails-test-examples/. The 157 specs in grails-beans-dsl pass, grails-databinding:generateAutoConfigurationImports produces exactly org.grails.plugins.databinding.DataBindingAutoConfiguration, and the i18n specs genuinely exercise the beforeName ordering rather than declaration order. Those threads are resolved.

Six notes from this pass. The first is a compiler crash in the empty-body form added since the last round; the rest are documentation and diagnostics, plus one condition that came off in the cache conversion.


Parameter[] beanParameters = factory == null || factory.getParameters() == null ?
Parameter.EMPTY_ARRAY : factory.getParameters();
Statement beanBody = constructsDeclaredType ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The synthesized ConstructorCallExpression is the one generated node in this file with no setSourcePosition(...), and it is the node most likely to be the subject of a compile error: the constructor is selected from the closure's parameter types, so a parameter list matching no constructor — after someone adds an argument to the type's constructor, say — is the ordinary failure mode of the empty-body form.

Without a position the failure never reaches the error collector as a located message. Compiling the documented shape:

class NeedsString {
    NeedsString(String required) { }
}

@GrailsBeans
@CompileStatic
@AutoConfiguration
class ProbeWrongParams {
    def beans = {
        bean(NeedsString) { Integer n ->
        }
    }
}

In a normal build (assertions off):

org.codehaus.groovy.GroovyBugError: BUG! exception in phase 'class generation' in source unit '...' at line -1 column -1
On receiver: NeedsString with message: <$constructor$> and arguments: (n)
StaticTypesCallSiteWriter#makeCallSite should not have been called. Call site lacked method target for static compilation.
Please try to create a simple example reproducing this error and file a bug report at https://issues.apache.org/jira/browse/GROOVY

Under -ea — which is what Gradle's test workers run with, so it is what a plugin author sees from ./gradlew test — it degrades further, to a bare java.lang.AssertionError: null out of StaticCompilationVisitor.visitConstructorCallExpression. The bodyless bean(NeedsString) spelling fails identically. Same failure class as the getTypeClass() one: an internal compiler error at line -1 in place of a located message, with an invitation to file a Groovy bug.

The discrepancy is that the hand-written equivalent this is documented as compiling to is diagnosed properly:

[Static type checking] - Cannot find matching constructor NeedsArgsHand(). Please check if the declared type is correct and if the method exists.
 @ line 15, column 43.
               bean(NeedsArgsHand) { new NeedsArgsHand() }
                                     ^

So "resolved from these argument types exactly as it would be for a hand-written new Type(...)" holds for resolution but not for what the author is told when it fails.

Copying the position onto the construction closes it. Verified against the branch:

ConstructorCallExpression construction =
        new ConstructorCallExpression(beanType, constructorArguments(beanParameters));
construction.setSourcePosition(baseCall);

after which the same fixture reports

[Static type checking] - Cannot find matching constructor NeedsString(java.lang.Integer). Please check if the declared type is correct and if the method exists.
 @ line 15, column 21.
               bean(NeedsString) { Integer n ->
               ^

pointing at the bean(...) statement. A test would help: the spec has no case for a constructor mismatch in either the bodyless or the empty-body form, which is why this got through.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a4deb3d2cd. Reproduced exactly as described — BUG! at line -1 for the empty-body form, against a located "Cannot find matching constructor" for the hand-written equivalent.

The position now goes on the whole synthesized construction: the ConstructorCallExpression, its argument list, each VariableExpression, and the enclosing ReturnStatement. Both spellings now report

[Static type checking] - Cannot find matching constructor NeedsString(java.lang.Integer).
 @ line 15, column 13.
               bean(NeedsString) { Integer n ->
               ^

Two tests, one per spelling, asserting the message and that it contains neither line -1 nor BUG!.

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

* Each top-level statement inside `beans { }` must be exactly `bean(...)`, `field(...)`, or `method(...)`, with their respective chaining — an `if`, a `for` loop, or any other imperative Groovy directly inside `beans { }` is a compile-time error, not a runtime one. Note that this is about the DSL's own top-level statements, not the bodies of `bean(...)`/`method(...)` closures themselves, which accept arbitrary Groovy, subject only to what is reachable from the generated class (see the last bullet below) — and `field(...)`/`method(...)` already cover the common reasons to want shared state or logic in the first place.
* `.annotate(...)` attribute values must be compile-time constants: strings, numbers, booleans, class literals, enum constants, or arrays of these. A bare constant *reference* counts, and so does a *concatenation* of them — `.annotate(Value, value: '${' + Settings.I18N_LOCALE_RESOLVER + ':session}')` folds, `@CompileStatic` included, the same way `.value(...)` folds its arguments. The one shape that does not fold is a `static final` declared on a Groovy *class*: that is a property with a generated getter, and `@CompileStatic` rewrites the reference into a call to it. An interface's constants are real `public static final` fields and fold correctly — which is every `grails.config.Settings` key. An attribute that itself takes a nested annotation as its value isn't supported.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception this bullet still carries is stale in the same way the concatenation one was, and for the same reason: foldStringValue resolves the value during canonicalization, before @CompileStatic's transform runs, so there is no reference left for it to rewrite into a getter call.

A static final on a Groovy class does fold. All four combinations, with @CompileStatic on the annotated class:

constant owner .value(KEY, 'x') .annotate(Value, value: '${' + KEY + ':y}')
same-compilation-unit Groovy class ${probe.key:x} ${probe.key:y}
already-compiled Groovy class ${precompiled.probe.key:x} ${precompiled.probe.key:y}

A bare reference as the whole attribute value folds too — .annotate(Value, value: PrecompiledKeys.KEY) gives precompiled.probe.key.

findStaticFinalField reads the FieldNode, and a Groovy property's backing field is still static final with its initial expression intact, so the property-versus-field distinction the bullet turns on no longer has any effect. As written it steers readers away from a shape that works — which is what the earlier version of this bullet did with Settings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and this bullet has now been wrong twice. The previous correction removed the concatenation claim but kept this exception on the strength of your earlier report — which predated the fix that changed the behaviour. I should have tested it rather than carrying it forward.

Confirmed all three shapes fold with the constant on a Groovy class under @CompileStatic: .value(KEY, 'x')${probe.key:x}, the concatenation → ${probe.key:y}, a bare reference → probe.key.

Rewritten in f31d57061f to say what actually holds — values must resolve to a compile-time constant, resolution happens before @CompileStatic runs, so interface-versus-class makes no difference — with three unrolled cases so it cannot drift again. PR description updated to match.


| `grails-databinding`
| `org.grails.plugins.databinding.DataBindingConfiguration`
| Now `org.grails.plugins.databinding.DataBindingAutoConfiguration`. It was registered as an auto-configuration while named only `*Configuration`; the name now says what it is. Update any `@EnableAutoConfiguration(exclude = ...)` or `spring.autoconfigure.exclude` entry that referenced the old name; the same silent-ignore applies as above.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These four rows cover every FQCN change, but not the one change in this PR that alters which beans a running application ends up with.

The component-scan fix means an application whose working directory is not the project root — a systemd unit, a container with its own WORKDIR, an app server — starts registering @Components under grails.spring.bean.packages that it silently did not register before. The PR description says so plainly ("Applications that deploy this way will start getting beans they currently miss"); the guide says nothing.

It arguably deserves a row here more than the renames do. A stale exclude entry leaves a name that no longer resolves, which someone can at least search for; this one changes the contents of a running context with nothing to grep for, and in the direction of adding beans — a duplicate registration or an unexpected @Component picking up a bean name is how it will present.

Worth noting while you are here that grails.spring.bean.packages appears nowhere in grails-doc at all, so there is no existing page describing the property for this to cross-reference.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it matters more than the renames. Added as section 39 in 4e51a2556a, covering the working-directory probe, which deployment shapes it affected, and the two things to check before upgrading: a duplicate bean name that now fails startup, and an injection point that was unambiguous becoming ambiguous.

Noted that grails.spring.bean.packages appears only in the Application Properties reference, so there is nothing to cross-reference.

The section is numbered 39 after the duplicate 38.s. Those, and a duplicate 34., are both on 8.0.x — I checked — so renumbering belongs in its own PR against 8.0.x rather than as seven unrelated heading changes here.

String callName, boolean requireValidIdentifier) {
if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1) instanceof ClassExpression)) {
if (args.size() == 2 && args.get(0) instanceof ClassExpression) {
addError(call, source, callName + "(...) takes the name before the type: " +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch fires for a shape that has nothing to do with argument order, and the message then sends the reader somewhere unrelated. Chaining a qualifier after the factory closure rather than before it:

bean(String) { 'hi' }.lazy()
bean(...) takes the name before the type: bean("myGreeter", Greeter), not bean(Greeter, "myGreeter")
 @ line 9, column 34.
               bean(String) { 'hi' }.lazy()
                            ^

The named spelling avoids the wrong advice but is no more useful:

bean('greeting', String) { 'hi' }.lazy()
bean(...) requires a type, optionally preceded by a name, e.g. bean(Greeter) or bean("myGreeter", Greeter)

It arrives here because factory is read off outerCall's arguments, so a closure sitting on baseCall is never stripped and is still in baseArgs at this point. Both spellings are a plausible first guess: the closure reads as belonging to bean(...), and it is where a body goes in every other DSL a Grails author writes. method('name', Type) { ... }.annotate(X) lands the same way, via "method(...) must end with a body closure".

Detecting a trailing ClosureExpression on the base call when qualifiers are present, and saying that qualifiers come before the body, is a one-line diagnostic — and a row in the malformed-statement table.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a4deb3d2cd. bean(String) { 'hi' }.lazy() now reports

the body closure comes last, after every chained qualifier - write bean(...).lazy(...) { ... }
rather than bean(...) { ... }.lazy(...)

Put on the shared statement path rather than in bean(...), so field(...) and method(...) get it too — method('x', String) { 'y' }.annotate(...) was landing on "must end with a body closure" the same way. Three table rows.

@Slf4j
@CompileStatic
@AutoConfiguration
@ConditionalOnBooleanProperty(name = 'grails.cache.enabled', matchIfMissing = true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ConditionalOnBean(CachePluginConfiguration) came off in this conversion and nothing replaces it. The deleted GrailsCacheAutoConfiguration carried it with the reason spelled out:

Gated on the CachePluginConfiguration definition contributed by the cache plugin descriptor's registrar (which runs before auto-configuration conditions are evaluated), so the auto-configuration backs off entirely when the plugin is not active — e.g. the jar is on the classpath but the plugin is excluded — keeping it in lockstep with the descriptor.

The gate could not survive verbatim: grailsCacheConfiguration is now declared in this same beans block rather than by the registrar, so it would be conditioning on a bean the same class contributes. But the condition it expressed has gone with it. With only @ConditionalOnBooleanProperty(name = 'grails.cache.enabled') left, a build with grails-cache on the classpath but the plugin not loaded now gets grailsCacheConfiguration, grailsCacheAdminService, customCacheKeyGenerator and grailsCacheManager registered anyway — exactly the case the deleted comment named.

Is that deliberate, i.e. is jar-on-the-classpath now sufficient for the cache beans, the way it is for an ordinary Boot starter? If so the class javadoc should say it: it explains the back-off design carefully but is silent on the lockstep that was dropped. If not, the descriptor no longer contributes anything for the generated configuration to gate on, so restoring it needs a different anchor — either way better decided explicitly than by omission.

Two smaller notes on the same move. environment.getProperty('grails.cache.enabled', Boolean, true) and @ConditionalOnBooleanProperty do not agree on non-canonical values: Spring's Boolean conversion accepts yes/on/1, the condition matches only true/false, so grails.cache.enabled=no used to disable the plugin and now leaves it enabled. And the log.warn('Cache plugin is disabled') that accompanied the old gate is gone, so disabling it is now silent.

@codeconsole codeconsole Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate, and now stated in the class javadoc (33c9ee771d) rather than left to omission: the jar being present is sufficient, as for an ordinary Boot starter. The gate cannot be restated once grailsCacheConfiguration is declared in this same block — it would condition on a bean the class contributes — and the plugin declares no profiles or environments, so it is active wherever its jar is. That is what makes it a change of mechanism rather than of which applications get the beans. The javadoc says a descriptor that later becomes conditionally inactive needs a new anchor.

On the property: real, but the direction is the other way round. Comparing @ConditionalOnBooleanProperty against config.getProperty(..., Boolean, true):

value condition old conversion
no / off / 0 disabled disabled
yes / on / 1 disabled enabled

So =no is unchanged; =yes is the regression. And it was worse than a disagreement: doWithApplicationContext re-read the flag with the relaxed conversion, so on =yes it believed caching was on and asked for grailsCacheConfiguration — which the condition had just declined to register. Startup failure.

That hook is now keyed on applicationContext.containsBean(...), which cannot drift from the condition that decided it. The log.warn is restored with it.

Covered in 9f18a77067: three unrolled cases for yes/on/1 asserting the beans are absent and the hook backs off. Against the previous implementation all three fail with NoSuchBeanDefinitionException, and a fourth case runs the hook on the enabled context so the back-off cannot pass by being taken unconditionally.

* of them has any effect where the author wrote them. Annotations outside that set can be moved
* explicitly via {@link #moveAnnotations}.
*/
@Retention(RetentionPolicy.RUNTIME)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carrying this forward from the scope thread so it does not get lost with it, since that thread's actual question — one story for the nine declarations — is settled.

Nothing reads @GrailsBeans at runtime. The transform consumes it at canonicalization, and it is not among the annotations moved to the generated sibling, so it stays on the source class where it is inert. RetentionPolicy.CLASS would leave every compile-time use working while keeping it out of the runtime image — which matters slightly more now that grails-core declares grails-beans-dsl as api and so puts it, and its own spring-context/spring-boot-autoconfigure api dependencies, on every application's runtime classpath.

Not a blocker, and I am fine with either answer. If RUNTIME is deliberate — leaving room for a runtime consumer later, say — a line on the annotation saying so is enough to close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to CLASS in 4e51a2556a, with a comment on the annotation recording why.

Nothing reads it: every converted module passes with it invisible, and ExampleBeans — which declares it explicitly — reports only @AutoConfiguration at runtime. It is a new annotation on an unreleased branch, so there is no compatibility cost, and your point about grails-core declaring the module api is what tipped it.

…wrong thing

The synthesized construction carried no source position - the one generated node left
without one, and the node most likely to be the subject of an error, since a parameter
list matching no constructor is the ordinary failure of the bodyless and empty-body
forms. Unlocated, it surfaced under static compilation as

  BUG! exception in phase 'class generation' ... at line -1 column -1
  StaticTypesCallSiteWriter#makeCallSite should not have been called
  Please try to create a simple example ... and file a bug report

where the hand-written equivalent gets "Cannot find matching constructor" against the
statement. Both spellings now report the same located message. Two tests cover it; the
spec had no case for a constructor mismatch in either form, which is how it got through.

A qualifier chained after the body closure sent the reader somewhere unrelated:
bean(String) { 'hi' }.lazy() reported "takes the name before the type", advice that has
nothing to do with the mistake. The closure sits on the root call in that spelling, so
the [name, ] Type parsing found it as an extra argument. It is now named for what it is,
on the shared path so bean(...), field(...) and method(...) all get it, with three table
rows.
It does. foldStringValue resolves the reference while the DSL is compiled, before
@CompileStatic could rewrite it into a getter call, so the property-versus-field
distinction the bullet turned on has no effect: .value(KEY, 'x'), a concatenation, and a
bare reference all fold whether the constant is declared on a class or an interface.

This is the second time this bullet has been wrong. The previous correction removed the
concatenation claim but kept the Groovy-class exception, on the strength of an earlier
report rather than a test - and that report predated the fix that changed the behaviour.
Three cases now cover it so the documentation cannot drift from the code again.

What actually cannot be used is anything that is not a compile-time constant, which is
what the bullet says now.
doWithApplicationContext re-read grails.cache.enabled through
config.getProperty(..., Boolean), which accepts yes/on/1, while the
@ConditionalOnBooleanProperty deciding whether the beans exist compares the literal
strings true/false. On grails.cache.enabled=yes the two disagreed in the direction that
fails startup: the hook believed caching was on and asked for grailsCacheConfiguration,
which the condition had just declined to register. Asking the context what it has cannot
drift from the condition that decided it.

The warning that accompanied the old gate is restored with it - disabling the plugin had
become silent.

Also records the decision the conversion made by omission. The deleted
GrailsCacheAutoConfiguration carried @ConditionalOnBean(CachePluginConfiguration), which
tied it to the descriptor's registrar; that cannot be restated now that
grailsCacheConfiguration is declared in this same beans block, since the condition would
gate on a bean the class itself contributes. The plugin declares no profiles or
environments, so it is active wherever its jar is, which is what makes this a change of
mechanism rather than of which applications get the beans - stated in the javadoc so a
descriptor that later becomes conditionally inactive is known to need a new anchor.
…o CLASS retention

The upgrade guide covered the four FQCN renames but not the one change here that alters
which beans a running application ends up with. An application whose working directory is
not the project root - a systemd unit, a container with its own WORKDIR, an application
server - registered none of the @components under grails.spring.bean.packages and now
registers all of them. A stale exclude entry leaves a name someone can grep for; this
changes the contents of a live context in the direction of adding beans, so it gets a
section naming what to check: a duplicate bean name now failing startup, and an injection
point that was unambiguous becoming ambiguous.

@GrailsBeans becomes CLASS retention. The transform consumes it at canonicalization and
it is not among the annotations moved to the generated sibling, so nothing reads it at
runtime - confirmed by every converted module passing with it invisible, and by
ExampleBeans, which declares it explicitly, reporting only @autoConfiguration at runtime.
It is a new annotation on an unreleased branch, so there is no compatibility cost, and
grails-core declaring grails-beans-dsl api puts this module on every application's
runtime classpath.
The spec exercised grails.cache.enabled=false, which never disagreed. The values that do -
yes, on and 1, which @ConditionalOnBooleanProperty reads as disabled and
config.getProperty(..., Boolean) as enabled - had no test, so the regression the fix was
written for was not covered.

Three unrolled cases assert the beans are absent and that doWithApplicationContext backs
off rather than throwing. Against the previous implementation all three fail with
NoSuchBeanDefinitionException, which is the production failure: the hook read the property
back, resolved it as true, and asked for a bean the condition had just declined to
register.

Wiring the plugin with a GrailsApplication carrying the same configuration is what makes
that real. Without it the old implementation failed these tests too, but on a
NullPointerException from an unset grailsApplication - an artefact of the harness rather
than the path being tested.

A fourth case runs the hook on the enabled context and asserts it creates the default
caches, so the back-off above cannot pass by being taken unconditionally.
@testlens-app

testlens-app Bot commented Jul 31, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 9f18a77
▶️ Tests: 66861 executed
⚪️ Checks: 62/62 completed


Learn more about TestLens at testlens.app.

@codeconsole
codeconsole requested a review from jdaugherty July 31, 2026 18:40
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.

5 participants