////
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.  See the NOTICE file
distributed with this work for additional information
regarding copyright ownership.  The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License.  You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied.  See the License for the
specific language governing permissions and limitations
under the License.
////

=== Upgrade Instructions for Grails and Related Dependencies

To ensure compatibility with Grails {version}, please review the following upgrade instructions carefully.
This guide outlines the necessary steps and warnings to upgrade your Grails project from Grails 7.x to Grails 8.

[TIP]
====
**Before you start: add the Spring Boot properties migrator.**
It is the fastest way to find deprecated or relocated configuration properties in your `application.yml` / `application.groovy`.
Add it as a `runtimeOnly` dependency for the duration of your migration:

[source,groovy]
.build.gradle
----
runtimeOnly 'org.springframework.boot:spring-boot-properties-migrator'
----

Boot the application once; the migrator logs warnings for every deprecated property it finds and, in many cases, applies the rename automatically at runtime so the app keeps starting.
Remove the dependency once your configuration is clean.
====

==== 1. Java 21 Minimum Requirement

Grails 8 requires a minimum of Java 21 to both build and run Grails applications.
This is an increase from Grails 7, which required Java 17.
Update your project's toolchain, CI pipelines, and deployment environments to use JDK 21 or later.

==== 2. Spring Boot 4.1.x

Grails 8 upgrades from Spring Boot 3.5.x to Spring Boot 4.1.x.
This crosses the Spring Boot 4 major release boundary and adopts the Spring Boot 4.1 managed stack used by Grails 8: Spring Framework 7.0.x, Spring Security 7.1.x, Spring Data 2026.0.x, Micrometer 1.17.x, Jackson 3.1.x, Tomcat 11.0.x, and Jakarta Servlet 6.1.
The upgrade includes the Spring Boot 4 module split, removed Spring Framework 6 deprecations, and additional Spring Boot 4.1 removals of APIs and properties that were deprecated in Spring Boot 4.0.

For full details, consult:

1. https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide[Spring Boot 4.0 Migration Guide]
2. https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Release-Notes[Spring Boot 4.0 Release Notes]
3. https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.1-Release-Notes[Spring Boot 4.1 Release Notes]
4. https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-7.0-Release-Notes[Spring Framework 7.0 Release Notes]

==== 3. Spring Boot Autoconfigure Modularization

Spring Boot 4 split the monolithic `spring-boot-autoconfigure` module into domain-specific modules.
Auto-configuration classes have moved to new packages.
If your application imports or references auto-configuration classes directly, you will need to update those references.

Notable module changes:

* Web MVC auto-configuration classes moved to `org.springframework.boot:spring-boot-webmvc`
* Servlet auto-configuration classes moved to `org.springframework.boot:spring-boot-servlet`
* MongoDB auto-configuration classes moved to `org.springframework.boot:spring-boot-mongodb`

For example, if your code imports `MongoAutoConfiguration`:

[source,groovy]
----
// Before (Grails 7 / Spring Boot 3.x)
import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration

// After (Grails 8 / Spring Boot 4.x)
import org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration
----

In addition to the auto-configuration class moves, two foundational interfaces were relocated out of nested packages and into `org.springframework.boot` directly:

[cols="1,1", options="header"]
|===
| Before (Spring Boot 3.x)
| After (Spring Boot 4.x)

| `org.springframework.boot.env.EnvironmentPostProcessor`
| `org.springframework.boot.EnvironmentPostProcessor`

| `org.springframework.boot.BootstrapRegistry` and related types in `org.springframework.boot`
| `org.springframework.boot.bootstrap.BootstrapRegistry`, `org.springframework.boot.bootstrap.BootstrapRegistryInitializer`, `org.springframework.boot.bootstrap.ConfigurableBootstrapContext`
|===

The old locations are deprecated but retained for upgrade ease.
If your application or plugin contributes an `EnvironmentPostProcessor` or `BootstrapRegistryInitializer`, update both the import and the corresponding `META-INF/spring.factories` key:

[source,properties]
.META-INF/spring.factories - Before
----
org.springframework.boot.env.EnvironmentPostProcessor=com.example.MyPostProcessor
----

[source,properties]
.META-INF/spring.factories - After
----
org.springframework.boot.EnvironmentPostProcessor=com.example.MyPostProcessor
----

==== 4. MongoDB Configuration Property Changes

Spring Boot 4 renamed the MongoDB configuration property namespace for Spring Boot's own auto-configuration.
If your `application.yml` or `application.groovy` uses `spring.data.mongodb.*` properties for Spring Boot auto-configuration, you must rename them to `spring.mongodb.*`.
Note that Grails GORM for MongoDB uses its own `mongodb.*` configuration namespace, which is unaffected by this change.

[source,yaml]
.application.yml - Before (Grails 7)
----
spring:
    data:
        mongodb:
            host: localhost
            port: 27017
            database: mydb
----

[source,yaml]
.application.yml - After (Grails 8)
----
spring:
    mongodb:
        host: localhost
        port: 27017
        database: mydb
----

==== 5. Hibernate ORM Package Relocations

Spring Framework 7 removed the `org.springframework.orm.hibernate5` package entirely.
Grails 8 vendors these classes from Spring Framework 6.2.x into `grails-data-hibernate5-spring-orm` and `grails-data-hibernate7-spring-orm` so that both Hibernate 5 and Hibernate 7 users have a drop-in replacement.

This change is transparent for most applications since Grails manages the Hibernate integration internally.
However, if your application directly imports any of the following classes, you must update your import statements based on which Hibernate version you are using.

===== 5.1 Hibernate 5 (Default)

If your application uses the default Grails Hibernate 5 integration (`org.apache.grails:grails-hibernate5` / `grails-data-hibernate5-spring-orm`), replace the `org.springframework.orm.hibernate5` package prefix with `org.grails.orm.hibernate.support.hibernate5`:

[cols="1,1", options="header"]
|===
| Before (Spring Framework 6 / Grails 7)
| After (Grails 8 — Hibernate 5)

| `org.springframework.orm.hibernate5.HibernateTemplate`
| `org.grails.orm.hibernate.support.hibernate5.HibernateTemplate`

| `org.springframework.orm.hibernate5.HibernateTransactionManager`
| `org.grails.orm.hibernate.support.hibernate5.HibernateTransactionManager`

| `org.springframework.orm.hibernate5.LocalSessionFactoryBean`
| `org.grails.orm.hibernate.support.hibernate5.LocalSessionFactoryBean`

| `org.springframework.orm.hibernate5.SessionFactoryUtils`
| `org.grails.orm.hibernate.support.hibernate5.SessionFactoryUtils`

| `org.springframework.orm.hibernate5.SessionHolder`
| `org.grails.orm.hibernate.support.hibernate5.SessionHolder`

| `org.springframework.orm.hibernate5.HibernateCallback`
| `org.grails.orm.hibernate.support.hibernate5.HibernateCallback`

| `org.springframework.orm.hibernate5.support.OpenSessionInViewInterceptor`
| `org.grails.orm.hibernate.support.hibernate5.support.OpenSessionInViewInterceptor`
|===

All other classes from `org.springframework.orm.hibernate5` follow the same pattern — replace the `org.springframework.orm.hibernate5` prefix with `org.grails.orm.hibernate.support.hibernate5`.

===== 5.2 Hibernate 7

If your application has opted in to Hibernate 7 (`org.apache.grails:grails-hibernate7` / `grails-data-hibernate7-spring-orm`), the equivalent vendored classes live under `org.grails.orm.hibernate.support.hibernate7`:

[cols="1,1", options="header"]
|===
| Before (Spring Framework 6 / Grails 7)
| After (Grails 8 — Hibernate 7)

| `org.springframework.orm.hibernate5.HibernateTemplate`
| `org.grails.orm.hibernate.support.hibernate7.HibernateTemplate`

| `org.springframework.orm.hibernate5.HibernateTransactionManager`
| `org.grails.orm.hibernate.support.hibernate7.HibernateTransactionManager`

| `org.springframework.orm.hibernate5.LocalSessionFactoryBean`
| `org.grails.orm.hibernate.support.hibernate7.LocalSessionFactoryBean`

| `org.springframework.orm.hibernate5.SessionFactoryUtils`
| `org.grails.orm.hibernate.support.hibernate7.SessionFactoryUtils`

| `org.springframework.orm.hibernate5.SessionHolder`
| `org.grails.orm.hibernate.support.hibernate7.SessionHolder`

| `org.springframework.orm.hibernate5.HibernateCallback`
| `org.grails.orm.hibernate.support.hibernate7.HibernateCallback`

| `org.springframework.orm.hibernate5.support.OpenSessionInViewInterceptor`
| `org.grails.orm.hibernate.support.hibernate7.support.OpenSessionInViewInterceptor`
|===

All other classes follow the same pattern — replace the `org.springframework.orm.hibernate5` prefix with `org.grails.orm.hibernate.support.hibernate7`.

NOTE: See <<_hibernate_5_to_hibernate_7_migration>> below if you are also upgrading from Hibernate 5 to Hibernate 7 as part of this Grails 8 migration.

==== 6. HttpStatus.MOVED_TEMPORARILY Removed

Spring Framework 7 removed the deprecated `HttpStatus.MOVED_TEMPORARILY` constant.
Use `HttpStatus.FOUND` instead - both represent HTTP 302 with no behavioral change.

If your application references `HttpStatus.MOVED_TEMPORARILY` directly (for example, in custom redirect logic or interceptors), update the reference:

[source,groovy]
----
// Before
import org.springframework.http.HttpStatus
response.status = HttpStatus.MOVED_TEMPORARILY.value()

// After
import org.springframework.http.HttpStatus
response.status = HttpStatus.FOUND.value()
----

==== 7. Micronaut Integration Changes

===== 7.1 CLASSIC Boot Loader Removed

Spring Boot 4 removed the `CLASSIC` loader implementation (`LoaderImplementation.CLASSIC`).
In Grails 7, the Grails Gradle Plugin automatically configured `bootJar` and `bootWar` tasks to use the `CLASSIC` loader when the `grails-micronaut` plugin was detected.
This was required for `java -jar` execution to work correctly with the Micronaut-Spring integration.

In Grails 8, this automatic configuration has been removed since the `CLASSIC` loader no longer exists in Spring Boot 4.

**Action required:**

* If you have explicitly set `loaderImplementation` in your `build.gradle`, remove it:

[source,groovy]
.build.gradle - remove this block
----
// This is no longer valid in Spring Boot 4 - remove it
tasks.withType(BootArchive).configureEach {
    loaderImplementation = org.springframework.boot.loader.tools.LoaderImplementation.CLASSIC
}
----

* The `grails-micronaut` plugin continues to work with Spring Boot 4's default loader.
If you experience issues running your Micronaut-enabled Grails application via `java -jar`, please report them to the https://github.com/apache/grails-core/issues[Grails issue tracker].

===== 7.2 enforcedPlatform Required for Micronaut BOMs

When using Micronaut, select a Micronaut-compatible Grails BOM and apply it as `enforcedPlatform` instead of `platform`.
The default choice is `grails-micronaut-bom`; Hibernate-specific variants are covered in section 7.4.
The Micronaut 5 platform declares its own managed dependency versions that conflict with those managed by the `grails-bom`.
Without `enforcedPlatform`, Gradle's default conflict resolution (highest version wins) allows the Micronaut platform to silently override `grails-bom` versions, leading to binary incompatibilities at runtime.

For example, without `enforcedPlatform`:

* **Groovy** is upgraded from 4.0.x to 5.0.x -- Groovy 5 has breaking API changes (e.g. `ArrayGroovyMethods` moved) that cause `NoClassDefFoundError` at runtime
* **Spock** is upgraded from 2.3-groovy-4.0 to 2.4-groovy-5.0 -- compiled against Groovy 5, incompatible with Groovy 4 classes
* **Kotlin**, **Mockito**, **Byte Buddy** are upgraded to versions not tested with the Grails stack

Using `enforcedPlatform` makes the selected Micronaut BOM constraints strictly versioned so they cannot be overridden by any transitive dependency.

The Grails Gradle Plugin will error at configuration time if `grails-micronaut` is detected and no Micronaut-compatible Grails BOM is applied as `enforcedPlatform`.

[source,groovy]
.build.gradle
----
dependencies {
    // enforcedPlatform is required when using grails-micronaut
    implementation enforcedPlatform("org.apache.grails:grails-micronaut-bom:$grailsVersion")

    implementation 'org.apache.grails:grails-micronaut'
}
----

NOTE: Applications generated by the Grails Forge automatically use the appropriate Micronaut BOM as `enforcedPlatform` when the Micronaut feature is selected.

===== 7.3 Micronaut Features Require JDK 25

While the Grails 8 baseline remains JDK 21, any Grails application that uses the Micronaut integration **must** run on JDK 25 or later.

Micronaut Core's internal `io.micronaut.core.propagation.ScopedValues` class references `java.lang.ScopedValue.CallableOp`, which only exists in JDK 25.
`ScopedValue` was a preview API in JDK 21–24 and was finalized by https://openjdk.org/jeps/506[JEP 506] in JDK 25, at which point its inner type was renamed from `Callable` to `CallableOp`.
Running Micronaut on JDK 21–24 therefore fails at runtime with:

[source]
----
java.lang.NoClassDefFoundError: java/lang/ScopedValue$CallableOp
    at io.micronaut.core.propagation.ScopedValues.propagate(ScopedValues.java:45)
    ...
----

This affects any code path that goes through Micronaut's HTTP client, filters, or context propagation -- including the declarative `@Client` interfaces and `micronaut-http-client-jdk`.

**Action required:**

* If your Grails 8 application uses `grails-micronaut`, `micronaut-http-client`, or any other Micronaut feature, upgrade your build, CI, and deployment JDK to **JDK 25 or later**.
* Grails applications that do **not** use any Micronaut integration are unaffected and continue to run on JDK 21.

NOTE: The Grails Forge generator enforces this requirement: selecting any Micronaut feature with a JDK version below 25 will fail with `IllegalArgumentException` at generation time.

===== 7.4 Hibernate-Specific Micronaut BOMs

Grails 8 introduces new BOMs for projects that combine Micronaut integration with a specific Hibernate version:

* `org.apache.grails:grails-hibernate5-micronaut-bom` -- For Micronaut projects using Hibernate 5. This is the default Micronaut BOM for Hibernate 5 users (analogous to how `grails-micronaut-bom` is the default). Use this when your Micronaut-enabled Grails application targets Hibernate 5.
* `org.apache.grails:grails-hibernate7-micronaut-bom` -- For Micronaut projects using Hibernate 7. Use this when your Micronaut-enabled Grails application targets Hibernate 7.

This BOM extends `grails-base-bom` and the Micronaut platform directly, and includes the appropriate Hibernate dependency versions alongside the Micronaut-specific overrides (Groovy 5, Spock 2.4, etc.). This ensures no version conflicts when consumed via `enforcedPlatform`.

[source,groovy]
.build.gradle - Hibernate 5 with Micronaut (default)
----
dependencies {
    implementation enforcedPlatform("org.apache.grails:grails-hibernate5-micronaut-bom:$grailsVersion")
    implementation 'org.apache.grails:grails-micronaut'
}
----

[source,groovy]
.build.gradle - Hibernate 7 with Micronaut
----
dependencies {
    implementation enforcedPlatform("org.apache.grails:grails-hibernate7-micronaut-bom:$grailsVersion")
    implementation 'org.apache.grails:grails-micronaut'
}
----

The existing `grails-micronaut-bom` remains available and is equivalent to `grails-hibernate5-micronaut-bom` for backward compatibility. If the framework changes its default Hibernate version in the future, `grails-micronaut-bom` will track the new default while the Hibernate-specific BOMs remain pinned to their versions.

==== 8. Enum Serialization Default Changed

As announced in the Grails 7.0.2 deprecation notice, the `SimpleEnumMarshaller` is now the default for JSON and XML enum serialization in Grails 8.
Enums are serialized as simple string values (e.g., `"SUBMIT"`) instead of the verbose legacy format with type metadata.

If you previously opted in via configuration, you can now remove it:

[source,yaml]
.application.yml - this is now the default and can be removed
----
grails:
    converters:
        json:
            enum:
                format: simple
        xml:
            enum:
                format: simple
----

The legacy `org.grails.web.converters.marshaller.json.EnumMarshaller` and `org.grails.web.converters.marshaller.xml.EnumMarshaller` classes were removed in Grails 8.
There is no Grails 8 configuration switch that restores the verbose enum-object shape.
If an external API contract still requires that shape, register a custom object marshaller explicitly.

[[jackson3-default]]
==== 9. Jackson 3 is the New Default

Spring Boot 4 ships Jackson 3 (`tools.jackson.*`) and auto-configures a `JsonMapper` bean instead of the Jackson 2 `ObjectMapper`.
Grails 8 follows that change: any Grails module that previously declared `com.fasterxml.jackson.core:jackson-databind` (Jackson 2) now declares `tools.jackson.core:jackson-databind` (Jackson 3.1.x via the Spring Boot 4.1 BOM).

**What stayed the same:**

* **Jackson annotations.** All `@JsonProperty`, `@JsonCreator`, `@JsonIgnore`, `@JsonInclude`, `@JsonIgnoreProperties`, etc. remain at `com.fasterxml.jackson.annotation.*`.
The Spring Boot 4 BOM explicitly permits the `jackson-annotations` 2.x artifact alongside the Jackson 3 BOM, so existing annotated DTOs and domain classes do **not** need to change.
* The `readTree(String)`, `writeValueAsString(...)`, and `readValue(...)` method names on the mapper are unchanged.

