apache / apache/grails-core

Groovy 6 compatibility: changes required in Grails, and when each can be removed

Open
#16,157 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
Groovy
Stars
2.9k
Forks
975
Avg merge
1d 22h
Merged PRs (30d)
92

Description

Tracking issue for the changes Apache Grails needs in order to build, test, and ship against Apache Groovy 6. It documents what landed via [#15558](https://github.com/apache/grails-core/pull/15558) on `9.0.x`, why each item exists, how it affects applications upgrading from Grails 7/8, and the condition under which it can be deleted.

Current state: Groovy `6.0.0-beta-2`, Grails 9 (`9.0.x`), JDK 21, Spring Boot 4.1, Spock `2.4-groovy-5.0` (no `groovy-6.0` artifact published yet).

Every row was established empirically: a workaround was only kept when removing it produced an observed failure, and only removed when the build and tests stayed green without it.

## Documentation standard (treat every workaround the same)

Each remaining workaround must have all five of:

1. A source comment (or build-file comment) naming Groovy 6 / the specific trigger.
2. A pointer to this issue (`#16157`).
3. The observed failure without the change.
4. The deletion condition ("remove when X").
5. An end-user impact class from the table below.

Do not leave "framework-only" items uncommented while test-only or build-only items are documented, or the reverse.

**Impact classes**

| Class | Meaning |
|---|---|
| App source | An upgraded application can hit the same Groovy 6 behaviour in its own code and may need the same change |
| App runtime | Framework behaviour the app calls at runtime changed; no app source edit required if they stay on public APIs |
| App test build | The app's test compile/runtime needs a Gradle/Maven flag or extra dependency until an upstream artifact exists |
| Framework only | Confined to Grails internals / this repo's build. Apps do not copy it |
| Permanent | Not a workaround. Keep it |

## 1. Upstream Groovy 6 regression (not filed upstream)

Static type checking merges the flow state of a `||` inside a closure to `void`, so a variable guarded by `x == null || ...` becomes unusable in the branch body.

```groovy
@groovy.transform.CompileStatic
class Repro {
void bind(Collection val, Class componentType) {
List boundItems = []
((Collection) val).each { item ->
if (item == null || componentType.isAssignableFrom(item.getClass())) {
boundItems << item // FAILS on 6.0.0-beta-2
}
}
}
}
```

```
[Static type checking] - Cannot find matching method java.util.ArrayList#leftShift(void)
```

The Map form fails equivalently with `Cannot find matching method java.util.LinkedHashMap#putAt(java.lang.Object, void)`.

- Compiles cleanly on Groovy `5.0.8`; fails on `6.0.0-beta-2`.
- `&&` in the same position compiles. Only `||` triggers it.
- Workaround: hoist the disjunction into a `boolean` local. Short-circuiting and null handling are unchanged.

Applied in `grails-web-databinding/.../GrailsWebDataBinder.groovy` (Collection and Map branches).

**Upgrade impact:** App source. Any `@CompileStatic` / `@GrailsCompileStatic` closure in an application that uses `||` as a type guard can fail to compile on Groovy 6 until hoisted the same way. The Grails framework change does not alter runtime bind behaviour.

## 2. Remaining items on 6.0.0-beta-2

| # | File | Groovy 6 behaviour | Observed failure without it | Removable when | End-user impact |
|---|---|---|---|---|---|
| 1 | `grails-web-databinding/.../GrailsWebDataBinder.groovy` | `\|\|` flow state inside a closure infers as `void` | `ArrayList#leftShift(void)` | Section 1 is fixed upstream | **App source** if the app has the same `@CompileStatic` pattern. Framework bind behaviour unchanged |
| 2 | `grails-core/.../config/external/WriterFilteringMap.groovy` | `@Delegate` now generates mutator methods that were previously excluded | `WriteFilteringMapSpec`: `getWrittenValues().size() == 0` | Groovy restores the previous `@Delegate` mutator exclusion | **App source** if the app uses `@Delegate` the same way. Grails config filtering stays correct because of this change |
| 3 | `grails-datamapping-core/.../GormEntity.groovy` | Generic trait method signatures are specialized differently, so trait-injected `merge(Object)` is not found | `NoSuchMethodException: Book.merge(Object)` | Groovy restores prior generic trait-signature resolution | **App runtime**. Domain `merge()` is a public GORM API. Without this, upgraded apps can fail at runtime when calling `merge` |
| 4 | `grails-datastore-core/.../reflect/ClassPropertyFetcher.java` | Interface methods surface differently during property introspection | `MissingMethodException ... __transients$get` | Unknown - needs upstream confirmation | **App runtime**. Property / transients introspection is used by GORM and databinding. Apps should not call this class directly |
| 5 | `grails-testing-support-http-client/.../utils/XmlUtils.groovy` | SAX/JAXP feature set recognised by the default parser changed | `DOCTYPE is disallowed...` | Unknown. This is XXE-hardening; do not drop it for convenience | **App test build** if the app uses this testing helper. Not a production runtime change. Keep the hardening |
| 6 | `grails-validation/.../ValidateableTraitSpec.groovy` | Static trait methods are emitted with a modifier combination the verifier rejects | `illegal combination of modifiers: abstract and static` | Groovy fixes static trait-method emission | **App source**. `Validateable` is a public trait. Command objects / domain-like types in the app can fail class verification on Groovy 6 |
| 7 | `grails-data-hibernate7/.../HibernateGormInstanceApi.groovy` | Negated `instanceof` requires explicit parenthesization | Compile failure | Groovy restores prior parsing precedence | **App source** if the app has `!(x instanceof Foo)` under static compile. Framework persistence behaviour unchanged |
| 8 | `grails-fields/.../BeanPropertyAccessorImpl.groovy` | `@Canonical` no longer implies `@MapConstructor` | Compile failure | Groovy reinstates implied `@MapConstructor`, or Grails keeps the explicit annotation permanently | **App source**. Apps using `@Canonical` and expecting a map constructor must add `@MapConstructor` themselves on Groovy 6 |
| 9 | `grails-views-gson/.../GrailsJsonViewHelper.groovy`, `.../internal/TemplateRenderer.groovy` | Closure/generic inference under `@CompileStatic` | Compile failure | Unknown | **Framework only** unless the app copies these helpers. Gson views keep compiling because of this |
| 10 | `grails-testing-support-http-client/.../HttpClientSupport.groovy` | A `static final` constant in a Spec collides with a trait instance getter | `cannot have both a static and an instance method` | Groovy restores prior static/instance resolution | **App test build**. Specs that mix a static `FORM` (or similar) with this trait will fail to compile. Rename the constant or drop the static |
| 11 | `gradle/*-test-config.gradle`, `gradle.properties`, `CompilePlugin`, forge template | Spock 2.4 is built against Groovy 5 and refuses to run on Groovy 6 | `IncompatibleGroovyVersionException` / `SpockTransform` | Spock ships `2.4-groovy-6.0` (not on Maven Central as of 2026-08-18; latest is `2.4-groovy-5.0`. Spock master compiles against Groovy `6.0.0-alpha-2` only) | **App test build**. Any app or plugin that compiles/tests with Spock on Groovy 6 must pass `-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true` until Spock publishes a groovy-6 artifact. This is the largest end-user item |
| 12 | `grails-common/build.gradle`, `dependencies.gradle` | `groovy-callsite` is a separate module in Groovy 6 | Missing class at runtime when indy is off | Never | **Permanent**. Apps/plugins that compile with `indy = false` need `groovy-callsite` on the compile/runtime classpath |
| 13 | `build-logic/.../SbomPlugin.groovy` | Groovy 6 pulls JLine 4, whose POM omits the licence SBOM validation expects | SBOM licence validation failure | JLine publishes complete licence metadata | **Framework only** (Grails repo / SBOM). Not an application concern |
| 14 | `settings.gradle` | Micronaut island is pinned to Groovy 5 | Version conflict if included | The Micronaut island supports Groovy 6 ([#16161](https://github.com/apache/grails-core/issues/16161)) | **App runtime / BOM**. Groovy 6 snapshots omit `grails-micronaut-*`. Apps that depend on Micronaut integration cannot use those artifacts from this line yet |
| 15 | `.github/workflows/groovy-joint-workflow.yml` | Groovy 6 development is still on `master`; `GROOVY_6_0_X` does not exist | Joint validation checks out a missing branch | Apache Groovy cuts `GROOVY_6_0_X` | **Framework only** (Grails CI) |

## 3. Upgrade checklist for application authors (7.x / 8.x -> 9 on Groovy 6)

These are the items an application actually has to do or know. Everything else in the table is absorbed inside Grails.

1. **Spock.** Until `org.spockframework:spock-core:2.4-groovy-6.0` exists, add the version-check opt-out on Groovy compile and test JVMs (`-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true`). Without it, `grails-test-core` and any Spec will fail to compile. This is unsupported by Spock and may produce odd runtime errors.
2. **`groovy-callsite`.** If the app keeps `grails { indy = false }` (the current plugin default), add `org.apache.groovy:groovy-callsite` or inherit it from the Grails BOM.
3. **`@CompileStatic` `||` guards inside closures.** Hoist to a `boolean` local. Same as item 1 in the table.
4. **`Validateable` / static trait methods.** If verification fails with `abstract and static`, that is Groovy 6 trait emission, not a Grails API break.
5. **`@Canonical` map constructors.** Add `@MapConstructor` explicitly.
6. **`!(x instanceof T)`** under static compile: parenthesize `!(x instanceof T)`.
7. **Micronaut.** `grails-micronaut-bom` and the island are not in Groovy 6 snapshots ([#16161](https://github.com/apache/grails-core/issues/16161)).
8. **Do not drop XmlUtils XXE hardening** if you copied that helper.

GORM `merge()`, transients introspection, and config filtering are handled inside Grails. Upgraded apps that stay on public APIs should not need source edits for those.

## 4. Workarounds retired at 6.0.0-beta-2

| Workaround | Why it is gone |
|---|---|
| Gradle `9.6.1` bump | Never a Groovy 6 requirement |
| `CoreGrailsPlugin` `BeanConfiguration.addProperty` | Obsolete after the `beanRegistrar` rewrite |
| `GrailsApplicationLifeCycle` rewritten as a Java interface | Groovy 6 handles the default method again. Verified under `-PgrailsIndy=false` |
| ~30 further snapshot-era compile workarounds | No longer reproduce on `6.0.0-beta-2` |

## 5. Not Groovy 6

- `ConfigurationBuilder` nested-map handling is **Spring 7** and lives on `8.0.x` via [#16160](https://github.com/apache/grails-core/pull/16160) / [#16159](https://github.com/apache/grails-core/issues/16159). It is not a Groovy 6 workaround.
- Embedded MongoDB / Testcontainers failures seen locally are environmental.

## Verification

[#15558](https://github.com/apache/grails-core/pull/15558) merged to `9.0.x` on 2026-08-18. Snapshot publish is the `9.0.x` push CI `publish` job.

Contributor guide

Open the contributing guide

Research direction

Start with the remaining-items table and the named files, especially GrailsWebDataBinder.groovy, GormEntity.groovy, the Gradle configuration files, and the Spock compatibility settings. Run the relevant module tests and Groovy 6 build checks described in the issue. Done means each retained workaround has the required comment, failure evidence, deletion condition, and impact class, with the build and tests green.

Written by the indexing model from the issue text.

Assessment

Tech stack
groovy, java
Domain
backend, build-system, testing
Issue type
Documentation
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.