////
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
----

If you need to restore the legacy behavior temporarily, the deprecated marshallers remain available but will be removed in a future release.

==== 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 is temporarily unsupported.**
Undertow has not yet been updated for Servlet 6.1, which Spring Boot 4 requires.
As a result, Undertow is **not** an available embedded servlet container in Grails 8.
Applications that previously used `spring-boot-starter-undertow` must switch to Tomcat or Jetty until Undertow ships Servlet 6.1 compatibility.
The Grails Forge generator no longer offers Undertow as a selectable servlet implementation, and explicitly selecting it (for example, `--servlet=undertow` on the API/CLI) fails fast with `IllegalArgumentException` and a clear error message.

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

==== 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

Some third-party plugins have not yet been updated for Spring Boot 4 / Spring Framework 7 compatibility.
The following are known blockers at this time:

* **grails-spring-security** - Uses `ReflectionUtils.getApplication()` which was removed in Spring Boot 4.
Integration tests for modules depending on Spring Security are disabled until the plugin is updated.

* **SiteMesh 3** - The decorator/layout mechanism is not compatible with Spring Framework 7.
Applications using `grails-sitemesh3` should remain on the `grails-layout` plugin (SiteMesh 2.6.x) until SiteMesh 3 is updated.

Check the https://github.com/apache/grails-core/issues[Grails issue tracker] for the latest status of plugin compatibility.

==== 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 is a *validation-layer* change only.
Column/DDL nullability is governed separately by the mapping layer and is unaffected.
Command objects are also unaffected: `Validateable` command-object fields remain required 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)
}
----

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

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

==== 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. `Holders.grailsApplication` is available earlier than before.
* **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.

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