**What changed (and what you need to do if your code touches Jackson directly):**

[cols="1,2,2",options="header"]
|===
| Concern | Jackson 2 | Jackson 3

| Package
| `com.fasterxml.jackson.databind.*`
| `tools.jackson.databind.*`

| Default mapper type
| `ObjectMapper`
| `tools.jackson.databind.json.JsonMapper`

| Construction
| `new ObjectMapper()`
| `JsonMapper.builder().build()` (immutable; configuration locked after `build()`)

| Exceptions
| `JsonProcessingException` / `IOException` (checked)
| `JacksonException` extends `RuntimeException` (unchecked)

| Default date format
| Numeric timestamps
| ISO-8601 strings
|===

**If you inject the auto-configured mapper:**
Spring Boot 4 provides a `tools.jackson.databind.json.JsonMapper` bean instead of `com.fasterxml.jackson.databind.ObjectMapper`.
Update injection points to expect `JsonMapper`.

**If you must stay on Jackson 2 temporarily:**
Spring Boot 4 still manages the Jackson 2 BOM and provides an opt-in toggle so the auto-configured mapper preserves Jackson 2 defaults:

[source,yaml]
.application.yml
----
spring:
    jackson:
        use-jackson2-defaults: true
----

This is intended as a migration aid only; new code should target Jackson 3.

**Spring Boot Jackson helper class renames.**
If your code uses Spring Boot's Jackson helpers, rename them to the Jackson 3 equivalents:

[cols="1,1", options="header"]
|===
| Spring Boot 3.x (Jackson 2)
| Spring Boot 4.x (Jackson 3)

| `Jackson2ObjectMapperBuilderCustomizer`
| `JsonMapperBuilderCustomizer`

| `JsonObjectSerializer`
| `ObjectValueSerializer`

| `JsonValueDeserializer`
| `ObjectValueDeserializer`

| `@JsonComponent`
| `@JacksonComponent`

| `@JsonMixin`
| `@JacksonMixin`
|===

**Configuration property renames.**
The `spring.jackson.*` namespace was reorganized.
The `spring-boot-properties-migrator` (see the tip at the top of this guide) will detect most of these and warn at boot time:

[cols="1,1", options="header"]
|===
| Before
| After

| `spring.jackson.read.*`
| `spring.jackson.json.read.*`

| `spring.jackson.write.*`
| `spring.jackson.json.write.*`

| `spring.jackson.parser.*`
| `spring.jackson.json.read.*` (where equivalents exist)
|===

**Module auto-detection.**
Jackson 3 auto-detects all Jackson modules on the classpath by default.
If you need to disable this — for example, to avoid an unwanted module being picked up — set:

[source,yaml]
.application.yml
----
spring:
    jackson:
        find-and-modules: false
----

==== 10. Theme Support Removed

Spring Framework 7 removed the theme infrastructure entirely.
The following theme-specific classes and interfaces no longer exist:

* `org.springframework.ui.context.ThemeSource`
* `org.springframework.ui.context.Theme`
* `org.springframework.ui.context.support.SimpleTheme`
* `org.springframework.web.servlet.theme.SessionThemeResolver`

NOTE: `JstlUtils` itself still exists in Spring WebMVC 7.0, but its theme-related methods have been removed.

If your application uses Spring theme resolution (for example, switching CSS stylesheets via `ThemeResolver`), you must replace this with your own implementation.
This was deprecated in Spring Framework 6.0 and fully removed in 7.0.

GSP layouts and views that relied on theme resolution will need to use alternative approaches such as configuration properties or session-based preferences managed in application code.

==== 11. Embedded Server Module Relocations

Spring Boot 4 reorganized embedded server classes into separate modules.
If your application or tests reference these classes directly, update the imports:

[cols="1,1", options="header"]
|===
| Before (Spring Boot 3.x / Grails 7)
| After (Spring Boot 4.x / Grails 8)

| `org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory`
| `org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory`

| `org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory`
| `org.springframework.boot.web.server.servlet.ConfigurableServletWebServerFactory`

| `org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext`
| `org.springframework.boot.web.server.servlet.context.AnnotationConfigServletWebServerApplicationContext`

| `org.springframework.boot.web.context.WebServerApplicationContext`
| `org.springframework.boot.web.server.context.WebServerApplicationContext`

| `org.springframework.boot.web.servlet.context.AnnotationConfigServletWebApplicationContext`
| `org.springframework.boot.web.context.servlet.AnnotationConfigServletWebApplicationContext`
|===

Applications using embedded Tomcat or Jetty should check for any direct class references and update accordingly.
The `spring-boot-tomcat` module must now be an explicit dependency if you reference Tomcat classes directly.

**Undertow support is now provided by the Grails Undertow plugin.**
Spring Boot 4 removed its Undertow support (`spring-boot-starter-undertow`) before Undertow shipped Servlet 6.1 compatibility.
Undertow has since released Servlet 6.1 support, and Grails 8 restores Undertow as an embedded servlet container through the `grails-undertow` plugin, which bundles the Spring Boot Undertow web server support.
Applications that previously used `spring-boot-starter-undertow` should replace it with:

[source,groovy]
----
implementation 'org.apache.grails:grails-undertow'
----

The existing `server.undertow.*` configuration properties continue to work.
Note that `server.undertow.max-http-post-size` now defaults to 2MB, matching Undertow's hardened default (CVE-2024-3884, CVE-2024-4027); set it to `-1` to restore the previous unlimited behavior.
The Grails Forge generator offers Undertow as a selectable servlet implementation again (for example, `--servlet=undertow` on the API/CLI, or the `grails-undertow` feature).

==== 12. HandlerAdapter.getLastModified Removed

Spring Framework 7 removed the deprecated `getLastModified` method from the `HandlerAdapter` interface.
If your application implements `HandlerAdapter` directly and overrides this method, remove the override.
The framework no longer calls this method for Last-Modified header management.

==== 13. Spring Security Filter Ordering

Spring Boot 4 removed the `SecurityProperties.DEFAULT_FILTER_ORDER` constant.
Spring Security is moving toward the fluent `HttpSecurity` configuration API for filter chain management, rather than explicit filter ordering via constants.

If your application references `DEFAULT_FILTER_ORDER` for custom filter positioning, replace it with the concrete value `-100` (computed as `OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100`).
Longer term, consider migrating to the fluent `HttpSecurity` API for filter chain configuration.

==== 14. Spring Dependency Management Plugin Replaced by Gradle Platforms

Grails 8 standardizes on Gradle's native `platform()` dependency management and **no longer applies the `io.spring.dependency-management` plugin**.
The Grails Gradle Plugin now adds `grails-bom` as a Gradle `platform()` on every declarable project configuration, and a new bundled plugin (`org.apache.grails.gradle.bom-property-overrides`, applied automatically by the Grails Gradle Plugin) preserves the property-based version-override workflow that Spring DM provided.

**No action is required for most applications.**
The standard `gradle.properties` and `ext['…']` override forms continue to work exactly as before:

[source,groovy]
.build.gradle
----
ext['slf4j.version'] = '2.0.9'
----

[source,properties]
.gradle.properties
----
slf4j.version=2.0.9
----

The `grails { springDependencyManagement = … }` flag on `GrailsExtension` is deprecated.
For backward compatibility, setting it to `false` is still honored and is equivalent to `grails { bom = null }`: it suppresses the automatic Grails BOM `platform()` application, just as it previously prevented the Spring Dependency Management BOM from being applied.
New builds should use `grails { bom = null }` instead.

**Two Spring DM features have no automatic migration.**
If your `build.gradle` uses either of the patterns below, update it as follows:

[cols="2,3", options="header"]
|===
| Spring DM pattern (Grails 7)
| Grails 8 replacement

| `dependencyManagement { dependencies { dependency 'group:artifact:version' } }` to pin arbitrary non-BOM dependency versions
| `dependencies { implementation 'group:artifact:version' }` for a direct dependency, or `configurations.all { resolutionStrategy.force 'group:artifact:version' }` to force a transitive version

| Version-range strings (e.g. `1.+`, `[1.0,2.0)`) embedded in BOM `<properties>` values, resolved by Spring DM's own resolver
| Now applied as Gradle strict version constraints, which use Gradle's range semantics. Concrete versions are unaffected. Only relevant if you consume a custom BOM whose `<properties>` block contains range strings.
|===

**Behavioural difference to be aware of: conflict resolution.**
The BOM's *default* managed versions are applied via a regular Gradle `platform()`, which participates in standard conflict resolution (highest-version-wins).
A transitive dependency can therefore pull a non-overridden managed version higher than the BOM default.
If you require Spring DM's "BOM wins always" behaviour for the default managed versions, declare `enforcedPlatform("org.apache.grails:grails-bom:$grailsVersion")` instead of relying on the auto-applied non-enforced platform.

An *explicit* property override (`ext['…']` or `gradle.properties`) is applied as a strict constraint and always wins - including when it downgrades a version - so overrides behave as they did under Spring DM.
Overriding a property that selects an imported BOM's version (for example `spring-boot.version`) re-imports that BOM at the overridden version and applies its updated managed set.

NOTE: Projects that apply the `grails-micronaut` plugin already require `enforcedPlatform` for unrelated reasons (see section 7.2); those projects retain Spring DM's strict-version semantics automatically.

**Selecting or opting out of the auto-applied BOM.**
The Grails Gradle plugin applies a single Grails BOM automatically, selected by the `grails.bom` property. Its value is the BOM artifact name within the `org.apache.grails` group, which the plugin resolves as `org.apache.grails:$bom:$grailsVersion`.
It defaults to `grails-bom`, which preserves the Grails 7 behaviour and is the right choice for the vast majority of applications.
Exactly one Grails BOM is ever applied; the BOMs are split by integration (default / hibernate5 / micronaut), so the plugin never layers two of them.

Select a different curated variant when your application needs it - for example the Micronaut variant, which the plugin applies as an `enforcedPlatform`:

[source,groovy]
.build.gradle
----
grails {
    bom = 'grails-micronaut-bom'
}
----

Set it to `null` to suppress the automatic BOM application entirely - for example when you intentionally want to manage Grails dependency versions yourself (consuming Grails modules from a different curated platform):

[source,groovy]
.build.gradle
----
grails {
    bom = null
}
----

When set to `null`, the Grails Gradle plugin will not declare a Grails `platform()`/`enforcedPlatform()` on your configurations and will not apply the `org.apache.grails.gradle.bom-property-overrides` plugin.
You are responsible for declaring the BOM yourself and, if you still want `gradle.properties` / `ext['...']` overrides, applying `id 'org.apache.grails.gradle.bom-property-overrides'` explicitly.

==== 15. Spring Boot Starter Renames

Spring Boot 4 renamed several starters as part of its modularization effort.
If your `build.gradle` references any of the following, update the artifact id:

[cols="1,1", options="header"]
|===
| Before (Spring Boot 3.x)
| After (Spring Boot 4.x)

| `spring-boot-starter-web`
| `spring-boot-starter-webmvc`

| `spring-boot-starter-aop`
| `spring-boot-starter-aspectj`

| `spring-boot-starter-web-services`
| `spring-boot-starter-webservices`

| `spring-boot-starter-oauth2-authorization-server`
| `spring-boot-starter-security-oauth2-authorization-server`

| `spring-boot-starter-oauth2-client`
| `spring-boot-starter-security-oauth2-client`

| `spring-boot-starter-oauth2-resource-server`
| `spring-boot-starter-security-oauth2-resource-server`
|===

[source,groovy]
.build.gradle - Before
----
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-aop'
----

[source,groovy]
.build.gradle - After
----
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-aspectj'
----

NOTE: If you want a faster, lower-friction migration that pulls in *all* Spring Boot modules without picking starters individually, Spring Boot 4 ships `spring-boot-starter-classic` (and `spring-boot-starter-test-classic`) as a transitional fallback.
This is intended as a stopgap; new code should target the modular starters.

==== 16. WAR Deployment Uses spring-boot-starter-tomcat-runtime

For WAR deployment to an external Servlet container, Spring Boot 4 introduced a dedicated `spring-boot-starter-tomcat-runtime` artifact that contributes only the integration classes needed to start under an external container, without bundling the embedded Tomcat distribution into the WAR.

Replace the previous "change scope to `provided`/`testImplementation`" idiom with the new starter:

[source,groovy]
.build.gradle - Before
----
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
----

[source,groovy]
.build.gradle - After
----
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat-runtime'
----

The Grails deployment guides have been updated to reflect this idiom.
Embedded Tomcat usage (the default for `grails run-app` and bootable WARs) continues to use `spring-boot-starter-tomcat` unchanged.

==== 17. Test Infrastructure Changes

Spring Boot 4 made several breaking changes to its test infrastructure.
Most Grails tests use `GrailsUnitTest`, `GrailsWebUnitTest`, and `Integration` and are unaffected, but if your application uses `@SpringBootTest` directly the following apply.

**`@SpringBootTest` no longer auto-configures `MockMvc`, `WebClient`, or `TestRestTemplate`.**
Add the corresponding `@AutoConfigureMockMvc` / `@AutoConfigureWebClient` / `@AutoConfigureTestRestTemplate` annotations to opt in.

[source,groovy]
----
// Before
@SpringBootTest
class MyControllerSpec extends Specification {
    @Autowired MockMvc mockMvc      // injected automatically
    @Autowired TestRestTemplate rest // injected automatically
}

// After
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestRestTemplate
class MyControllerSpec extends Specification {
    @Autowired MockMvc mockMvc
    @Autowired TestRestTemplate rest
}
----

**`@MockBean` and `@SpyBean` were removed.**
Replace them with `@MockitoBean` and `@MockitoSpyBean` from `org.springframework.test.context.bean.override.mockito`.
The new annotations work as fields in test classes only — they cannot be declared in `@Configuration` classes.

[source,groovy]
----
// Before
import org.springframework.boot.test.mock.mockito.MockBean
@SpringBootTest
class MyServiceSpec extends Specification {
    @MockBean BookRepository repo
}

// After
import org.springframework.test.context.bean.override.mockito.MockitoBean
@SpringBootTest
class MyServiceSpec extends Specification {
    @MockitoBean BookRepository repo
}
----

**`MockitoTestExecutionListener` was removed.**
If your test infrastructure registers it explicitly, remove the registration; Spring's bean override support handles `@MockitoBean` directly.

**`TestRestTemplate` package change.**
`TestRestTemplate` moved to `org.springframework.boot.resttestclient`.
Update the import.
A new `RestTestClient` and corresponding `@AutoConfigureRestTestClient` annotation are also available as a fluent alternative.

==== 18. DevTools Live Reload Disabled by Default

Spring Boot 4 changed the default of `spring.devtools.livereload.enabled` from `true` to `false`.
If you rely on browser live reload during development, opt in explicitly under the development environment so non-development environments still honor the new Spring Boot 4 default:

[source,yaml]
.application-development.yml
----
spring:
    devtools:
        livereload:
            enabled: true
----

NOTE: Apps generated by the Grails Forge with the `spring-boot-devtools` reloading option **do** set this property automatically in `application-development.yml`, restoring Grails 7 livereload behavior for the dev profile only.
You can opt out by changing the value to `false` or by deleting the `application-development.yml` block after generation.
Existing Grails 7 applications that opt in to devtools must add this configuration manually.

==== 19. Spring Retry No Longer Managed

Spring Boot 4 removed `spring-retry` from its managed dependencies.
Spring for Apache Kafka and Spring AMQP have moved off Spring Retry to Spring Framework's new retry support; if you used Spring Retry transitively through those projects you may not need it any more.

If your application *directly* uses `@Retryable`, `@EnableRetry`, or `@Recover`, declare the dependency explicitly in `build.gradle`:

[source,groovy]
.build.gradle
----
implementation 'org.springframework.retry:spring-retry'
----

NOTE: The `grails-bom` pins a known-good Spring Retry version (`2.0.x`), so you do not need to specify a version when your build uses the Grails BOM, whether it is auto-applied by the Grails Gradle plugin or declared explicitly.
Override the BOM-managed version in your build if you need a newer release.

==== 20. Other Default Behavior Changes

Spring Boot 4.0 and 4.1 made several smaller default-behavior changes that you may notice but rarely require code changes:

* **Spring Boot 4.1 removes Spring Boot 4.0 deprecations.**
If you tested against an earlier Grails 8 snapshot or Spring Boot 4.0.x directly, remove any use of APIs, methods, or configuration properties that were only deprecated there before moving to the current Grails 8 line.

* **The Spring Boot `BuildInfo` task writes `META-INF/build-info.properties` by default.**
If your build, packaging, or tests read `build-info.properties` from the root of the generated resources directory, update them to read `META-INF/build-info.properties` or configure the task's `filename` property.

* **Spring Data JPA repository bootstrap modes were tightened.**
Grails GORM is unaffected, but applications that also use Spring Data JPA directly should review any `spring.data.jpa.repositories.bootstrap-mode` setting when moving to Spring Boot 4.1.

* **Derby support is deprecated in Spring Boot.**
Grails does not use Derby by default, but applications that added Derby through Spring Boot auto-configuration should plan a migration to H2, HSQLDB, or another supported database.

* **Logback default charset is now `UTF-8`** for log files (aligning with the existing Log4j2 default).
Console output uses `Console#charset()` if available, otherwise `UTF-8`.
If your log scraping tooling assumed a platform-default charset, switch it to UTF-8.

* **Liveness and readiness probes are enabled by default** on the Health endpoint.
The `liveness` and `readiness` health groups are exposed automatically.
To disable, set:
+
[source,yaml]
.application.yml
----
management:
    endpoint:
        health:
            probes:
                enabled: false
----

* **`HttpMessageConverters` is deprecated.**
If your application contributed `HttpMessageConverter` beans through this class, switch to the new `ClientHttpMessageConvertersCustomizer` and `ServerHttpMessageConvertersCustomizer` interfaces.

* **JSpecify nullability annotations.**
Spring Framework 7 deprecated `org.springframework.lang.Nullable` and `org.springframework.lang.NonNull` in favor of the JSpecify annotations (`org.jspecify.annotations.Nullable`, `org.jspecify.annotations.NonNull`).
The Spring annotations still work, so this is non-blocking, but new code should prefer the JSpecify equivalents.

* **Namespaced link generation is namespace-aware.**
When a link, form action, pagination link, sortable column link, redirect, chain, or include targets a controller without an explicit `namespace`, Grails now resolves the namespace automatically. In the normal case, where only one controller has the target name, `controller` and `action` generate the correct namespaced or non-namespaced URL. Ambiguity only occurs when multiple controllers share the same name. In that case, specify `namespace` to choose the target explicitly. Pass `namespace: null` from Groovy code or `namespace=""` in a GSP tag to target the non-namespaced controller explicitly.

* **`bindData` can clear omitted included fields.**
`bindData(target, source, [include: [...], clearMissing: true])` now clears included properties that are absent from the binding source. The behavior is opt-in, requires an `include` list, and does not apply globally. Existing `bindData` calls without `clearMissing: true` keep omitted fields unchanged.

==== 21. Tag Library Test Cleanup Changes

Grails 8 removes the `purgeTagLibMetaClass` test hook used by some web and TagLib unit tests.
TagLib metaclass cleanup now happens automatically as part of the web test infrastructure, so test specs should delete any custom `purgeTagLibMetaClass` property or getter.

If your tests use `TagLibUnitTest` and mock additional tag libraries with `mockTagLib(...)` or `mockTagLibs(...)`, those mocked tag libraries are rebuilt automatically between feature methods.
No replacement flag is required.

IMPORTANT: Mocked tag libraries are cleared after each feature method.
If your tests call `mockTagLib` directly (for example, when implementing `GrailsWebUnitTest` instead of `TagLibUnitTest`), move the call from `setupSpec()` to `setup()` so the mock is re-applied before every feature.
`TagLibUnitTest` handles this automatically, but `GrailsWebUnitTest` does not.

[source,groovy]
----
// Before
class MyTagLibSpec extends Specification implements TagLibUnitTest<MyTagLib> {
    boolean getPurgeTagLibMetaClass() {
        true
    }
}

// After
class MyTagLibSpec extends Specification implements TagLibUnitTest<MyTagLib> {
}
----

==== 22. Method-based TagLib Handlers

Grails 8 introduces method-based tag handler syntax as the recommended way to define tags. Closure-based handlers are still supported for backward compatibility.

[source,groovy]
----
import grails.gsp.Tag

class DemoTagLib {
    static namespace = 'demo'

    def greet(Map attrs) {
        out << "Hello, ${attrs.name}!"
    }

    @Tag
    def greeting(String name) {
        out << "Hello, ${name}!"
    }
}
----

Method tags with the conventional signatures `def tag(Map attrs)`, `def tag(Closure body)`, and `def tag(Map attrs, Closure body)` are discovered automatically. Zero-argument method tags and method tags that bind named attributes directly to method parameters must use `@grails.gsp.Tag`; this avoids exposing ordinary public helper methods as tags.

Grails Gradle application builds preserve Java and Groovy method parameter names by default (see §22.3 below).
If you compile TagLib classes outside those plugins, enable Java `-parameters` and Groovy `groovyOptions.parameters = true` so `@Tag` methods can bind attributes by parameter name.

To exclude a conventional public method from being exposed as a tag (for example, a helper that must remain public to satisfy an interface), annotate it with `@grails.gsp.NotATag`:

[source,groovy]
----
import grails.gsp.NotATag

class DemoTagLib {
    def greet(Map attrs) {
        out << "Hello, ${attrs.name}!"
    }

    @NotATag
    def helper(Map attrs) { /* ... */ }
}
----

The compile-time deprecation warning emitted for closure-based tag fields can be silenced by setting the system property `grails.taglib.warnDeprecatedClosures=false`.

===== 22.1 Behavior change: tag method registration

In Grails 7, the metaclass tag dispatchers registered for the taglib's own namespace did not override existing methods. Because method-based tags are now real methods on the TagLib class, those dispatchers must override methods of the same name so that function-style invocation (for example `tagLib.myTag(attrs)`) returns captured tag output rather than writing directly to `out`.

If a TagLib intentionally defines a method that shares a name with a default-namespace tag in another TagLib, the locally defined method will be shadowed by the tag dispatcher. Rename the helper method or move it behind a different namespace if the previous behavior is required.

This shadowing is not reported at runtime by default. To audit which TagLib methods are being overridden by tag dispatchers, enable `DEBUG` logging for `org.grails.taglib.TagLibraryMetaUtils` in `logback.xml` (or `logback-spring.xml`):

[source,xml]
----
<logger name="org.grails.taglib.TagLibraryMetaUtils" level="DEBUG"/>
----

Each override is logged as `Registering tag dispatcher <namespace>:<tagName> over existing method <Class>.<method>(<paramTypes>)`. Inspect the output after application startup; any entry whose `<Class>` is one of your own TagLibs identifies a method that has been replaced by the dispatcher.

===== 22.2 Behavior change: parameter-name binding for `Map` and `Closure` parameters

Method-based tag handlers bind attributes to method parameters by parameter name. Two reserved names participate in this resolution:

- A `Map`-typed parameter named `attrs` receives the full attributes map.
- A `Closure`-typed parameter named `body` receives the tag body.

In earlier iterations of the method-based handler implementation, a `Closure`-typed parameter was bound to the body regardless of its parameter name, while a `Map`-typed parameter required the literal name `attrs`. That asymmetry has been removed. Both reserved names are now applied consistently: a `Closure` parameter named anything other than `body` is treated as a normal named attribute (and must be supplied by name), just as a `Map` parameter named anything other than `attrs` is.

If you have method-based tag handlers whose `Closure` parameter uses a name other than `body`, rename the parameter so the body binds as expected:

[source,groovy]
----
import grails.gsp.Tag

class DemoTagLib {

    // Will NOT receive the tag body; `renderer` is treated as a named attribute.
    @Tag
    def before(Closure renderer) {
        out << renderer.call()
    }

    // Receives the tag body.
    @Tag
    def after(Closure body) {
        out << body.call()
    }
}
----

If parameter names are not preserved at compile time (see §22.3 below for the `preserveParameterNames` Gradle extension property), the first `Map` parameter is bound to `attrs` and the first `Closure` parameter is bound to `body` regardless of declared names, matching the behavior of closure-based tag handlers.

===== 22.3 Grails Gradle Extension Preserves Parameter Names By Default

In Grails 8, the Grails Gradle extension defaults `preserveParameterNames` to `true`.
This configures Groovy compilation to retain method and constructor parameter names in generated class files.

This behavior supports framework features that bind by parameter name, including method-based TagLib handlers annotated with `@grails.gsp.Tag`:

[source,groovy]
----
import grails.gsp.Tag

class DemoTagLib {
    @Tag
    def greeting(String name) {
        out << "Hello, ${name}!"
    }
}
----

If you need the previous Groovy compiler behavior, disable it in `build.gradle`:

[source,groovy]
----
grails {
    preserveParameterNames = false
}
----

NOTE: This Gradle extension property controls Groovy compilation only (`groovyOptions.parameters`).
Java parameter-name preservation is enabled by the Spring Boot Gradle plugin, which adds `-parameters` to every `JavaCompile` task by default since Spring Boot 3.2. Grails 8 applications inherit this through the Spring Boot Gradle plugin and require no additional configuration for Java sources.

If you compile TagLib classes outside the Grails Gradle plugin (for example, in a standalone Groovy module, a plain Spring Boot application that does not apply `org.springframework.boot`, or a build that disables the Spring Boot plugin's compiler configuration), enable parameter-name preservation explicitly so `@grails.gsp.Tag` methods can bind attributes by parameter name:

[source,groovy]
----
tasks.withType(JavaCompile).configureEach {
    options.compilerArgs.add('-parameters')
}

tasks.withType(GroovyCompile).configureEach {
    groovyOptions.parameters = true
}
----

Without these flags, `Parameter.getName()` returns synthetic names such as `arg0`, the attribute lookup misses, and the call surfaces as `MissingMethodException` at runtime.
The `@Tag`-annotated method itself produces no compile-time warning, because preservation is a property of the build, not of the source file.

==== 23. Known Plugin Incompatibilities

A Grails 7 plugin is not automatically incompatible with Grails 8. A plugin resolving, appearing in the load order, and letting the application start is encouraging, but it is not enough to establish compatibility. Exercise the plugin's documented public behavior, including web requests, outbound calls, serialization, commands, and any domain traits or annotations it exposes.

When a released Grails 7 plugin fails, first check whether the maintainer has published a Grails 8 version. Some failures have a temporary application-side workaround. Others are binary incompatibilities in already-published bytecode and require the plugin author to rebuild and release the plugin against Grails 8. Do not add framework-internal compatibility classes to an application to work around those failures.

Use the first distinctive failure to choose the next step:

* `NoClassDefFoundError` mentioning `com/fasterxml/jackson/databind/exc/InvalidDefinitionException` while creating `OrderedFormContentFilter`: add the BOM-managed Jackson 2 `jackson-databind` dependency described below, then retest the plugin's public behavior.
* `Could not find org.grails:...:.`: use a plugin version built against `org.apache.grails`, or use narrowly targeted dependency substitution as a temporary migration aid.
* `MissingPropertyException` or `MissingMethodException` from `Metadata`: the plugin must be rebuilt against the typed metadata API.
* `MalformedParameterizedTypeException` mentioning `$Trait$FieldHelper`, `NoClassDefFoundError` mentioning `ObjectUtil`, `IncompatibleClassChangeError` involving `HttpHeaders`, or an unexpected internal property after using a plugin annotation: the plugin must be rebuilt for Grails 8.

===== 23.1 Metadata dynamic and map-style access

A plugin that reads metadata through `Metadata.current.'some.key'` or `Metadata.current.get('some.key')` can start normally and fail only when that code path runs. The usual symptoms are `MissingPropertyException` or `MissingMethodException`, often as an HTTP 500 on a plugin endpoint. See https://github.com/apache/grails-core/issues/16122[Issue #16122].

Plugin authors must replace the removed dynamic accessors with the typed public API:

[source,groovy]
----
// Grails 7
def version = Metadata.current.'info.app.grailsVersion'
def name = Metadata.current.get('info.app.name')

// Grails 8
String version = Metadata.current.getProperty('info.app.grailsVersion', String, null)
String name = Metadata.current.getProperty('info.app.name', String, null)
----

For a nested metadata value that does not need type conversion, `Metadata.current.navigate('some', 'nested', 'key')` is also available. This is a rebuild-only change for a published binary plugin. No Metadata compatibility shim is available.

===== 23.2 Groovy 4 trait and AST-transform bytecode

Groovy 5 can compile application code against a Groovy 4 plugin, but the following generated-bytecode patterns are known to be incompatible. These failures commonly occur only when the plugin's public feature is used, rather than at dependency resolution or startup. Rebuild the plugin with Grails 8 and Groovy 5. Consumers cannot reliably repair an already-published jar.

* **Generic traits with fields.** A Grails 7 plugin that exposes a generic trait with fields can fail when an application implements that trait. JavaBeans, Spring, or Jackson introspection then throws `MalformedParameterizedTypeException` mentioning `<Trait>$Trait$FieldHelper`. The plugin's trait must be recompiled with Groovy 5. See https://github.com/apache/grails-core/issues/16123[Issue #16123]. Non-generic traits are not affected by this specific issue.

* **AST transformations that target private trait methods.** A plugin transformation can compile successfully but silently lose a private trait method from the emitted application class. Using the annotated public feature then throws `MissingPropertyException` for an unexpected internal property. Plugin authors should change the transform to target a member emitted on the implementing class, then rebuild with Groovy 5. See https://github.com/apache/grails-core/issues/16126[Issue #16126].

* **`@Immutable` classes.** A Groovy 4-compiled `@Immutable` class can throw `NoClassDefFoundError: org/apache/groovy/runtime/ObjectUtil` when constructed on Groovy 5. The missing call is generated by Groovy 4's AST transformation, so updating application source does not fix a released plugin jar. Rebuild affected plugin artifacts with Groovy 5. See https://github.com/apache/grails-core/issues/16128[Issue #16128].

See <<groovy5-behavior-changes,Apache Groovy 5 Behavior Changes>> for source-level Groovy 5 changes that may affect the rebuilt plugin.

===== 23.3 Jackson 2 dataformats without Jackson 2 databind

Grails 8 uses Jackson 3 for databind, while a released plugin may still bring Jackson 2 Smile, CBOR, or YAML dataformat artifacts. If one of those dataformats is present while Jackson 2 databind is absent, Spring Framework detects an incomplete Jackson 2 classpath and application startup fails with `NoClassDefFoundError: com/fasterxml/jackson/databind/exc/InvalidDefinitionException`, usually while creating `OrderedFormContentFilter`. Jackson 2 core, annotations, and XML dataformat alone do not trigger this specific failure.

The immediate consumer workaround is to supply the matching Jackson 2 databind artifact while the plugin is being migrated:

[source,groovy]
.build.gradle
----
dependencies {
    implementation 'com.example:plugin-that-needs-jackson2-dataformats:1.0.0'

    // Keep the Jackson 2 runtime classpath complete.
    runtimeOnly 'com.fasterxml.jackson.core:jackson-databind'
}
----

Use the Jackson 2 version managed by the Grails and Spring Boot platform. Do not add a version if the active BOM already manages it. Plugin authors should release a Grails 8 version that migrates to Jackson 3 where possible, or declares a complete, compatible Jackson 2 runtime when it must retain Jackson 2 dataformats. See https://github.com/apache/grails-core/issues/16124[Issue #16124] and <<jackson3-default,Jackson 3 is the New Default>>.

===== 23.4 Pre-Apache `org.grails` coordinates

Some older plugins import a pre-Apache `org.grails` BOM and declare versionless `org.grails:*` dependencies. Grails 8 does not publish those coordinates. Gradle often reports an unhelpful empty-version error such as `Could not find org.grails:grails-core:.`.

No complete, general coordinate substitution map exists. A narrowly targeted application-level substitution rule can resolve some plugins, but module names and binary compatibility are not always one-to-one after the Apache group rename. Treat substitution as a temporary diagnostic or migration aid, not proof that a plugin is compatible. The supported migration is for plugin authors to rebuild against `org.apache.grails` coordinates and publish a Grails 8 release. See https://github.com/apache/grails-core/issues/16125[Issue #16125].

[source,groovy]
.build.gradle - Plugin author migration
----
// Before: imported by an early Grails 7 plugin POM
implementation platform('org.grails:grails-bom:7.0.0-M1')

// After: compile against the Apache Grails 8 BOM
implementation platform("org.apache.grails:grails-bom:$grailsVersion")
----

===== 23.5 Spring Framework 7 `HttpHeaders` binary incompatibility

Spring Framework 7's `HttpHeaders` no longer implements `MultiValueMap`. A Grails 7 plugin compiled against Spring 6 can therefore boot and then throw `IncompatibleClassChangeError` when it passes `HttpHeaders` to an API expecting `MultiValueMap`, often during an outbound HTTP request. See https://github.com/apache/grails-core/issues/16127[Issue #16127].

Plugin authors must compile against Spring Framework 7 and use the `HttpHeaders`-typed API. Remove any explicit `MultiValueMap` cast so recompilation selects Spring 7's `HttpEntity(T, HttpHeaders)` constructor instead of the deprecated `HttpEntity(T, MultiValueMap)` constructor:

[source,groovy]
----
// Grails 7 / Spring 6 code that selected the MultiValueMap API
HttpHeaders headers = new HttpHeaders()
new HttpEntity(body, (MultiValueMap<String, String>) headers)

// Grails 8 / Spring 7: use the HttpHeaders API
HttpHeaders headers = new HttpHeaders()
new HttpEntity(body, headers)
----

Changing only an application's dependencies cannot repair the plugin's existing bytecode. Upgrade to a plugin release compiled for Grails 8, or ask the maintainer to publish one.

===== 23.6 Commands and plugin bean timing

For a released Grails 7 plugin that implements `grails.dev.commands.ApplicationCommand`, keep the default `cliAutoProvision = true` and enable `grails { legacyCommandSupport = true }`. Run its commands with `./gradlew runCommand "-Pargs=<command> <arguments>"` or through the shell. Legacy commands do not create named Gradle tasks. Script, YAML, and profile commands do not need this bridge.

The complete command migration, including the legacy bridge, script packaging, and companion artifact support delivered by https://github.com/apache/grails-core/pull/16011[PR #16011], https://github.com/apache/grails-core/pull/16082[PR #16082], and https://github.com/apache/grails-core/pull/15948[PR #15948], is covered in <<cli-companion-artifacts,CLI Commands Move to Companion `-cli` Artifacts>>. Plugin authors should still migrate commands to the current API and publish companion `-cli` artifacts.

The plugin bean lifecycle change is not a compatibility bridge. Existing `doWithSpring` plugins still run, but https://github.com/apache/grails-core/pull/15934[PR #15934] moved them before Spring Boot auto-configuration. During that callback, the initializing application is available through the plugin's `grailsApplication` property, but it has not yet been published to the process-global `Holders.grailsApplication` fallback. Plugins that read the global Holder from `doWithSpring` must instead use their injected application, configuration, or environment. See <<plugin-beans-before-autoconfiguration,Plugin Beans Now Register Before Spring Boot Auto-Configuration>> for the timing change and migration to `beanRegistrar()`.

Plugin compatibility continues to evolve. Check the plugin's release notes and the https://github.com/apache/grails-core/issues?q=state%3Aopen%20label%3A%22relates-to%3A%20external-plugin%22[Grails external-plugin issues] for current status.

==== 24. Custom JSON View Converters

JSON views now use Groovy's `groovy.json.JsonGenerator` implementation instead of the previous Grails-specific
JSON generator infrastructure.
If you previously customized JSON view rendering with Grails-specific generator classes, migrate that logic to a
`groovy.json.JsonGenerator.Converter` implementation.

This change applies to JSON views rendered by the `grails-views-gson` module.
The `grails.converters.JSON.registerObjectMarshaller(...)` API is still used by the `grails-converters` module
for `render ... as JSON` and related converter-based rendering.

To migrate your custom converters:

- Implement `groovy.json.JsonGenerator.Converter` (was previously `grails.plugin.json.builder.JsonGenerator.Converter`)
- Change the file name of the converter registration service loader to
`src/main/resources/META-INF/services/groovy.json.JsonGenerator$Converter`
(was previously `src/main/resources/META-INF/services/grails.plugin.json.builder.JsonGenerator$Converter`)

==== 25. Rendering Enum values as JSON

It is no longer possible to render a single enum value as JSON using the `render(Number.ONE as JSON)` syntax.
Previously, rendering an enum value would produce a JSON string with the type and name of the enum value like:
```json
{
  "enumType": "com.example.Number",
  "name": "ONE"
}
```

Rendering an enum value as JSON will now instead throw a `ConverterException`.
See https://github.com/apache/grails-core/pull/15212[PR 15212] for more details on this change.

[[_hibernate_5_to_hibernate_7_migration]]
==== 26. Hibernate 5 to Hibernate 7 Migration

Grails 8 supports both Hibernate 5 (the default) and Hibernate 7.
If you are upgrading from Grails 7 and also want to migrate from Hibernate 5 to Hibernate 7, apply the `grails-hibernate7-bom` and the Hibernate 7 plugin in addition to the standard Grails 8 upgrade steps.

[source,groovy]
.build.gradle — switch to Hibernate 7
----
dependencies {
    implementation enforcedPlatform("org.apache.grails:grails-hibernate7-bom:$grailsVersion")
    implementation 'org.apache.grails:grails-hibernate7'
}
----

The following sections cover every breaking change introduced between Hibernate ORM 5.6.x (used by Grails 7) and Hibernate ORM 7.0.x, and what you need to do in your Grails application.

===== 26.1 Hibernate Session API Removals

Hibernate 7 removed long-deprecated Hibernate-specific session methods in favour of the standard JPA equivalents.
GORM's dynamic methods (`save()`, `delete()`, `get()`, `load()`, `merge()`, etc.) are **not** affected — these go through GORM's own persistence API and have been updated internally.

You are only affected if your code calls the Hibernate `Session` or `StatelessSession` directly (e.g. inside a `withSession` or `withStatelessSession` block).

[cols="1,1", options="header"]
|===
| Removed (Hibernate 5/6)
| Replacement (Hibernate 7 / JPA)

| `session.save(entity)`
| `session.persist(entity)`

| `session.update(entity)`
| `session.merge(entity)`

| `session.saveOrUpdate(entity)`
| `session.persist(entity)` (new) or `session.merge(entity)` (detached)

| `session.delete(entity)`
| `session.remove(entity)`

| `session.load(Class, id)`
| `session.getReference(Class, id)`

| `session.get(Class, id)`
| `session.find(Class, id)`
|===

===== 26.2 Removed Hibernate Annotations

The following Hibernate-specific annotations were removed in Hibernate 7.
Where a replacement exists, migrate before upgrading.

[cols="1,2", options="header"]
|===
| Removed annotation
| Action required

| `@org.hibernate.annotations.Where`
| Replace with `@org.hibernate.annotations.SQLRestriction`

| `@org.hibernate.annotations.WhereJoinTable`
| Replace with `@org.hibernate.annotations.SQLJoinTableRestriction`

| `@org.hibernate.annotations.Proxy`
| Remove — proxy configuration is no longer supported

| `@org.hibernate.annotations.LazyCollection`
| Remove and use `@ManyToMany(fetch = FetchType.LAZY)` or `EAGER` directly

| `@org.hibernate.annotations.Persister`
| Remove — custom persisters are no longer supported

| `@org.hibernate.annotations.SelectBeforeUpdate`
| Remove — behaviour is now configurable via `@DynamicUpdate`

| `@org.hibernate.annotations.Loader`
| Remove — custom SQL loaders are no longer supported; use `@SQLSelect` on the entity itself
|===

NOTE: Grails domain classes that use the `mapping { }` DSL are not affected by annotation removals.
These annotations only apply if you are using Hibernate annotations directly on Java or Groovy classes.

===== 26.3 CascadeType.SAVE_UPDATE Removed

`CascadeType.SAVE_UPDATE` (a Hibernate-specific cascade type) was removed in Hibernate 7.
Persisting a transient entity that has detached associations now throws `EntityExistsException` instead of silently merging.

If your domain mapping or annotated classes used `cascade = CascadeType.SAVE_UPDATE`, replace it with `cascade = CascadeType.ALL` or `cascade = [CascadeType.PERSIST, CascadeType.MERGE]` as appropriate.

Also note that automatic `cascade = CascadeType.PERSIST` on `@Id` and `@MapsId` associations was removed.
If you relied on this implicit behaviour, add an explicit `cascade = CascadeType.PERSIST` to the `@ManyToOne` or `@OneToOne` carrying `@MapsId`.

===== 26.4 Detached Entity Operations

Calling `refresh()` or `lock()` on a detached entity now throws `IllegalArgumentException` (JPA specification compliance).
In Hibernate 5 and 6 this was silently permitted.

If your code calls `entity.refresh()` or `entity.lock()` after the Hibernate session has been closed or the entity has been evicted, you must either re-attach the entity first (via `session.merge()`) or reload it before refreshing.

===== 26.5 Native Query Temporal Type Changes

Native SQL queries (via `executeQuery`, `withCriteria`, or a raw `Session.createNativeQuery`) now return `java.time` types (`LocalDate`, `LocalTime`, `LocalDateTime`) instead of the legacy `java.sql` types (`java.sql.Date`, `java.sql.Time`, `java.sql.Timestamp`).

If your code casts results from native queries to `java.sql` date/time types, update those casts to use the `java.time` equivalents, or set the following property to restore legacy behaviour during a phased migration:

[source,yaml]
.application.yml
----
hibernate:
    query:
        native:
            prefer_jdbc_datetime_types: true
----

===== 26.6 StatelessSession Now Uses Second-Level Cache by Default

`StatelessSession` in Hibernate 7 participates in the second-level cache by default (it did not in Hibernate 5/6).
If you use `withStatelessSession` for bulk operations where cache bypass was intentional, disable caching explicitly:

[source,groovy]
----
YourDomain.withStatelessSession { StatelessSession session ->
    session.setCacheMode(CacheMode.IGNORE)
    // ... bulk operations
}
----

===== 26.7 DDL Schema Changes

If you use `dbCreate = 'create-drop'` or `dbCreate = 'update'` in development, or generate a schema with the `schema-export` command, be aware of the following DDL differences in Hibernate 7.

[cols="1,1,2", options="header"]
|===
| Database
| Column type
| Change

| All
| `char` / `Character` fields
| Now maps to `varchar(1)` instead of `char(1)` to avoid trailing-space issues in MySQL

| Oracle
| `float` / `double` fields
| Now maps to `binary_float` / `binary_double` (IEEE 754). Set `hibernate.dialect.oracle.use_binary_floats=false` to restore the old mapping.

| Oracle
| `Timestamp` columns
| Precision changed to 9 digits (nanoseconds). Existing columns with lower precision will need migration.

| SQL Server
| `Timestamp` columns
| Precision changed to 7 digits (100 nanoseconds). Existing columns will need migration.

| MySQL / MariaDB
| Array-mapped columns
| Now stored as JSON instead of `VARBINARY`. Set `hibernate.type.preferred_array_jdbc_type=VARBINARY` to restore old behaviour.
|===

WARNING: If you have an existing production database, validate the schema diff before enabling `dbCreate = 'update'` or running Liquibase migrations after the Hibernate 7 upgrade.

===== 26.8 Hibernate 6 Intermediate Changes

Hibernate 7 builds on Hibernate 6, which itself introduced breaking changes relative to Hibernate 5.
The changes in Hibernate 6 that are most likely to affect a Grails application are listed here for completeness.

*HQL / criteria result type*: An HQL query with a `JOIN` but no explicit `SELECT` clause now returns the root entity type (`List<Person>`) instead of `List<Object[]>`.
If you depended on the old tuple-style result, add an explicit `SELECT` clause to your query.

*Ordinal HQL parameters*: Ordinal parameters changed from 0-based to 1-based indexing (`:?1`, `:?2`, ...).
The GORM dynamic finders and criteria API are not affected; only raw HQL strings with ordinal parameters need updating.

*Legacy Hibernate Criteria API removed*: The legacy `Criteria` / `DetachedCriteria` API from Hibernate 5 was removed in Hibernate 6.
GORM's `createCriteria()` and `withCriteria()` DSL are implemented on top of the JPA Criteria API and are **not** affected.

*Boolean type mappings*: If your schema used string-based boolean type names (e.g. `type: 'yes_no'`), migrate to the corresponding converter class (`YesNoConverter`).

*`javax.persistence` → `jakarta.persistence`*: This migration was already required for Grails 7; Grails 8 continues to require `jakarta.*`.

==== 27. GORM Properties Are Nullable by Default

Grails 8 changes the validation default so that an *unconstrained persistent (domain) property is nullable by default* rather than required.
This aligns Grails with the rest of the JVM persistence/validation ecosystem (JPA/Hibernate, Spring Data, Micronaut Data and Jakarta Bean Validation), all of which treat an unconstrained property as valid when `null`.

Previously every domain property without an explicit constraint had an implicit `nullable: false` applied, so `save()` failed validation when the property was `null`.
From Grails 8 such a property is optional unless you declare otherwise:

[source,groovy]
----
class Book {
    String title    // Grails 7: required, Grails 8: nullable

    static constraints = {
        title nullable: false   // declare explicitly to keep it required
    }
}
----

This change also affects persistence mapping. Columns for unconstrained properties are
nullable by default, so schema creation and schema migration can produce nullable columns
where Grails 7 produced `NOT NULL` columns. Review existing schemas and declare
`nullable: false` explicitly for properties that must remain required before running
schema migration or diff tools.

Command objects are unaffected: Validateable command-object properties remain non-nullable by default, except for collections and maps, which remain nullable by default.

To restore the legacy required-by-default behaviour for an entire application, set the new `grails.gorm.default.nullable` property to `false`:

[source,yaml]
.application.yml
----
grails:
    gorm:
        default:
            nullable: false
----

The existing `grails.gorm.default.constraints` wildcard form also works:

[source,groovy]
.application.groovy
----
grails.gorm.default.constraints = {
    '*'(nullable: false)
}
----

[[groovy5-behavior-changes]]
==== 28. Apache Groovy 5 Behavior Changes

Grails 8 upgrades from Apache Groovy 4 to Apache Groovy 5.
Most applications need no changes, but a few stricter behaviors can affect application code.

===== 28.1 Assigning a static field from a static closure requires the class name

Under Groovy 5 with static compilation (`@CompileStatic` / `@GrailsCompileStatic`), assigning to a static field from inside a static closure - such as the `constraints`, `mapping`, or `namedQueries` blocks of a statically compiled class - no longer resolves an unqualified field name to the enclosing class's static field.
Qualify the assignment with the class name:

[source,groovy]
----
@GrailsCompileStatic
class Book implements Validateable {
    static boolean constraintsClosureCalled = false

    static constraints = {
        // Groovy 4: an unqualified assignment resolved to Book.constraintsClosureCalled
        // Groovy 5: the field must be qualified with the class name
        Book.constraintsClosureCalled = true
    }
}
----

Reading a static field is unaffected; only assignment from within the static closure needs the class-name qualifier.

===== 28.2 Probing a `ConfigObject` for a missing key

In Groovy 5, indexing a `groovy.util.ConfigObject` with the subscript operator for a key that does not exist creates and inserts an empty nested `ConfigObject` for that key, rather than reading back as a falsy value.
Code that walks a configuration source with `[]` and expects a missing key to be `null` should test for the key explicitly before reading it:

[source,groovy]
----
// Under Groovy 5 this inserts an empty entry for a missing key
def value = configObject[key]

// Probe without creating an entry
def value = configObject.containsKey(key) ? configObject.get(key) : null
----

Grails applies this guard internally when resolving Spring profile activation keys; you only need it in application code that navigates a raw `ConfigObject` by subscript.

==== 29. MIME Types Are Now Framework Defaults

Grails 8 ships the full set of supported MIME types as built-in framework defaults.
Previously every newly generated application declared a `grails.mime.types` block in `grails-app/conf/application.yml`; the Grails Forge and the application profiles no longer generate that block, because the framework now supplies the same defaults out of the box.

The built-in defaults cover `all`, `atom`, `css`, `csv`, `form`, `html`, `js`, `json`, `multipartForm`, `pdf`, `rss`, `text`, `hal` and `xml`, the same set new applications historically declared.

**No action is required.**
A declared `grails.mime.types` still *replaces* the defaults, exactly as in Grails 7. Your existing block remains authoritative and you get precisely the types you declared (including a deliberately narrowed set). New applications simply no longer need the block, because the framework supplies these defaults when none is configured.

**Optional: merge instead of replace.**
If you would rather add to the defaults than re-declare them, set `grails.mime.mergeDefaults` to `true`. Your declared entries are then merged over the built-in defaults. An entry replaces the default for its extension, and any extension you do not declare keeps its default, so you can register a single custom type without restating the full list:

[source,yaml]
.application.yml
----
grails:
    mime:
        mergeDefaults: true
        types:
            custom: application/vnd.example+json
----

==== 30. Accept Header Honored for All Clients

Grails 8 honors the HTTP `Accept` header for content negotiation for **every** client by default, including browsers.

In Grails 7 and earlier, the `Accept` header was ignored for the major browser rendering engines: `grails.mime.disable.accept.header.userAgents` defaulted to `['Gecko', 'WebKit', 'Presto', 'Trident']`.
That was a workaround for very old browsers (Firefox 2 and 3) whose `Accept` header ranked XML above HTML.
Modern browsers send a well-formed header that already prioritizes HTML (`text/html` at `q=1.0`, `application/xml` at `q=0.9`), so the workaround is no longer needed.

The practical effect of the old default was that a browser-issued `fetch()` or `XMLHttpRequest` call requesting `application/json` would receive HTML instead of JSON, because those calls do not send an `X-Requested-With: XMLHttpRequest` header (only libraries such as jQuery add it automatically).
In Grails 8 such a request correctly receives JSON.

**What changed:** `grails.mime.disable.accept.header.userAgents` now defaults to unset, so no user agents are filtered.

**Most applications need no action.**
Browser page loads still resolve to HTML because modern browsers rank `text/html` highest, and `withFormat { html { } json { } }` continues to serve HTML to browsers and JSON to clients that ask for it.

**`respond` endpoints without an HTML view now error for browser requests.**
Because browsers now negotiate `text/html`, an action that uses `respond(...)` but has no corresponding HTML view (GSP) can no longer return JSON to a browser by accident. It negotiates `html` and attempts to render the missing view, which results in an error.
Previously the browser's `Accept` header was ignored, so such an endpoint happened to return JSON.
If you have browser-facing `respond` endpoints, add an HTML view for the action (or `render` an appropriate response directly).
API clients that send a specific `Accept` such as `application/json` are unaffected.

To restore the Grails 7 behavior of ignoring the `Accept` header for browsers, set the property explicitly:

[source,yaml]
.application.yml
----
grails:
    mime:
        disable:
            accept:
                header:
                    userAgents:
                        - Gecko
                        - WebKit
                        - Presto
                        - Trident
----

==== 31. @EnableWebMvc No Longer Added to Grails Applications

In earlier versions, Grails added Spring's `@EnableWebMvc` annotation to the generated `Application` class at compile time.
`@EnableWebMvc` is a Spring Framework configuration annotation that is not intended to be combined with Spring Boot: it imports `DelegatingWebMvcConfiguration`, which eagerly registers a fixed set of MVC beans and switches off Spring Boot's `WebMvcAutoConfiguration`.
In a Boot application this produced bean-definition override warnings at startup (for example for `localeResolver` and `internalAutoProxyCreator`) and prevented Boot's conditional, more configurable auto-configuration from taking effect.

Grails 8 removes the auto-injected `@EnableWebMvc`.
Spring Boot's `WebMvcAutoConfiguration` is now active for Grails servlet web applications, which is the intended Boot behavior and resolves those override warnings.

**No action is required for most applications.**
Grails adapts internally so the change is transparent.
The notes below explain the behavioral differences this introduces and the configuration toggles available if you need to control them.

===== 31.1 Boot's default view resolver is removed automatically

With `WebMvcAutoConfiguration` active, Boot contributes a catch-all `defaultViewResolver` (an `InternalResourceViewResolver`).
For a Grails application that does not use JSP/`InternalResource` views (for example a REST/JSON-views application) this resolver maps any unmatched view name to a servlet forward, which can surface as:

[source]
----
Circular view path [index]: would dispatch back to the current handler URL
----

To prevent this, Grails removes Boot's `defaultViewResolver` for every Grails servlet web application so that Grails' own view resolution is used instead.
If you need to keep Boot's `defaultViewResolver` (for example, an application that genuinely relies on JSP/`InternalResource` views), opt out:

[source,yaml]
.application.yml
----
grails:
    web:
        removeDefaultViewResolverBean: false
----

NOTE: In GSP applications the GSP view resolver continues to be installed as the primary `viewResolver`, controlled as before by `spring.gsp.replaceViewResolverBean`.
The earlier `spring.gsp.removeDefaultViewResolverBean` property (from when this removal lived in the GSP module) is still honoured for backward compatibility but is **deprecated** — `defaultViewResolver` removal is now handled centrally for all Grails servlet web applications by the `grails.web.removeDefaultViewResolverBean` property above, which takes precedence. Setting the old name logs a deprecation warning at startup.

===== 31.2 GrailsWebRequest binding

With `WebMvcAutoConfiguration` active, Boot would otherwise register its own `RequestContextFilter` that rebinds a plain `ServletRequestAttributes`, replacing the `GrailsWebRequest` bound earlier in the filter chain.
Downstream code that expects a `GrailsWebRequest` would then fail with a `ClassCastException`.

Grails 8 registers its request-binding filter as a `RequestContextFilter` bean so that Boot's filter backs off and the `GrailsWebRequest` remains bound for the whole request.
This is handled internally and requires no configuration changes.
Applications that defined their own `GrailsWebRequestFilter` bean, or their own `grailsWebRequestFilter` filter-registration bean, continue to override the Grails-provided ones.

===== 31.3 Boot's static resources and welcome page are removed

With `WebMvcAutoConfiguration` active, Boot would register a catch-all static-resource handler (`classpath:/META-INF/resources/`, `/resources/`, `/static/`, `/public/`) and a `WelcomePageHandlerMapping` that serves a static `index.html` for the root path.
In a Grails application these can shadow URL mappings — a request that should fall through to Grails' URL-mapping error handling could instead be served as a static resource, and `/` could return a static `index.html` rather than being handled by your `UrlMappings`.

So that Grails' own URL mappings and resource handling stay in control, Grails 8 disables both by default:

* `spring.web.resources.add-mappings` defaults to `false`, which disables Boot's catch-all static-resource handler. Re-enable it with `spring.web.resources.add-mappings: true`.
* Boot's welcome-page mapping is removed so `UrlMappings` owns `/`. Opt out with `grails.web.removeWelcomePageMapping: false`.

===== 31.4 Other Boot MVC features now active

Because Boot's `WebMvcAutoConfiguration` is now active, a handful of its features that `@EnableWebMvc` previously suppressed take effect for Grails servlet web applications.
None require action for a typical application, but they are behavioral differences worth knowing about when you upgrade:

* **Form-content filter for `PUT` / `PATCH` / `DELETE`.** A `FormContentFilter` parses `application/x-www-form-urlencoded` bodies of `PUT`, `PATCH` and `DELETE` requests so their fields are visible through the standard `request.getParameter(...)` API (and therefore in `params`). Boot's `OrderedFormContentFilter` provides it for a default application; for an application that declares `@EnableWebMvc` — where Boot's MVC auto-configuration backs off — Grails contributes an equivalent filter itself, so form parameters behave the same either way. In Grails 7 only `PUT` and `PATCH` bodies were parsed (by `GrailsParameterMap`) and `DELETE` was not; all three are now handled uniformly by the filter. It is enabled by default via `spring.mvc.formcontent.filter.enabled`; setting that to `false` disables `PUT` / `PATCH` / `DELETE` form-parameter parsing entirely. An application that reads those bodies itself can disable it:
+
[source,yaml]
.application.yml
----
spring:
    mvc:
        formcontent:
            filter:
                enabled: false
----

* **`spring.mvc.*` and `spring.web.*` properties now apply.** Properties such as `spring.web.locale`, `spring.mvc.format.date` / `time` / `date-time`, and `spring.web.resources.*` were inert before and now take effect. Review any of these you may have set inadvertently (for example copied from Boot documentation).

* **View resolution.** For non-GSP applications the primary `viewResolver` is now Boot's `ContentNegotiatingViewResolver`, which negotiates and delegates to the available view resolvers. GSP applications continue to use the GSP view resolver as before.

* **Conversion and message converters.** `mvcConversionService` is now Boot's `ApplicationConversionService` (which honours the `spring.mvc.format.*` properties above), and the MVC HTTP message converters pick up Boot's customizations, including the application's Jackson `ObjectMapper`. This mainly affects MVC-layer data binding and `@RestController`-style endpoints.

===== 31.5 Locale resolution

Grails' locale resolution — including `?lang=` switching via `SessionLocaleResolver` — continues to work as before, and the resolver strategy is now configurable through `grails.i18n.localeResolver` (`session` (default), `cookie`, `acceptHeader` or `fixed`).
Applications that opt into `@EnableWebMvc` are unaffected too: Grails removes the `AcceptHeaderLocaleResolver` that Spring's `WebMvcConfigurationSupport` would otherwise contribute, so the configured Grails resolver still wins.

==== 32. More Framework Beans Are Cleanly Overridable

Several Grails framework beans that were previously registered unconditionally are now guarded with `@ConditionalOnMissingBean`, so a bean of the same name (or type) defined in an application configuration class (a `@Bean` method) takes precedence cleanly instead of relying on bean-definition overriding:

* i18n: `localeResolver`, `localeChangeInterceptor` and `messageSource`
* URL mappings: `grailsCorsFilter`, `urlMappingsErrorPageCustomizer` and `urlMappingsInfoHandlerAdapter`
* Controllers: `exceptionHandler`
* Cache: `grailsCacheManager` and `customCacheKeyGenerator`

**No action is required.**
The framework beans still register by default; this only changes what happens when you define your own.
Previously, defining one of these beans produced a bean-definition override (with a startup warning, or a failure when `spring.main.allow-bean-definition-overriding` is `false`).
Now the framework bean simply backs off.

NOTE: The clean back-off applies to beans that are registered before auto-configuration conditions are evaluated: `@Bean` methods in application configuration classes, and — as of the retimed plugin lifecycle (see _Plugin Beans Now Register Before Spring Boot Auto-Configuration_ below) — beans contributed through a plugin's `doWithSpring` or `beanRegistrar()`.
Beans contributed through the application's `resources.groovy` or `resources.xml` register later and continue to take effect through bean-definition overriding, exactly as in previous releases.

[[plugin-beans-before-autoconfiguration]]
==== 33. Plugin Beans Now Register Before Spring Boot Auto-Configuration

In Grails 7 and earlier, the beans a plugin contributed through `doWithSpring` were registered *after* Spring Boot had processed its auto-configurations.
A plugin that wanted to replace a Boot-provided bean therefore had to rely on bean-definition overriding, and Boot beans guarded by `@ConditionalOnMissingBean` never saw the plugin's bean in time to back off.

In Grails 8 the plugin lifecycle is unified and retimed: plugin bean definitions — both `doWithSpring` and the new `beanRegistrar()` hook — are drained into the bean definition registry *before* Spring Boot expands its auto-configurations. The practical effects are:

* **Plugin beans win `@ConditionalOnMissingBean` races.** When a plugin registers a bean, a Boot auto-configuration bean of the same name or type that is guarded by `@ConditionalOnMissingBean` now backs off in favor of the plugin's bean. Plugins no longer need to override or remove Boot's defaults after the fact.
* **A single plugin manager and `GrailsApplication`.** The plugin manager and the `grailsApplication` are built once, early in the context lifecycle, and reused for the rest of startup, so plugins are loaded by a single manager pass rather than a throwaway pass followed by the real one. The global `Holders.grailsApplication` fallback is published after plugin `doWithSpring` and `beanRegistrar()` callbacks complete; plugins must not use it during those callbacks.
* **Artefact discovery happens earlier.** Controllers, services, domain classes and other artefacts are discovered before plugin beans are registered, because core plugins iterate the application's artefacts while defining beans. The set of discovered artefacts is unchanged — only the timing moved.
* **Side-effectful `doWithSpring` closures still run once**, just earlier. Closures that read `grailsApplication.config` continue to work; the configuration is fully loaded (including plugin-contributed configuration) before any bean-definition code runs.
* **The application class is instantiated a second time during early artefact discovery.** A throwaway instance is created solely to compute the application's class scan; the real lifecycle bean is still created by Spring. An application-class constructor with side effects therefore runs twice per boot, so keep application-class constructors free of side effects.
* **Plugins that inspect the bean definition registry see a pre-auto-configuration registry.** A `doWithSpring` closure that checks for the presence of a bean definition (for example `dispatcherServlet` as a "web application" signal) now runs before Spring Boot has registered its auto-configured beans, so such a check returns `false` where it previously returned `true`. Plugin code should test a registration-order-independent signal instead — for example whether the application context is a web application context.
* **The `annotationHandlerMapping` and `annotationHandlerAdapter` beans were removed.** These duplicated the request-mapping infrastructure that Spring's own MVC configuration already provides and were never consulted at runtime, but with plugin beans now registering first the bare duplicate adapter started shadowing Spring's fully configured one. `@Controller` annotated beans continue to be handled by Spring Boot's standard MVC infrastructure.

**The bean builder DSL is deprecated.** `doWithSpring()` on `GrailsApplicationLifeCycle` and `grails.plugins.Plugin` is deprecated as of Grails 8 — it continues to work, but the recommended way to register beans is the new `beanRegistrar()` hook, which returns a Spring Framework `org.springframework.beans.factory.BeanRegistrar`.

Before (deprecated bean builder DSL):

[source,groovy]
----
import grails.plugins.Plugin

class MyGrailsPlugin extends Plugin {

    @Override
    Closure doWithSpring() {
        { ->
            myService(MyServiceImpl) {
                timeout = config.getProperty('myPlugin.timeout', Integer, 30)
            }
        }
    }
}
----

After (Spring `BeanRegistrar`). `BeanRegistrar` is a functional interface, so a closure coerced with `as BeanRegistrar` is all that is required — a separate class is optional and only worth it for more involved or reusable registration:

[source,groovy]
----
import grails.plugins.Plugin
import org.springframework.beans.factory.BeanRegistrar
import org.springframework.beans.factory.BeanRegistry
import org.springframework.core.env.Environment

class MyGrailsPlugin extends Plugin {

    @Override
    BeanRegistrar beanRegistrar() {
        { BeanRegistry registry, Environment environment ->
            registry.registerBean('myService', MyServiceImpl) { spec ->
                spec.supplier { context ->
                    new MyServiceImpl(timeout: environment.getProperty('myPlugin.timeout', Integer, 30))
                }
            }
        } as BeanRegistrar
    }
}
----

Both hooks may be used on the same plugin during migration; when a registrar and the DSL register a bean under the same name, the registrar's definition wins.

**Bean-definition overriding edge case.** Grails defaults `spring.main.allow-bean-definition-overriding` to `true`, and under that default nothing changes: an application bean (from the application class, `resources.groovy` or `resources.xml`) that uses the same name as a plugin bean still replaces the plugin's bean. However, because plugin beans now register earlier and application beans register in a separate, later step, an application that explicitly sets `spring.main.allow-bean-definition-overriding` to `false` will see a `BeanDefinitionOverrideException` for an application bean that shadows a plugin bean — in previous releases the two definitions were merged before a single registry write, so the shadowing was silent even with overriding disabled.


==== 34. Compile-Time GORM Query Safety Check

Grails 8 adds a compile-time check that can **break existing builds**: passing a GORM query method (`find`, `findAll`, `executeQuery`, `executeUpdate`, and the Neo4j Cypher equivalents) a `String`-typed variable that was built from an interpolated `GString` is now a compile error, not just a discouraged pattern.

[source,groovy]
----
def vulnerable() {
    String query = "from Book as b where b.title = ${params.title}"  // fails to compile
    def books = Book.executeQuery(query)
}
----

This is flagged because passing the `GString` *directly* to `executeQuery` is safe — GORM binds the interpolated value as a query parameter — but assigning it to a `String`-typed local first causes Groovy to coerce it to a plain `String` at that point, silently discarding the safe binding; the interpolated value is then embedded as raw, unescaped query text. See <<sqlInjection,SQL injection>> for the full explanation and examples.

If your application has this pattern, fix it by keeping the query a `GString` all the way to the call, or by using named/positional parameters instead. For a call site that has been reviewed and is genuinely safe, add `@SuppressWarnings("GormUnsafeQueryString")` to the enclosing method rather than restructuring the code.

This check runs automatically wherever `grails-datamapping-core` is on the compile classpath, with no opt-in configuration required. If it produces a false positive with no workaround, the whole check can be disabled by setting the `protectSqlInjectionAttacks` system property to `false` (it defaults to `true`) — this is a last-resort, build-wide kill switch, not a substitute for the per-call-site `@SuppressWarnings` above.

The same check also emits (non-build-breaking) **compile-time warnings**, not errors, for two related but lower-confidence patterns: a `String`-typed field assigned an interpolated `GString` and later read through `this.field` in a query call, and query text built with `+` concatenation from a non-constant value with no `GString` involved. Neither of these fails the build, but both are worth reviewing — see <<sqlInjection,SQL injection>> for examples of both.

**Most applications and plugins need no action.** Behavior only changes where a plugin bean and a conditional Boot bean competed for the same name or type — the plugin bean now wins, which is almost always the intended outcome.

==== 35. Extension API Compatibility Notes

Most Grails applications do not call the Grails extension APIs listed below directly.
They can affect applications, plugins, or build logic that import these public classes or override these public extension points directly.
Unsupported internal implementation details are not covered here.

[cols="1,2,2", options="header"]
|===
| Module
| Changed or removed API
| Migration note

| `grails-codecs`
| `org.grails.plugins.codecs.CodecsGrailsPlugin` no longer overrides `doWithSpring()`
| Do not depend on that plugin override to contribute codec bean definitions.
Custom plugins should register their own beans from their own `doWithSpring()` implementation.

| `grails-controllers`
| `ControllersAutoConfiguration.dispatcherServletRegistration(...)` now returns Spring Boot 4's `org.springframework.boot.webmvc.autoconfigure.DispatcherServletRegistrationBean`
| Update direct imports, overrides, and binary integrations that referenced the Spring Boot 3 return type.

| `grails-converters`
| `grails.web.JSONBuilder`
| Use Groovy's `groovy.json.JsonBuilder` or `groovy.json.StreamingJsonBuilder` for JSON builder code.

| `grails-core`
| `GrailsApplicationPostProcessor`, `ProfilingGrailsApplicationPostProcessor`, and `DefaultGrailsPluginManager` plugin-discovery wiring changed
| Update custom bootstrapping code or subclasses that construct `GrailsApplicationPostProcessor` or `DefaultGrailsPluginManager` directly to pass the plugin-discovery dependency.
`ProfilingGrailsApplicationPostProcessor` keeps its `GrailsApplicationLifeCycle`, `ApplicationContext`, and `Class...` constructor shape, but the application context must expose the `grailsPluginDiscovery` bean.
This was introduced during the Grails 7.1 plugin-loading changes and remains required in Grails 8.

| `grails-core`
| `grails.plugins.PluginFilter`, `IncludingPluginFilter`, `ExcludingPluginFilter`, `NoOpPluginFilter`, and related filter types
| Import replacement types from the `org.apache.grails.core.plugins.filters` package, such as `PluginFilter`, `IncludingPluginFilter`, `ExcludingPluginFilter`, and `NoOpPluginFilter`.

| `grails-core`
| Legacy mixin transform APIs such as `grails.util.Mixin`, `MixinTargetAware`, and `MixinTransformation`
| Replace legacy mixin usage with Groovy traits, extension methods, or explicit composition.

| `grails-core`
| `grails.validation.ConstraintsEvaluator`
| Use `org.grails.datastore.gorm.validation.constraints.eval.ConstraintsEvaluator`.

| `grails-core`
| `BinaryGrailsPluginDescriptor`, `CorePluginFinder`, and `BinaryGrailsPlugin.getBinaryDescriptor()`
| Use the current plugin-discovery APIs and `BinaryGrailsPlugin.getPluginDescriptor()`.

| `grails-domain-class`
| `ConstraintEvaluatorAdapter` and `GrailsDomainClassAutoConfiguration.constraintsEvaluator(...)`
| Code that imported the auto-configuration method should use the current `validateableConstraintsEvaluator(...)` bean path and the datastore constraint evaluator APIs. The auto-configuration itself is now named `org.grails.plugins.domain.DomainClassAutoConfiguration`, following the `*GrailsPlugin` -> `*AutoConfiguration` convention; update any `@EnableAutoConfiguration(exclude = ...)` or `spring.autoconfigure.exclude` entry that referenced the old name.

| `grails-core`
| `org.grails.plugins.core.CoreAutoConfiguration`
| Now `org.grails.plugins.CoreAutoConfiguration`. The class is generated beside the plugin descriptor that declares its beans, and `CoreGrailsPlugin` lives in `org.grails.plugins`, so the simple name is unchanged but the package is not. Update any `@EnableAutoConfiguration(exclude = ...)`, `spring.autoconfigure.exclude`, or `@AutoConfigureAfter(name = ...)` entry naming the old package. Spring Boot only reports an invalid exclude for a class still on the classpath, so a stale entry is silently ignored rather than reported, and the beans it was meant to suppress are registered anyway.

| `grails-cache`
| `grails.plugin.cache.GrailsCacheAutoConfiguration`
| Now `grails.plugin.cache.CacheAutoConfiguration`, following the `*GrailsPlugin` -> `*AutoConfiguration` convention. Update any `@EnableAutoConfiguration(exclude = ...)` or `spring.autoconfigure.exclude` entry that referenced the old name; the same silent-ignore applies as above.

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

| `grails-fields`
| `BeanPropertyAccessor.getBeanClass()`, its setter, `PropertyPathAccessor.getBeanClass()`, `FormFieldsTagLib.input(...)`, and direct `render*` helper methods
| Use the `beanType` property exposed by the accessors.
Custom field renderers should go through the documented `f:field`, `f:widget`, and `f:displayWidget` tags and templates instead of calling private render helpers directly.

| `grails-gradle-plugins`
| `ApplicationContextCommandTask`, `ApplicationContextScriptTask`, `ProfileCompilerTask`, and `GrailsRunTask` are abstract; `GrailsGradlePlugin.createBuildPropertiesTask(...)` no longer returns a task value; several `IntegrationTestGradlePlugin` constants were removed
| Register these task types through Gradle task registration and update custom plugins that subclass `GrailsGradlePlugin` or read integration-test constants to use Gradle source sets, configurations, and the current test-phase APIs.

| `grails-shell-cli`
| `AetherGrapeEngine` and `AetherGrapeEngineFactory`
| Use `MavenResolverGrapeEngine` and `MavenResolverGrapeEngineFactory` if you extend the legacy shell dependency resolver.

| `grails-shell-cli`
| JLine 2 `complete(...)` signatures
| Implement the JLine 3 `org.jline.reader.Completer` contract, `complete(LineReader, ParsedLine, List<Candidate>)`.

| `grails-spring`
| `GrailsApplicationContext.onRefresh()`
| Custom context subclasses should use supported Spring lifecycle extension points instead of overriding this removed hook.
Spring theme APIs removed from the same area are covered in section 10.
|===

==== 36. Logback Configuration No Longer Generated by Default

Newly generated Grails 8 applications no longer include a `grails-app/conf/logback-spring.xml` file.

Logging still works out of the box. A generated application depends on `grails-logging`, which brings in Spring Boot's logging starter, so Logback and Spring Boot's own default Logback configuration apply automatically when the application supplies no configuration of its own. This matches the Spring Boot convention of needing no logging configuration file unless one is actually required.

NOTE: This change affects *application generation* only. Existing applications that already contain `grails-app/conf/logback-spring.xml` are unaffected and continue to use that file.

With no configuration file present you get Spring Boot's defaults: an `INFO` root level and a colorized console pattern. Levels and patterns can be tuned from `application.yml` with no XML:

[source,yaml]
.application.yml
----
logging:
    level:
        root: INFO
        com.example.myapp: DEBUG          # your packages
        org.hibernate.SQL: DEBUG          # see the generated SQL
    pattern:
        console: "%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n"
    file:
        name: logs/myapp.log              # setting this activates Spring Boot's file appender
----

Per-environment logging also needs no XML. Grails environments map to Spring profiles, so an `environments` block in `application.yml` applies levels to a single environment — for example, verbose logging for your own packages in development only:

[source,yaml]
.application.yml
----
environments:
    development:
        logging:
            level:
                com.example.myapp: DEBUG
----

A profile-specific `grails-app/conf/application-development.yml` containing the same `logging.level` entries works as well.

Add a `logback-spring.xml` file when properties are not enough — for example custom appenders, structured/JSON output, or per-environment appender routing. Use the `-spring` variant (not plain `logback.xml`), because it is processed by Spring Boot and unlocks `<springProfile>` and `<springProperty>`. Note that once the file exists it *replaces* Spring Boot's bundled configuration entirely, so it must declare its own appenders — the simplest approach is to compose Spring Boot's own includes:

[source,xml]
.grails-app/conf/logback-spring.xml
----
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>

    <springProfile name="development">
        <logger name="org.hibernate.SQL" level="DEBUG"/>
    </springProfile>
</configuration>
----

TIP: When generating a project with https://start.grails.org[Grails Forge], select the *Logback Configuration* feature to have a starter `grails-app/conf/logback-spring.xml` — the configuration shown above composed with a `<springProfile name="development">` block — generated for you.

[[cli-companion-artifacts]]
==== 37. CLI Commands Move to Companion `-cli` Artifacts

Grails commands (`ApplicationCommand` implementations) no longer ship inside runtime plugin
artifacts. Each command-bearing module publishes a companion artifact under its own coordinate with
a `-cli` suffix, and commands are registered in a dedicated `META-INF/grails-cli.factories` file
instead of `META-INF/grails.factories`.

**Package rename.** The command contract moved from `grails.dev.commands.*` (in `grails-core`) to
`org.apache.grails.core.cli.*` (in the new `org.apache.grails:grails-core-cli` artifact). Update
imports in your `grails-app/commands` classes:

[source,groovy]
----
// Grails 7
import grails.dev.commands.ApplicationCommand
import grails.dev.commands.ExecutionContext

// Grails 8
import org.apache.grails.core.cli.ApplicationCommand
import org.apache.grails.core.cli.ExecutionContext
----

**New `grailsCli` configuration.** The Grails Gradle plugin registers a `grailsCli` configuration
that is compile-visible for `grails-app/commands` sources and on the command-runner classpath, but
never on `runtimeClasspath` — so commands and their CLI-only dependencies are excluded from
`bootRun`, `bootJar`, and `bootWar`.

**CLI dependencies are discovered automatically.** The plugin adds
`org.apache.grails:grails-core-cli` (the command contract) and `org.apache.grails:grails-console`
(the command runner) to `grailsCli`, and walks the application's resolved dependency graph —
including transitive plugins — for jars carrying the `Grails-Cli-Artifact` manifest attribute,
adding each advertised companion automatically. The per-command Gradle tasks (e.g. `dbmUpdate`)
are registered from those companions as well, so no `buildscript` classpath entry is required. In
most applications, upgrading therefore requires **no build changes** to keep plugin commands
working:

[source,groovy]
.build.gradle
----
dependencies {
    // the dbm-* commands are discovered automatically: the plugin jar advertises
    // org.apache.grails:grails-data-hibernate7-dbmigration-cli, which is added to grailsCli
    implementation 'org.apache.grails:grails-data-hibernate7-dbmigration'
}
----

The `console` and `shell` tasks also draw from the provisioned cli tier, so the
`console "org.apache.grails:grails-console"` dependency that earlier Grails versions generated in
`build.gradle` is no longer needed and can be removed (the `console` configuration itself remains
for additional console-only dependencies).

The deprecated Grails 7 command contract is isolated in
`org.apache.grails:grails-core-cli-legacy`. When both `cliAutoProvision` (default `true`) and
`legacyCommandSupport` (default `false`) are enabled, the plugin adds this artifact to the
execution-only `grailsCliLegacy` bucket. `grailsCliClasspath` includes that bucket, but application
and plugin compile classpaths do not. Opt in so existing Grails 7 command plugins keep working
without exposing the deprecated API to new command sources.

Two flags control auto-provisioning:

* `cliAutoProvision` (default `true`) controls the modern CLI tier: `grails-core-cli`,
  `grails-console`, and discovered companion `-cli` artifacts.
* `legacyCommandSupport` (default `false`) controls only the Grails 7 application-command bridge.
  It is consulted only when `cliAutoProvision` is also enabled.

Disable the modern CLI tier with `grails { cliAutoProvision = false }` and declare dependencies
explicitly when full control over the command classpath is preferred:

[source,groovy]
.build.gradle
----
grails {
    cliAutoProvision = false
}

dependencies {
    implementation 'org.apache.grails:grails-data-hibernate7-dbmigration'
    grailsCli 'org.apache.grails:grails-core-cli'
    grailsCli 'org.apache.grails:grails-console'
    grailsCli 'org.apache.grails:grails-data-hibernate7-dbmigration-cli'
    grailsCliLegacy 'org.apache.grails:grails-core-cli-legacy'
}
----

WARNING: `grails-console` is not optional in that list. It carries the entry points the Gradle tasks
run - `grails.ui.console.GrailsSwingConsole`, `grails.ui.shell.GrailsShell`,
`grails.ui.command.GrailsApplicationContextCommandRunner` and
`grails.ui.script.GrailsApplicationScriptRunner`. Earlier versions reached them through the `console`
configuration that the application generator wrote into every `build.gradle`, so the tasks worked
without any CLI tier at all. Generated applications no longer declare that dependency, so with
auto-provisioning off and `grails-console` omitted, `console`, `shell`, `runCommand`, `runScript` and
`urlMappingsReport` fail with `Could not find or load main class`.

Keep modern companion auto-provisioning while enabling only the Grails 7 bridge:

[source,groovy]
.build.gradle
----
grails {
    legacyCommandSupport = true
}
----

The renamed framework command packages are:

|===
|Grails 7 | Grails 8 (`-cli` artifact)

|`grails.dev.commands.*` (grails-core)
|`org.apache.grails.core.cli.*` (grails-core-cli)

|`org.grails.web.mapping.reporting.UrlMappingsReportCommand`
|`org.apache.grails.web.mapping.cli.UrlMappingsReportCommand` (grails-web-url-mappings-cli)

|`scaffolding.*` commands
|`org.apache.grails.scaffolding.cli.*` (grails-scaffolding-cli)

|`grails.plugin.hibernate.commands.SchemaExportCommand`
|`org.apache.grails.data.hibernate5.cli` / `...hibernate7.cli` (grails-data-hibernate5-cli / -7-cli)

|`org.grails.plugins.databasemigration.command.*`
|`org.apache.grails.data.hibernate5.dbmigration.cli` / `...hibernate7.dbmigration.cli`

|`grails.plugin.springsecurity` s2 commands
|`org.apache.grails.security.cli.*` (grails-spring-security-cli)
|===

===== 35.1 Backwards Compatibility for Existing Command Plugins

Grails 8 provides a deprecated, execution-only compatibility layer for already-published Grails 7 command
plugins. Application commands created with `create-command` that implement
`grails.dev.commands.ApplicationCommand` or `grails.dev.commands.GrailsApplicationCommand` remain
available without a plugin re-release when the bridge is enabled. The deprecated
`grails.dev.commands.*` types are retained in `org.apache.grails:grails-core-cli-legacy`, which is
provisioned only on the command runner classpath when both `cliAutoProvision` and
`legacyCommandSupport` are enabled (`legacyCommandSupport` defaults to `false`), and registrations
in `META-INF/grails.factories` under `grails.dev.commands.ApplicationCommand` continue to be
discovered at runtime.

Enable the bridge in the consuming application:

[source,groovy]
.build.gradle
----
grails {
    legacyCommandSupport = true
}
----

Or set the project property `grailsLegacyCommandSupport=true`.

When the bridge is disabled, Grails still inspects legacy `META-INF/grails.factories` resources at
command runtime without loading their command classes. If a plugin still publishes
`grails.dev.commands.ApplicationCommand` entries, Grails logs an error naming the plugin artifact.
An unknown command also includes the remediation to enable `legacyCommandSupport` or upgrade the
plugin. This detection does not resolve application dependencies during Gradle configuration.

Grails logs a one-time deprecation warning when it uses this compatibility layer. It is intended
to keep existing published plugins working while plugin authors migrate; it is deprecated and may
be removed in a future major release. No removal major has been announced.

Legacy application commands do not receive a dedicated Gradle task. Run them through the generic
`runCommand` task (or the shell, which routes legacy adapters there), for example:

[source,bash]
----
./gradlew runCommand "-Pargs=generate-controller example.Book"
----

Named per-command Gradle tasks remain available only for migrated commands published in a
companion `-cli` artifact.

New and migrated commands should always compile against `org.apache.grails.core.cli.*` from
`grails-core-cli`. A plugin that intentionally continues to author commands against the deprecated
contract must declare `compileOnly 'org.apache.grails:grails-core-cli-legacy'` explicitly. Consumers
of already-published Grails 7 command plugins must opt in with
`grails { legacyCommandSupport = true }` (or declare `grailsCliLegacy` themselves).

**What just works:**

* Code generation scripts created with `create-script`, including scripts packaged as
  `META-INF/commands/*.groovy` such as `audit-quickstart` and `s2-quickstart`, remain executable
  through the CLI. When a plugin publishes a companion `-cli` artifact, those scripts (and
  `META-INF/commands/*.yml` / `*.json` multi-step commands under `src/main/scripts`) are packaged
  into the companion only - not the runtime plugin jar - and stay available via
  `grailsCliClasspath`. Plugins without a companion continue to ship `src/main/scripts` in the
  runtime jar, matching Grails 7 packaging.
* Existing published Grails 7 application-command plugins continue to work on Grails 8 without a
  re-release once `legacyCommandSupport` is enabled.

**What consumers of Grails 7 command plugins must do:**

* Enable `grails { legacyCommandSupport = true }` (or `-PgrailsLegacyCommandSupport=true`), or
  declare `grailsCliLegacy 'org.apache.grails:grails-core-cli-legacy'` explicitly when
  `cliAutoProvision` is disabled.

**What plugin authors must do:**

* Migrate application commands to the `org.apache.grails.core.cli.*` API and register them in
  `META-INF/grails-cli.factories`.
* Publish commands in a companion `-cli` artifact for the CLI classpath split and per-command
  Gradle task support. See xref:commandLine#creatingCustomCommands[Creating Custom Commands] for
  the complete workflow. Applying `org.apache.grails.gradle.grails-plugin-cli` also routes
  `src/main/scripts` into that companion automatically - leave scripts under `src/main/scripts`
  or place them under `src/cli/resources/META-INF/commands`.
* The `copyCommands` and `copyTemplates` tasks stage into a directory of their own instead of
  writing into the `processResources` output, and are registered when the plugin is applied rather
  than in `afterEvaluate`. They are still `Copy` tasks, and paths are still relative to
  `META-INF/templates` (or `META-INF/commands`), so a build script that configures them only needs
  to drop the `afterEvaluate { }` wrapper that used to be required:
+
[source,groovy]
----
tasks.named('copyTemplates', Copy) {
    from(layout.projectDirectory.dir('grails-app/views')) {
        into('views')
    }
}
----
* After first applying `grails-plugin-cli` on a project that was already built without it, consider
  running `./gradlew clean` once. Older plugin packaging wrote command scripts directly into
  `build/resources/main/META-INF/commands`; the first build on 8.0 drops those files, but the empty
  directories they lived in remain until the build directory is cleaned. Fresh builds never place
  companion scripts on the runtime classpath.

NOTE: The compatibility layer applies only to the legacy application-command contract. Independently
of it, a Grails 7 plugin that relies on APIs removed in Grails 8, Spring Boot 4, or other Grails 8
changes still requires an update.

==== 38. jQuery Webjar Upgraded to 4.0.0

The jQuery webjar managed by the `grails-bom` (`org.webjars.npm:jquery`) moves from 3.7.1 to 4.0.0.
jQuery 4 is a major release: it drops Internet Explorer and other legacy browsers and removes long-deprecated utilities such as `jQuery.trim`, `jQuery.type`, `jQuery.isArray`, `jQuery.isFunction`, `jQuery.proxy`, and `jQuery.isWindow`.
See the https://jquery.com/upgrade-guide/4.0/[jQuery 4.0 Upgrade Guide] for the complete list of changes.

Because the version is managed by the BOM, any application that references the jQuery webjar without an explicit version picks up 4.0.0 automatically once it adopts the Grails 8 BOM.
This includes the generated create-app welcome page, whose assets have been verified against 4.0.0.

If your application's own JavaScript relies on the removed APIs, migrate those call sites.
If you are not ready to migrate, pin the previous version in your gradle build:

[source,properties]
.gradle.properties
----
jquery.version=3.7.1
----

==== 39. spring-security-ui Rewritten on Bootstrap 5

The `spring-security-ui` plugin's screens are rewritten on Bootstrap 5 with no bundled styling of their own.
The plugin no longer ships or depends on jQuery UI, jquery-form, jGrowl, DataTables, bgiframe or the bundled jdMenu/positionBy plugins, and every plugin stylesheet and image has been removed — dialogs are Bootstrap modals, tabs are nav-tabs, notifications render through `g:flashMessages`, and the autocomplete search uses a native `datalist`.
jQuery (4.x) and Bootstrap are expected from the host application, as provided by the create-app layout.

Plugin pages now render through the host application's own layout — configured with `grails.plugin.springsecurity.ui.gsp.parentLayout` (default `main`) — inheriting its theme, including Bootstrap dark mode, locale selector and branding.
The security menu and login state are contributed to the host navbar through `nav` and `navActions` content blocks, which the create-app layout renders with `<g:pageProperty name="page.nav"/>` and `<g:pageProperty name="page.navActions"/>`; a custom layout that wants the menu in its navbar renders the same page properties.

If you upgrade an application that customized the plugin:

* Views previously copied into the application with `s2ui-override` reference removed stylesheets and rely on the old table-based markup of the `s2ui` field-row tags, which now emit Bootstrap form groups; regenerate or update those copies.
* CSS written against the old `s2ui_*` and `jd_menu` classes no longer has anything to match; restyle against Bootstrap classes.
* Applications that used the plugin's transitive webjars (jQuery UI, jGrowl, DataTables, jquery-form) must now declare those dependencies themselves.
* The `grails.plugin.springsecurity.ui.Constants` class and the `spring-security-ui-register.js` and `spring-security-ui-*` stylesheet assets have been removed.
* `LogoutController` now declares a computed `allowedMethods` restricting its action to POST while `logout.postOnly` is active (the default), making the restriction visible to tooling such as the create-app welcome page.

==== 40. Stack Trace Filterer Configuration Reaches All Callers

`grails.logging.stackTraceFiltererClass` and `grails.exceptionresolver.logFullStackTraceOnFilter` now also
apply to paths outside the exception resolver — including GSP view-render exceptions — so the same two keys
control which filterer is used and whether it emits, everywhere the framework filters a stack trace, not
just for resolver-driven requests.

[source, yaml]
.grails-app/conf/application.yml
----
grails:
    logging:
        stackTraceFiltererClass: com.example.MyStackTraceFilterer
    exceptionresolver:
        logFullStackTraceOnFilter: false
----

Applications that previously had to silence the `StackTrace` logger in logback purely to suppress
render-time noise can set `logFullStackTraceOnFilter: false` instead and reach every caller of the filterer.

The configured filterer is resolved once during application startup, before the application context
refreshes. Any context not started through `SpringApplication` — the CLI, plain `main()` usage, Grails unit
tests — keeps the previous fallback behaviour unchanged: a plain `DefaultStackTraceFilterer` with no
configuration applied. The filterer is JVM-global rather than per-context, so in a JVM hosting more than one
application context the last one booted supplies the filterer for all of them.

==== 41. Jansi Removed and Developer Tooling Off the Application Classpath

Grails no longer depends on a separate ANSI library. The console renders its escape sequences directly,
and the FuseSource Jansi artifact - unmaintained, and the subject of an unfixed CVE - is gone from the
dependency graph along with the `org.jline:jansi` fork that briefly replaced it.

At the same time, developer-only tooling no longer ships with an application. These artifacts could reach
an application's production runtime classpath, by a different route for each:

* `org.apache.grails:grails-console` - the Swing console, the Groovy shell, and the script and command
  runners - and the `org.apache.groovy:groovy-console`, `groovy-groovysh` and `groovy-swing` libraries it
  brings with it, through the `console` configuration the application generator declared
* the JLine family, including `org.jline:jansi`, and the FuseSource Jansi artifact, declared directly by
  the generated build

None of these are on an application's runtime classpath now. They are provisioned onto the `grailsCli`
configuration instead, which the Grails Gradle plugin puts on the compile and test classpaths but never on
`runtimeClasspath` - so `bootRun`, `bootJar` and `bootWar` no longer carry them. The `console` and `shell`
tasks are unaffected and continue to work exactly as before.

===== What you need to change

Applications generated by earlier versions of Grails declare Jansi explicitly:

[source,groovy]
.build.gradle
----
runtimeOnly "org.fusesource.jansi:jansi"
----

Delete that line. New projects are generated without it.

If your own code imports Jansi directly, it must move off it - there is no Grails-managed replacement
coordinate. Application code that needs coloured output should use Spring Boot's
`org.springframework.boot.ansi.AnsiOutput`, or JLine's `org.jline.utils.AttributedStringBuilder` if the
project already depends on JLine.

If your code, or a `runtimeOnly` dependency of it, genuinely needs the Groovy shell, Swing console or JLine
at application runtime, declare that dependency explicitly rather than relying on the starter:

[source,groovy]
.build.gradle
----
runtimeOnly "org.apache.groovy:groovy-groovysh"
----

===== Controlling ANSI output

Jansi's global switch went away with the library. Console colour is now driven by Spring Boot's own
property, so it is configured in one place alongside the rest of the application's output settings:

[source,yaml]
.application.yml
----
spring:
    output:
        ansi:
            enabled: never   # always | detect | never
----

`detect` is the default and keeps the previous behaviour - colour only when the attached terminal
supports it. `never` suppresses it entirely, and `always` forces it on even when the terminal is not
detected as capable, which is useful for CI logs that render ANSI.

The same property works as a system property, which is how the CLI reads it since it has no Spring
`Environment` to bind from:

[source,bash]
----
grails -Dspring.output.ansi.enabled=never run-app
----

Note that passing `-D` to `gradlew` sets the property on the Gradle daemon, not on the JVM that
`bootRun` forks for your application, so it has no effect there. To disable colour for `bootRun`, use
the `application.yml` form above or set it on the task itself:

[source,groovy]
----
tasks.named('bootRun') {
    systemProperty 'spring.output.ansi.enabled', 'never'
}
----

If you previously disabled colour through Jansi's own switch, replace it - the property no longer has any
effect now that the library is gone:

[cols="1,1"]
|===
|Before |Now

|`-Dorg.fusesource.jansi.Ansi.disable=true`
|`-Dspring.output.ansi.enabled=never`
|===

===== Remove `withJansi` from your Logback configuration

Logback's `ConsoleAppender` has a `withJansi` option that loads `org.fusesource.jansi.AnsiConsole` by
name to wrap the output stream. With Jansi gone from the dependency graph, an application that still
enables it logs a warning on every startup and falls back to the plain stream:

[source]
----
-WARN in ch.qos.logback.core.ConsoleAppender[STDOUT] - Failed to create AnsiPrintStream. Falling back
on the default stream. java.lang.ClassNotFoundException: org.fusesource.jansi.AnsiConsole
----

Delete the element from `grails-app/conf/logback.xml` or `logback-spring.xml`:

[source,xml]
----
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <withJansi>true</withJansi>   <!--1-->
    <encoder>
        ...
    </encoder>
</appender>
----
<1> remove this line

Nothing is lost. Coloured log output comes from Spring Boot's `ColorConverter` - the `%clr(...)` tokens
in the encoder pattern - which is independent of Jansi and honours `spring.output.ansi.enabled`. The
`withJansi` option only wrapped the stream for legacy Windows consoles.

Applications generated by recent versions of Grails already omit it: Jansi's global `System.out`
replacement breaks console logging after a Spring Boot DevTools restart
(https://github.com/apache/grails-core/issues/15663[issue #15663]), so it was removed from the
generated configuration before Jansi itself was dropped. Only applications carrying older
configuration forward need this change.

===== `GrailsConsole` moved to a companion artifact

The console classes are developer tooling, so they now ship as `grails-bootstrap`'s CLI companion
rather than in the artifact applications depend on. These types moved out of
`org.apache.grails.bootstrap:grails-bootstrap` and into
`org.apache.grails.bootstrap:grails-bootstrap-cli`:

* `grails.build.logging.GrailsConsole`
* `grails.build.logging.ConsoleLogger`
* `grails.build.logging.GrailsEclipseConsole`
* `org.grails.build.logging.GrailsConsolePrintStream`
* `org.grails.build.logging.GrailsConsoleErrorPrintStream`
* `org.grails.build.logging.GrailsConsoleAntBuilder`
* `org.grails.build.logging.GrailsConsoleBuildListener`
* `org.grails.build.interactive.CandidateListCompletionHandler`

The URL mappings report renderer moved the same way, out of `org.apache.grails:grails-url-mappings`
and into `org.apache.grails:grails-url-mappings-cli`:

* `org.grails.web.mapping.reporting.AnsiConsoleUrlMappingsRenderer`

Their package names are unchanged - only the artifact that carries them is different.

Command code needs no change: the CLI tier is auto-provisioned onto `grailsCli`, so commands in
`grails-app/commands` and in a plugin's `src/cli` continue to see `GrailsConsole` as before.

If application code (not command code) references any of these types, it was relying on developer
tooling being present at runtime. Prefer a logger, or Spring Boot's
`org.springframework.boot.ansi.AnsiOutput` for coloured output. If the dependency is genuinely
required, declare the companion explicitly:

[source,groovy]
.build.gradle
----
implementation "org.apache.grails.bootstrap:grails-bootstrap-cli"
----

Test code that extended `GrailsConsolePrintStream` to capture output does not need it: that class
routes output through the CLI console, which a test almost never wants. Extend `java.io.PrintStream`
directly instead.

==== 42. Plugin Authors: Separate Source Sets for the CLI Tier

This affects plugins that apply `org.apache.grails.gradle.grails-plugin-cli` and therefore publish a
companion `-cli` artifact.

Previously the `cli` source set's output and its dependencies were added to the plugin's `test`
classpath, so unit tests saw CLI classes and CLI-only libraries that an application never receives. A test
could pass against something that was not actually there in production.

The `cli` tier now has its own test source sets:

[cols="1,2"]
|===
|Source set |Purpose

|`src/test-cli`
|Tests for CLI classes. Runs as the `testCli` task, wired into `check`.

|`src/integration-test-cli`
|CLI tests that need a booted application - a command starts one the same way the shell does. Runs as the
`integrationTestCli` task.
|===

Both are created by `org.apache.grails.gradle.grails-plugin-cli`, so there is nothing to declare: create the
folder, add a spec, and the task picks it up.

`src/test` no longer sees the CLI tier at all. Move any existing spec that exercises a command or another
CLI class into `src/test-cli`, or into `src/integration-test-cli` if it boots an application.

Both CLI test source sets can read the module's `main` and `test` output, so shared fixtures stay where they
are. `src/integration-test-cli` additionally reuses `src/integration-test/resources` when that folder exists,
since both integration phases boot the same application and its configuration should not be duplicated.

The relationship between the source sets is deliberately one-way: CLI tests may use test fixtures, but
ordinary tests never see CLI classes, which is what keeps the `test` classpath a faithful stand-in for
production.

==== 43. `gspCompile` Configuration Removed

The `org.apache.grails.gradle.grails-gsp` plugin no longer registers a `gspCompile` configuration.

It dated back to the original Ant-based GSP compiler, where it carried the classpath that compiler ran
against. Once GSP compilation moved to a forked compile task the configuration's only remaining content was
a hardcoded servlet API dependency, and that dependency has itself been removed — the servlet API already
reaches GSP compilation through the regular compile classpath, at the version the BOM manages.

GSPs continue to compile against the project's `compileClasspath`, its compiled classes, and — for a WAR
build — `providedCompile`. No application that leaves the configuration alone is affected.

A build that declares dependencies on `gspCompile` fails with `Could not find method gspCompile()`. Move
those declarations to `compileOnly`, which places them on the compile classpath that GSP compilation uses:

[source,groovy]
.build.gradle
----
dependencies {
    // Grails 7
    // gspCompile 'com.example:some-library:1.0'

    // Grails 8
    compileOnly 'com.example:some-library:1.0'
}
----

==== 44. `grails.spring.bean.packages` Scanning Fixed Outside the Project Directory

The component scan driven by `grails.spring.bean.packages` resolved its search root from a probe of the
*working directory* — it looked for a `grails-app` directory or an `Application.groovy` beside it — and
returned nothing when neither was there. An application started from anywhere other than its project root
therefore registered none of the `@Component` classes in the configured packages, silently.

That covers most production shapes: a systemd unit with its own `WorkingDirectory`, a container with a
`WORKDIR`, an application server. `java -jar build/libs/app.jar` run from the project root was unaffected,
which is why the gap survived. The scan now uses Spring's own resource resolution, so the packages are
scanned wherever the process is started from.

[source, yaml]
.grails-app/conf/application.yml
----
grails:
    spring:
        bean:
            packages:
                - com.example.components
----

**Applications deployed this way will start getting beans they previously did not.** The scan registers
every `@Component`, `@Service`, `@Repository` and `@Controller` in the listed packages. Two things to check
before upgrading:

* A class in one of those packages that was never actually reaching the context — because it duplicates a
bean the application defines elsewhere, or because it was left behind — now registers. A duplicate under the
same bean name will fail startup rather than pass silently.
* Beans that were previously absent may now participate in autowiring by type, so an injection point that
resolved unambiguously before can become ambiguous.

Removing the packages from `grails.spring.bean.packages`, or the stray classes from those packages, restores
the previous set of beans.

==== 45. GORM API Resolution Moves to a Registry

GORM no longer allocates an API object for every (entity, connection) pair up front. Per-entity, per-qualifier
state now lives in a registry (`org.grails.datastore.gorm.GormRegistry`), and APIs are built on first use by a
pluggable factory. For applications this is invisible — `Book.list()`, dynamic finders, and
`Book.withNewSession { }` behave as before. It does change some extension points that out-of-tree GORM
implementations subclass.

===== GormEnhancer

`org.grails.datastore.gorm.GormEnhancer`'s public constructors, `enhance()`/`enhance(PersistentEntity)`,
`dynamicEnhance`, and the `transactionManager` field are all unchanged in behaviour and signature (the
`transactionManager` field is now `@Deprecated` and immutable after construction — read it, don't assign it).

The following members were removed:

[cols="1,2", options="header"]
|===
| Removed
| Migration note

| `finders` field / `getFinders()`
| Use `createDynamicFinders(Datastore)`, or read the finders from the API object you are working with.

| `removeConstraints()`
| Moved to `GormRegistry.removeConstraints()`, called automatically from `GormRegistry.removeDatastore(Datastore)`
as part of tearing a datastore down. There is nothing to call directly - it runs when the enhancer's `close()`
removes the datastore from the registry, same as before.

| `addStaticMethods(PersistentEntity, boolean)` / `addInstanceMethods(PersistentEntity, boolean)`
| The `onlyExtendedMethods` parameter is gone; both methods now take only the entity. Installation mechanism
also changed from per-method reflective registration to a single `methodMissing`/`propertyMissing` handler pair.

| `findTenantId`, `apiQualifiers`, `findDatastoreByType`, `findSingleDatastore`,
`findSingleTransactionManager`, `findTransactionManager(Class, String)`, `appliesToDatastore`,
`getInstanceMethodApiProviders`, `registerStaticMethod`, `registerInstanceMethod`, `doesRealMethodExist`,
`isRealMethod`
| Internal helpers of the old per-method reflective registration mechanism, replaced wholesale by the registry
and its `methodMissing`/`propertyMissing` handlers. If you overrode one of these to customise dispatch, override
`registerApiFactories()` (below) or a `GormApiFactory` instead.
|===

A new `registerApiFactories()` hook is called once, before any entity is registered. Adapters that build
specialised API objects should override it:

[source,groovy]
----
class MyGormEnhancer extends GormEnhancer {

    @Override
    protected void registerApiFactories() {
        GormRegistry.instance.registerApiFactory(MyDatastore, new MyGormApiFactory())
    }
}
----

WARNING: Earlier snapshots of this work registered API factories from `registerConstraints(Datastore)`.
Move that code to `registerApiFactories()`; factory registration must not depend on the constraints lifecycle step.

===== CurrentTenantHolder.get()

`grails.gorm.multitenancy.CurrentTenantHolder.get()` — the no-argument form — now throws `TenantException` when
different datastores hold different tenants on the calling thread, rather than returning an arbitrary one of
them. Pass the datastore you are operating on:

[source,groovy]
----
CurrentTenantHolder.get(datastore)
----

See <<multiTenancy,Multi-Tenancy>> for the full multi-tenancy API.

===== Tenants.withId(Class, Serializable, Closure)

`grails.gorm.multitenancy.Tenants` had two distinct 3-argument `withId` overloads that both erased to
`withId(Class, Serializable, Closure)` at the bytecode level:

[source,groovy]
----
// removed - resolved a datastore by its own type
static <T> T withId(Class<? extends Datastore> datastoreClass, Serializable tenantId, Closure<T> callable)

// kept - resolves the datastore that maps a domain class
static <T> T withId(Class domainClass, Serializable tenantId, Closure<T> callable)
----

The datastore-class overload relied on `GormEnhancer.findDatastoreByType(Class)`, which was one of the internal
per-method-dispatch helpers removed as part of the registry migration (see <<_gormenhancer,GormEnhancer>>
above), so it could not be carried forward under its own name. Only the domain-class overload remains.

Because both overloads share one erased signature, a call site that used to pass a datastore class -
`Tenants.withId(SomeDatastore, tenantId) { ... }` - still compiles unchanged against the domain-class overload.
It no longer resolves the datastore you named: `getDatastoreForDomain(SomeDatastore)` looks `SomeDatastore` up as
if it were a *domain class*, finds no entity registered under that name, and falls through to the application's
`DEFAULT` datastore. On a single-datastore application this is indistinguishable from the old behaviour. On a
multi-datastore application it silently binds the tenant against the wrong datastore - no exception, no log -
which can surface much later as a cross-database read or write.

If you have a call site that passes a `Datastore` subclass to this method, migrate it to look the datastore up
explicitly and call the datastore-instance overload instead. `Tenants.datastoreLocator` still resolves a
datastore by its type - only the `withId(Class, ..)` shortcut for it is gone:

[source,groovy]
----
MultiTenantCapableDatastore datastore = Tenants.datastoreLocator.getDatastore(SomeDatastore) as MultiTenantCapableDatastore
Tenants.withId(datastore, tenantId) { ... }
----

or pass an actual domain class if that is what you meant:

[source,groovy]
----
Tenants.withId(SomeDomainClass, tenantId) { ... }
----


==== 46. Startup Banner Shows the Servlet Container and Spring Security

The banner now reports the servlet container it is running on, and the version of Spring Security where the
application has it, without being asked for either. Both were previously opt-in through
`grails.banner.versions.include`.

What made them opt-in was that a version which could not be determined was shown as `unknown`, so turning one
on by default would have printed `Spring Security: unknown` for every application without it. A version shown
by default that cannot be determined is now left out instead, which is what allows the two to be shown at all.
A version the application asked for by name still reads `unknown` where it cannot be determined, since saying
nothing about something that was asked for reads as the option having been ignored.

The container is named as itself: an application on Tomcat shows `Tomcat`, one on Jetty shows `Jetty`. It is
configured under the single key `container`, whichever container that turns out to be.

To restore the previous banner, exclude them:

[source, yaml]
.grails-app/conf/application.yml
----
grails:
    banner:
        versions:
            exclude:
                - container
                - spring-security
----

An application that already asked for `spring-security` under `grails.banner.versions.include` keeps working
and needs no change — the option is still recognised, and the version is shown either way. The same is true
of `tomcat`, `jetty` and `undertow`, which remain available for an application that wants to be told about a
particular container whether or not it is the one serving; naming one as well as taking the default shows it
once, not twice.

A version option that names none of the known keys is now reported as a warning after the banner is printed,
rather than being dropped in silence.
==== 47. Message Bundles Resolved by Spring Boot

Grails no longer registers a `messageSource` bean. Spring Boot's `MessageSourceAutoConfiguration`
supplies a standard `ResourceBundleMessageSource`, and Grails contributes only the base-name list, which
it records at build time rather than discovering by scanning the classpath at runtime.

Most applications need no change. The rest of this section covers the cases that do.

===== `spring.messages.*` is now the configuration surface

`spring.messages.*` was previously ignored, because Grails' own message source claimed the bean name and
Boot's auto-configuration backed off. It is now honoured in full — `encoding`, `cache-duration`,
`fallback-to-system-locale`, `always-use-message-format`, `use-code-as-default-message` and
`common-messages`.

Two Grails settings give way to it:

[cols="2,2,3"]
|===
|Setting |Replacement |Status

|`grails.i18n.cache.seconds`
|`spring.messages.cache-duration`
|Deprecated, still honoured for now — see below

|`grails.i18n.filecache.seconds`
|None
|Ignored
|===

`grails.i18n.cache.seconds` is still honoured as a temporary upgrade aid: it is translated into
`spring.messages.cache-duration` and logs a deprecation warning. As before, it applies only when reload
is enabled, so an application that set it for development keeps the timing it had. Setting
`spring.messages.cache-duration` explicitly always wins. Migrate before it is removed.

`grails.i18n.filecache.seconds` has no replacement and is ignored: it tuned the file-timestamp re-check
of the message source Grails used to own, which Spring Boot's has no equivalent for.

`grails.i18n.availableLocales.includePlugins` is replaced by `grails.i18n.include-plugin-bundles`, which
now governs both message resolution and the locales offered by a language selector, so the two can no
longer disagree.

===== Plugin authors: base names must be namespaced

A plugin's message bundle base names must be its plugin name, or that name followed by a hyphen and a
suffix:

[source]
----
grails-app/i18n/spring-security-core.properties            <1>
grails-app/i18n/spring-security-core_fr.properties
grails-app/i18n/spring-security-core-validation.properties <2>
----
<1> the plugin's own name
<2> further bundles inside its namespace

The plugin build fails otherwise. A plugin shipping `messages.properties` used to contribute messages as
a fallback behind the application's own bundle; Spring resolves a base name to the first matching
resource on the classpath, so such a bundle would now silently shadow, or be shadowed by, the
application's. Rename it into the plugin's namespace.

Applications are unaffected — there is only one application, so its base names cannot collide.

A namespaced bundle still works under Grails 7, so a plugin does not need a separate source branch to
support both. Grails 7 discovers a plugin's bundles by scanning the plugin jar's root for
`*.properties` and matching locale suffixes; it never inspects the base name, so
`spring-security-core.properties` and `spring-security-core_fr.properties` are found exactly as
`messages.properties` was. The `spring-security-core` plugin has shipped this layout for years. Rename
once, and the same source builds for both: Grails 7 finds the bundles by scanning, Grails 8 through the
generated descriptor.

===== Every bundle needs a locale-independent file

Spring Boot's auto-configuration only activates when a configured base name has a bundle without a
locale suffix, and it contributes the whole `messageSource` bean. An application shipping only
`messages_de.properties` would therefore have had no message source at all, so the build now rejects it.

===== Ambiguous base names must be declared

A base name ending in a valid locale identifier is ambiguous: `api_fr.properties` reads as base name
`api` in French. Likewise `api_errors.properties` alongside `api.properties` reads as a mistyped locale
variant of `api`. Both fail the build; declare the base name when the inference is wrong:

[source,groovy]
----
grails {
    i18n {
        basenames = ['api', 'api_errors']
    }
}
----

===== `PluginAwareResourceBundleMessageSource` removed

`org.grails.spring.context.support.PluginAwareResourceBundleMessageSource` and the Grails fork of
`ReloadableResourceBundleMessageSource` beside it are removed. Code that injected `MessageSource` is
unaffected. Code that cast the bean to either type, or called `getBundleCodes()`, must be updated —
inject `MessageSource` and use its interface, or declare your own bean, which Boot's auto-configuration
backs off from.

===== Language selectors read the same plugin set as message resolution

The locales offered by `g:localeSelect available="true"` now come from the recorded bundles rather than a
classpath scan, and they follow exactly the plugin set that message resolution uses. A plugin that was
evicted, excluded, or failed to load no longer contributes a language entry, so a selector can no longer
offer a translation whose messages will not resolve.

An application that declares its own `AvailableLocaleResolver` bean must supply the effective descriptors
instead of a `ClassLoader`; most applications simply rely on the one Grails registers.

===== Reloading during development

Editing a value in an existing bundle still reloads without a restart: Grails contributes a short
`spring.messages.cache-duration` when reload is enabled, in place of the `grails.i18n.cache.seconds` it
used to apply to its own message source.

Adding or removing a base name now needs a restart, because Spring Boot reads the configured base names
once when it builds the message source.

==== 48. CAS Single Sign-Out Is Opt-In

`grails.plugin.springsecurity.cas.useSingleSignout` now defaults to `false`. It previously defaulted
to `true`, so every CAS application registered the CAS client's `SingleSignOutFilter` whether or not
it wanted single sign-out.

The default changed because enabling single sign-out is a security trade-off rather than a free
feature. CAS maps the service ticket to the HTTP session id, so single sign-out cannot work while
session fixation prevention is replacing the session when the user authenticates. Enabling
`cas.useSingleSignout` disables session fixation prevention, and an application should make that
choice deliberately.

If your application relies on CAS single sign-out, enable it explicitly:

[source,groovy]
.grails-app/conf/application.groovy
----
grails.plugin.springsecurity.cas.useSingleSignout = true
----

The plugin logs a warning at startup when it is enabled, noting that session fixation prevention has
been disabled.

**Single sign-out did not actually work before Grails 8**, so an application upgrading from an
earlier version is unlikely to lose working behaviour. The plugin set
`useSessionFixationPrevention = false` from `doWithSpring`, but it declares `loadAfter =
['springSecurityCore']`, so the core plugin had already defined `sessionAuthenticationStrategy` from
the original value. The filter was registered, the session was still replaced on login, and CAS
logout requests silently matched nothing. Grails 8 also redefines `sessionAuthenticationStrategy`
from the CAS plugin, so enabling `cas.useSingleSignout` now has the documented effect.

The Spring Security CAS configuration reference in this guide documents the setting in full.

==== 49. GSPs Record a Source Checksum Instead of a Modification Time

A GSP used to carry the modification time of its `.gsp` source, precompiled pages baking it into the
generated class as a `LAST_MODIFIED` constant. Git stores no modification times, so every fresh clone or CI
checkout gave each source a new one, and the generated classes differed on every checkout even when the
sources were byte-for-byte identical. Because `LAST_MODIFIED` was a compile-time constant it was part of the
class's ABI, so the difference survived even Gradle's compile-classpath normalization and every task
downstream of a jar containing precompiled GSPs missed the build cache.

Grails 8 records a `SOURCE_CHECKSUM` of the page source instead, for pages compiled at build time and at
runtime alike. Identical sources now compile to identical bytes on every machine.

Runtime reloading, described in
<<makingChangesToADeployedApplication,Making Changes to a Deployed Application>>, is unaffected: a page is
reloaded when its source no longer matches the recorded checksum. Detection is more accurate than it was.
An edit is caught whenever the source's modification time or length moves, which any ordinary save does;
previously a save landing within the old granularity window went unnoticed until a later edit moved it
clear. In the other direction, a page whose modification time moves without its content changing is no
longer recompiled, so touching a file — or a build step copying views into place — no longer reloads pages
that did not actually change. If you were using `touch` to force a page to recompile, edit the page instead.

**Removed API.** `GroovyPageMetaInfo.getLastModified()`, `setLastModified(long)` and
`applyLastModifiedFromResource(Resource)` are gone, as are `GroovyPageParser.getLastModified()` and
`setLastModified(long)`. Use `GroovyPageMetaInfo.getSourceChecksum()` to identify the source a page was
compiled from. Generated page classes no longer declare a `LAST_MODIFIED` constant.

**Removed setting.** `grails.gsp.reload.granularity` is gone. It existed to absorb rounding differences
between modification times, which comparing content does not need; setting it now has no effect.
`grails.gsp.reload.interval` is unchanged.

A GSP precompiled by Grails 7 and shipped inside a binary plugin carries no checksum. Such a page is served
normally; it is simply never reload-checked, which was already the case for views resolved out of a plugin
jar.

==== 50. Tag Libraries Are Described When Compiled

Tag calls are resolved against a description each tag library contributes as it is compiled, and a
resolved call is compiled into a direct invocation. The tag itself is still selected by name when the
call runs, through the same lookup dynamic dispatch uses, so a tag library that overrides another and
the order tag libraries are registered in behave as before. A call into a namespace no compiled tag
library declares — a tag library from a plugin built against an earlier version of Grails, or one
registered while the application runs — is left to dispatch exactly as it did.

Three things are worth knowing when upgrading.

A tag that no compiled tag library declares is left to resolve at runtime, and nothing is reported.
Setting `grails { compileStatic { strictTags = true } }` makes it a compilation error instead, which
is worth doing once to find misspelled tags, but is not the default because a namespace can
legitimately hold tag libraries that carry no description. Where an application registers tag
libraries while it runs, name their namespaces in
`grails { compileStatic { dynamicTagNamespaces = [...] } }` so that they are never checked.

Strict checking applies only where the source says a call is a tag: one naming its namespace, and one
written as markup. A call written without a namespace is never checked, and a namespaced expression in
a page is checked only when that page declares `compileStatic`.

A call written without a namespace is not compiled at all unless the build asks for it with
`grails { compileStatic { unqualifiedTagCalls = true } }`. Whether a bare name is a tag depends on what
else answers to it, and not all of that is visible when compiling — a method Groovy gives every object,
a delegate an enclosing closure is handed, an overload the tag library also declares — so by default
such a call is dispatched exactly as it was before this release.

Where it is turned on, a call written without a namespace reaches a tag only when nothing nearer
answers to the name: not a method of the class, not one it inherits, not a field, property or local,
not a method Groovy provides, and not a name inside a closure, whose delegate is only known when it
runs. A method added to a controller or tag library *while the application runs*, through a plugin's
`doWithDynamicMethods`, is not visible when the calling code is compiled, so a call that used to reach
such a method and shares its name with a tag would reach the tag instead. Declare the method on the
class, name the namespace in `dynamicTagNamespaces`, or call the tag with its namespace.

Naming a namespace in `dynamicTagNamespaces` turns rewriting off for it entirely, not just the
reporting: calls into it are dispatched exactly as they were before this release. That is the escape
hatch for a namespace whose tags are decided while the application runs.

Tags defined as closures now warn at compile time. They still work and are called the same way, but a
closure carries no signature, so nothing about a call to such a tag can be checked. This covers the
`def` form as well as the explicitly typed one, and the `def` form is the one most tag libraries are
written in:

[source,groovy]
----
// Before - both forms warn
def hello = { attrs ->
    out << "Hello ${attrs.name}"
}

Closure goodbye = { Map attrs ->
    out << "Goodbye ${attrs.name}"
}

// After
def hello(Map attrs) {
    out << "Hello ${attrs.name}"
}

def goodbye(Map attrs) {
    out << "Goodbye ${attrs.name}"
}
----

A tag taking a body becomes a method with a second `Closure body` parameter:

[source,groovy]
----
// Before
def wrapped = { attrs, body ->
    out << '<div>' << body() << '</div>'
}

// After
def wrapped(Map attrs, Closure body) {
    out << '<div>' << body() << '</div>'
}
----

===== Tags Are No Longer Installed Onto Metaclasses

Dispatching a tag no longer works by installing a method for every tag, and a property for every
namespace, onto the metaclass of every tag library, controller and page. Tags are resolved through the
tag library lookup instead. Calling a tag — from a page, a tag library or a controller, with or
without its namespace — is unaffected.

What changes is code that inspected the metaclass to find tags. A check such as

[source,groovy]
----
tagLib.metaClass.respondsTo(tagLib, 'someTag')
----

answered `true` before because the tag had been installed there, and now answers `false`. Call the tag,
or consult the tag library lookup, instead of asking the metaclass what it holds.

The methods that performed the installation are deprecated or removed:

[cols="2,3"]
|===
|Member |Replacement

|`NamespacedTagDispatcher.initializeMetaClass()`
|Removed. Nothing needs to be initialised.

|`NamespacedTagDispatcher.registerTagMetaMethods(ExpandoMetaClass)`
|Removed.

|`TemplateNamespacedTagDispatcher.registerTagMetaMethods(ExpandoMetaClass)`
|Removed.

|`GroovyPagesMetaUtils.registerMethodMissingForGSP(...)`
|Retained but does nothing. A page now declares `methodMissing` itself.

|`TagLibraryMetaUtils.enhanceTagLibMetaClass`, `registerTagMetaMethods`, `registerMethodMissingForTags`, `registerNamespaceMetaProperties`, `registerPropertyMissingForTag`, `addTagLibMethodToMetaClass`
|Deprecated. Unit test support still uses them so that a tag method can be called directly on a tag library under test.
|===

`TagLibraryMetaUtils.methodMissingForTagLib` is not deprecated — it is the dynamic dispatch path a call
into an undescribed namespace still takes.

===== A Method-Declared Tag Called Without a Namespace Returns Its Output

A class that can call tags but is not a tag library — a controller — reaches a tag written without a
namespace through `methodMissing` on the `TagLibraryInvoker` trait. That used to end in a direct call
on the tag library bean. For a tag declared as a closure this made no difference, because the call
landed on a generated wrapper that captured output anyway; for one declared as a method there was no
wrapper, so the method ran with nothing captured and its own return value came back.

Both forms now capture, so a method-declared tag returns what it wrote:

[source,groovy]
----
class ReportTagLib {
    static namespace = 'g'

    def summary(Map attrs) {
        out << 'the output'
        'the return value'      // <1>
    }
}

class ReportController {
    def index() {
        String result = summary(id: 1)   // 'the output', previously 'the return value'
    }
}
----
<1> a tag's return value is not what a caller receives; what it writes is

A tag called *with* its namespace, and any tag called from a GSP, already captured, so only an
unqualified call from a controller to a method-declared tag changes.

==== 51. Quartz Job Scheduling Behavior Changes

Three behaviors of the Quartz plugin changed in this release. All three concern what happens when
something about a schedule is wrong; none of them require a change to a job class.

===== 51.1 A trigger that can never fire no longer stops startup

A job whose trigger has no possible fire time — a cron expression whose last occurrence is in the past,
or an end time that has already passed — used to make the application fail to start, because the
scheduler rejects such a trigger:

----
org.quartz.SchedulerException: Based on configured schedule, the given trigger will never fire.
----

The plugin now reports the trigger as an error in the log and leaves it unscheduled, so the rest of the
application's jobs and triggers are scheduled and the application starts. The job itself is still
registered, so it can be triggered and rescheduled at runtime.

To go back to a failing startup, set `quartz.failOnNeverFiringTriggers`:

[source,yaml]
.application.yml
----
quartz:
    failOnNeverFiringTriggers: true
----

===== 51.2 An application only removes the jobs it registered itself

When an application starts, the plugin removes the jobs in the job store which are no longer enabled job
artefacts of the application. It used to consider *every* job in the store, which with a persistent job
store deleted the jobs and triggers of anything else using it — another application, or code that
schedules through the Quartz API.

Every job the plugin registers now carries the name of the application that registered it, taken from
`info.app.name` or from the application metadata when that is unset, and only jobs carrying that name are
removed. Two consequences for an application with `quartz.jdbcStore` enabled:

* **Two applications sharing a job store must not share an application name.** Instances of one
application do share it — that is what makes a clustered deployment clean up after itself — but two
different applications on the same Quartz tables each need their own `info.app.name`. An application which
does not name itself registers its jobs under the default name, which every unnamed application shares;
with `quartz.jdbcStore` enabled the plugin warns about that at startup.
* **A job written to the store by Grails 7 or earlier carries no application name**, so it is never
removed automatically. A job class you deleted while running an older version leaves a row behind; remove
it with `jobManagerService.removeJob(group, name)` or from the Quartz tables.

===== 51.3 Scheduling methods report what is wrong instead of failing with a null pointer

The static scheduling methods every job class gets — `schedule`, `reschedule`, `unschedule`,
`triggerNow`, `removeJob` — used to fail with a `NullPointerException` when called with a null argument,
or on a job the scheduler does not know about. They now raise:

* an `IllegalArgumentException` naming the argument that is null. Note that a null argument resolves to
`schedule(Trigger)` whichever method the caller meant, because the runtime type of `null` carries no
information, so the message points at the other arguments too.
* an `IllegalStateException` when the job is not registered with a scheduler — the plugin is disabled, the
job is disabled with `jobEnabled = false`, or the class is not a job artefact of the application.

An application that caught `NullPointerException` around these calls has to catch those instead. See
<<quartzDynamicScheduling,Dynamic Job Scheduling>>.
