You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/05/04 12:31:47 UTC
(grails-static-website) 05/10: Add grails-multi-module v8 dev-reload chapter + source-cited descriptor walkthrough
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch improve-v8-guides-pre-release
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git
commit 9f7e442e3a8beb87086ceddf28b2181913a66e1f
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Mon May 4 08:29:51 2026 -0400
Add grails-multi-module v8 dev-reload chapter + source-cited descriptor walkthrough
The most common day-to-day question for a multi-project Grails build is
"how do I save a file in shared-core and have my running webapp
bootRun pick it up?". Grails 8 answers this with a first-class plugin
- org.apache.grails.gradle.grails-exploded - that publishes exploded
classes/resources from a Grails Plugin subproject so the consuming
webapp's bootRun can request the exploded variant automatically.
Source: grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/exploded/GrailsExplodedPlugin.groovy.
Changes:
* New chapter devReload.adoc walks the supported recipe (apply
org.apache.grails.gradle.grails-exploded in shared-core, declare
the plugin under the grails { plugins { ... } } block in each
webapp), includes a what-reloads vs what-restarts table, and keeps
the manual bootRun classpath patch + spring.devtools.restart.additional-paths
approach as alternatives in NOTE blocks.
* runningEach.adoc forward-references the new chapter, documents
bootJar deployment, and clarifies that shared-core does not produce
a deployable bootJar (GrailsPluginGradlePlugin disables bootJar for
the plugin project).
* sharedCore.adoc expands the plugin descriptor explanation with
source citations: grails-core/src/main/groovy/grails/plugins/Plugin.groovy
for the lifecycle hooks; grails-core/src/main/groovy/org/grails/core/artefact/DomainClassArtefactHandler.java,
grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultMappingContextFactoryBean.groovy,
and grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovy
for cross-plugin domain auto-discovery.
* New shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy
snippet so sharedCore.adoc can include::../snippets/...[].
* snippets/build.gradle: collapse the dual content { } filter blocks
in the snapshots/staging Maven repos into a single block per repo
(separate blocks AND-combine, which silently disables the
repository - the intent is OR-combine), drop the redundant
plugins.withId('groovy') guard around useJUnitPlatform.
* snippets/settings.gradle: minor cleanup matching the chapter.
* All chapter prose picks up source-citation NOTE blocks and "Files
touched" bullets to match the established
grails-fields-custom-widgets-and-wrappers v8 style.
* conf/guides.yml: TOC entry for devReload between runningEach and
testingPerModule.
Assisted-by: claude-code:claude-4.6-opus
---
conf/guides.yml | 2 +
guides/grails-multi-module/v8/guide/devReload.adoc | 66 ++++++++++++++++++++++
.../v8/guide/extractingPlugin.adoc | 24 +++++---
.../grails-multi-module/v8/guide/failureModes.adoc | 17 +++---
.../v8/guide/gettingStarted.adoc | 10 +++-
.../v8/guide/helpWithGrails.adoc | 4 ++
.../grails-multi-module/v8/guide/requirements.adoc | 5 ++
guides/grails-multi-module/v8/guide/rootBuild.adoc | 22 ++++++--
.../grails-multi-module/v8/guide/runningEach.adoc | 23 +++++---
.../grails-multi-module/v8/guide/sharedCore.adoc | 34 +++++++++--
.../v8/guide/testingPerModule.adoc | 27 ++++++---
guides/grails-multi-module/v8/guide/webapps.adoc | 32 +++++++----
.../v8/guide/whatYouWillBuild.adoc | 13 +++--
.../grails-multi-module/v8/snippets/build.gradle | 18 ++----
.../v8/snippets/settings.gradle | 7 +--
.../groovy/example/SharedCoreGrailsPlugin.groovy | 29 ++++++++++
16 files changed, 255 insertions(+), 78 deletions(-)
diff --git a/conf/guides.yml b/conf/guides.yml
index f980ec6b30d..b1e98e996b9 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -3396,6 +3396,8 @@ guides:
title: The Two Webapps Consume shared-core
runningEach:
title: Running and Packaging Each Webapp
+ devReload:
+ title: Dev-Mode Auto-Reload Across Projects
testingPerModule:
title: Per-Module Testing
extractingPlugin:
diff --git a/guides/grails-multi-module/v8/guide/devReload.adoc b/guides/grails-multi-module/v8/guide/devReload.adoc
new file mode 100644
index 00000000000..d44e67fbfc9
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/devReload.adoc
@@ -0,0 +1,66 @@
+Grails 8 already ships a supported cross-project reload path for plugin subprojects. You do not need the manual `bootRun` classpath patch as the primary recipe.
+
+The supported setup has two moving parts:
+
+* the plugin project publishes exploded classes and resources by applying `org.apache.grails.gradle.grails-exploded`
+* the webapp consumes the plugin through `grails { plugins { ... } }`, which lets `bootRun` request that exploded variant automatically
+
+In `shared-core/build.gradle`, add the exploded plugin:
+
+[source,groovy]
+----
+apply plugin: 'org.apache.grails.gradle.grails-exploded'
+----
+
+In each webapp `build.gradle`, keep the plugin dependency inside the `grails` block:
+
+[source,groovy]
+----
+grails {
+ plugins {
+ implementation project(':shared-core')
+ }
+}
+----
+
+Those two pieces line up exactly with:
+
+* `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/exploded/GrailsExplodedPlugin.groovy`
+* `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/PluginDefiner.groovy`
+* `grails-doc/src/en/guide/plugins/creatingAndInstallingPlugins.adoc`
+
+Now keep `shared-core` compiled in one shell and run whichever webapps you need in the others:
+
+[source,bash]
+----
+./gradlew :shared-core:classes --continuous # shell 1
+./gradlew :webapp-customer:bootRun # shell 2
+./gradlew :webapp-admin:bootRun --args='--server.port=8081' # shell 3
+----
+
+Save a file under `shared-core/grails-app/services/` or `shared-core/src/main/groovy/`, shell 1 recompiles it, and the running webapp restarts from the exploded output directories. No manual classpath surgery is required.
+
+What reloads cleanly versus what still needs a full restart:
+
+[cols="3,1,4"]
+|===
+| Change | Reloads? | Reason
+
+| Service or controller method body | yes | DevTools restart picks up the new bean classes
+| GSP under `shared-core/grails-app/views/` | yes | The view layer is resolved from plugin resources during development
+| Static asset | yes | Served from source/build outputs in dev mode
+| Add or change a domain class field | no | Hibernate metadata is built once at startup
+| Edit `application.yml` | no | Configuration is read at startup
+| Edit `*GrailsPlugin.groovy` | no | Plugin lifecycle hooks run during startup
+|===
+
+[NOTE]
+====
+The older manual `bootRun { doFirst { classpath = ... } }` patch can still work because `bootRun` ultimately runs with a mutable classpath. But on Grails 8 it is now a fallback for unusual cases, not the guide's primary recipe, because the framework already includes a first-class exploded-plugin mechanism.
+====
+
+Files touched:
+
+- shared-core/build.gradle
+- webapp-customer/build.gradle
+- webapp-admin/build.gradle
diff --git a/guides/grails-multi-module/v8/guide/extractingPlugin.adoc b/guides/grails-multi-module/v8/guide/extractingPlugin.adoc
index 3e966c508a9..bb9fef24991 100644
--- a/guides/grails-multi-module/v8/guide/extractingPlugin.adoc
+++ b/guides/grails-multi-module/v8/guide/extractingPlugin.adoc
@@ -1,10 +1,18 @@
-Most multi-module layouts grow out of a monolith that already exists. The migration from "one Grails app with two areas" to "one shared plugin plus two webapps" is mechanical:
+Most multi-module workspaces start from a monolith that already exists. The split is mechanical if you keep the ownership lines clear.
-1. *Decide what the plugin owns.* Domain classes, GORM data services, security policy beans, and any tag library that emits content the customer and admin pages both render. Anything UI-shaped (controllers, GSPs, JSON views) stays in the consuming webapp.
-2. *Generate a Grails 8 plugin* (the forge `web_plugin` type) into a new `shared-core/` directory next to the existing app.
-3. *Move (`git mv`) the chosen files* out of `app/grails-app/...` into `shared-core/grails-app/...`. Keep package names. The history follows the rename.
-4. *In `settings.gradle` of the existing app, ``include 'shared-core'``.* In the existing app's `build.gradle`, add `implementation project(':shared-core')`. Run `./gradlew build` - it should still pass.
-5. *Generate the second webapp* (the forge `web` type) into `webapp-customer/`. Add `implementation project(':shared-core')` to its `build.gradle`. Move whichever controllers/views logically belong there from the original app.
-6. *Rename the original app* to `webapp-admin/` (or whichever role it ended up playing).
+1. Generate `shared-core` as a Grails `web_plugin`, not as a plain library project.
+2. Move only genuinely shared code into the plugin: domain classes, GORM data services, shared taglibs, and reusable Spring beans. Leave `Application.groovy`, `UrlMappings.groovy`, controllers, GSPs, JSON views, and `BootStrap` classes in the webapps.
+3. Keep or recreate the plugin descriptor class (`SharedCoreGrailsPlugin.groovy`). If the extracted code needs plugin ordering or watched resources, express that there using the normal `Plugin` conventions.
+4. Wire the original app to the plugin with `grails { plugins { implementation project(':shared-core') } }`. If you want live reload while the split is in flight, also apply `org.apache.grails.gradle.grails-exploded` in `shared-core/build.gradle`.
+5. Create the second webapp, wire the same plugin, and move the customer-facing or admin-facing controllers and views into their target module.
+6. Choose one webapp to own database migrations and startup data changes.
-The third commit on the timeline is a single commit that moves files; the fourth is the wiring change. Reviewers can see exactly what moved without unrelated diffs.
+Keep package names stable while you move files. `git mv` plus unchanged packages makes the review readable and keeps history attached to the files that actually moved.
+
+Files touched:
+
+- settings.gradle
+- shared-core/build.gradle
+- shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy
+- webapp-customer/build.gradle
+- webapp-admin/build.gradle
diff --git a/guides/grails-multi-module/v8/guide/failureModes.adoc b/guides/grails-multi-module/v8/guide/failureModes.adoc
index fc433f0913d..2dd0c919213 100644
--- a/guides/grails-multi-module/v8/guide/failureModes.adoc
+++ b/guides/grails-multi-module/v8/guide/failureModes.adoc
@@ -1,12 +1,13 @@
-Three failure modes show up in real multi-module Grails layouts. Recognise them early, address them once.
+Three failure modes show up again and again in real Grails multi-module workspaces.
-* *Circular plugin dependencies.* If `shared-core` reaches "up" into either webapp - e.g. via a `MessageSource` lookup that depends on a webapp's `i18n/` - the build still passes locally (because the consumer is on the classpath at run time) but breaks the moment you try to publish `shared-core` to a Maven repo. Fix the direction: the plugin should never know about its consumers.
+* *Circular module dependencies, disguised as plugin wiring.* The `dependsOn` and `loadAfter` properties on a Grails plugin descriptor are runtime ordering hints from `grails-core/src/main/groovy/grails/plugins/Plugin.groovy`. They do not replace Gradle project dependencies. If a webapp depends on `shared-core`, then `shared-core` must not depend back on that webapp. Keep the dependency arrow one-way: webapps depend on the plugin.
-* *GORM mapping conflicts when the same domain class loads twice.* A common mistake: copying a domain class from `shared-core` into `webapp-admin` "just to add an admin-only field". GORM sees two `example.Book` classes on the classpath, registers both, and either silently picks one or fails at startup. The fix is always the same: the field belongs on `shared-core.Book`, with `nullable: true` and an admin-only setter.
+* *Mapping collisions in the Hibernate model.* Grails 8 collects every domain artefact into the mapping context and Hibernate bootstrap path. You can see that in `grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultMappingContextFactoryBean.groovy` and `grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovy`. If the plugin and a webapp both define separate entities that map to the same table or incompatible assoc [...]
-* *Two webapps disagree on the schema migration order.* If both webapps run Liquibase changesets at startup, one of them will be racing the other on first deploy. Pick one - typically `webapp-admin` - to own migrations; the other (`webapp-customer`) reads the migrated schema with `dbCreate: none`. Document the discipline; do not rely on lock acquisition from Liquibase for cross-app coordination.
+* *Both webapps try to run startup migrations.* `grails-data-hibernate5/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/DatabaseMigrationGrailsPlugin.groovy` runs Liquibase updates at startup when `grails.plugin.databasemigration.updateOnStart*` flags are enabled. Liquibase locking prevents corruption, but it still leaves startup order and deploy timing to chance. Pick one app to own migrations. Let the other app boot against an already-migrated schema.
-[NOTE]
-====
-*Cross-repo iteration.* If `shared-core` later moves to its own GitHub repository, `includeBuild('../shared-core')` in the webapps' `settings.gradle` still gives you live cross-iteration during development - the consumer compiles against the live source tree, not a published JAR.
-====
+Files touched:
+
+- shared-core/build.gradle
+- webapp-customer/build.gradle
+- webapp-admin/build.gradle
diff --git a/guides/grails-multi-module/v8/guide/gettingStarted.adoc b/guides/grails-multi-module/v8/guide/gettingStarted.adoc
index 49ea6c91987..2039238614e 100644
--- a/guides/grails-multi-module/v8/guide/gettingStarted.adoc
+++ b/guides/grails-multi-module/v8/guide/gettingStarted.adoc
@@ -1,5 +1,9 @@
-In this guide you will lay out a real-world Grails 8 multi-project build: one shared Grails Plugin (`shared-core`) holding the domain model, GORM services, and security configuration, plus two separate Grails web apps (`webapp-customer` and `webapp-admin`) that consume the plugin.
+In this guide you will assemble a Grails 8 multi-project workspace with three modules: one shared Grails plugin (`shared-core`) and two Grails web applications (`webapp-customer`, `webapp-admin`).
-This is the canonical shape for products with a customer-facing UI and an admin/back-office UI: the two apps share their data model exactly but differ in screens, authorization rules, and deployment cadence. Each webapp builds its own `bootJar` with the plugin embedded, so the two apps deploy independently while always staying in sync on the data model.
+The shared module owns the domain model, GORM data services, and plugin-level configuration. The two webapps own their own controllers, views, and deployment lifecycle, but both run against the same compiled plugin jar in production and the same exploded plugin classes in development.
-This guide targets Apache Grails 8 / JDK 21.
+This is the layout Grails 8 is built to support: plugin code lives in a plain jar, webapps build their own `bootJar`, and Grails plus Hibernate discover domain artefacts from the plugin at runtime.
+
+Files touched:
+
+- None yet
diff --git a/guides/grails-multi-module/v8/guide/helpWithGrails.adoc b/guides/grails-multi-module/v8/guide/helpWithGrails.adoc
index e062f614b1a..d61a37c2144 100644
--- a/guides/grails-multi-module/v8/guide/helpWithGrails.adoc
+++ b/guides/grails-multi-module/v8/guide/helpWithGrails.adoc
@@ -1 +1,5 @@
include::{commondir}/common-helpWithGrails.adoc[]
+
+Files touched:
+
+- None
diff --git a/guides/grails-multi-module/v8/guide/requirements.adoc b/guides/grails-multi-module/v8/guide/requirements.adoc
index 53a90b001e6..f1dc76059cc 100644
--- a/guides/grails-multi-module/v8/guide/requirements.adoc
+++ b/guides/grails-multi-module/v8/guide/requirements.adoc
@@ -1,4 +1,9 @@
To complete this guide you will need:
* JDK 21
+* Internet access to download three starters from link:https://start.grails.org[start.grails.org]
* About 45 minutes
+
+Files touched:
+
+- None yet
diff --git a/guides/grails-multi-module/v8/guide/rootBuild.adoc b/guides/grails-multi-module/v8/guide/rootBuild.adoc
index 48c38528536..96f82bc7d73 100644
--- a/guides/grails-multi-module/v8/guide/rootBuild.adoc
+++ b/guides/grails-multi-module/v8/guide/rootBuild.adoc
@@ -1,4 +1,4 @@
-A root `settings.gradle` and a root `build.gradle` turn three side-by-side Grails projects into one Gradle build:
+A root `settings.gradle` and a root `build.gradle` turn the three generated starters into one Gradle build:
[source,groovy]
.settings.gradle
@@ -12,10 +12,20 @@ include::../snippets/settings.gradle[]
include::../snippets/build.gradle[]
----
-Three patterns to point at:
+This root pattern intentionally mirrors two Grails 8 authority sources:
-* `rootProject.name = 'grails-multi-module'` is what `./gradlew projects` shows at the top of the tree. Without it, Gradle uses the directory name, which is fine for this sample but may not match your repo name in a real layout.
-* The `subprojects { repositories { ... } }` block is where every module's Apache Grails snapshot + staging Maven repos live. Avoid the temptation to also put `dependencies { ... }` here - you want each module to declare what it needs explicitly.
-* `plugins.withId('groovy') { tasks.withType(Test).configureEach { useJUnitPlatform() } }` ensures the JUnit 5 runner is used in every module that loads the Groovy plugin (which is every Grails module). Without it, only the Grails 8 starter's own build.gradle would have it; the plugin module would default to the Vintage runner.
+* `grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/gradle/GradleRepository.java` for the default starter repositories.
+* `grails-profiles/base/skeleton/build.gradle` plus `grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.raw` for the per-project `tasks.withType(Test).configureEach { useJUnitPlatform() }` block.
-The per-module `build.gradle` files keep their dependency lists. Sharing too aggressively at root level (e.g. `dependencies { implementation '...' }` inside `subprojects`) creates surprise transitive dependencies that are hard to debug.
+Three details matter:
+
+* `rootProject.name = 'grails-multi-module'` gives the whole workspace a stable Gradle identity.
+* `subprojects { repositories { ... } }` centralises the same Maven repos every generated Grails 8 module would otherwise repeat. On the live `8.0.x` line that includes filtered Apache snapshot and staging repos, because the current forge default still adds them for snapshot Grails builds.
+* `tasks.withType(Test).configureEach { useJUnitPlatform() }` is the exact Grails 8 starter pattern. Lifting that block to the root keeps `test` and `integrationTest` aligned across all three modules without changing their dependency lists.
+
+Do not move ordinary library dependencies to the root. The generated module builds already know which Grails plugins and starters they need. This root file should only carry cross-cutting build policy.
+
+Files touched:
+
+- settings.gradle
+- build.gradle
diff --git a/guides/grails-multi-module/v8/guide/runningEach.adoc b/guides/grails-multi-module/v8/guide/runningEach.adoc
index 1b0561c883f..a7ac7ec12c1 100644
--- a/guides/grails-multi-module/v8/guide/runningEach.adoc
+++ b/guides/grails-multi-module/v8/guide/runningEach.adoc
@@ -4,22 +4,29 @@ Run each webapp independently:
----
./gradlew :webapp-customer:bootRun
# In another shell:
-./gradlew :webapp-admin:bootRun
+./gradlew :webapp-admin:bootRun --args='--server.port=8081'
----
-Both default to port 8080, so you cannot run them at the same time without overrides. For dev work you have two cheap options:
+Both generated webapps default to port 8080. Grails 8 documents two accurate override styles:
-* `./gradlew :webapp-admin:bootRun --args='--server.port=8081'` - one-shot port override.
-* Set `server.port: 8081` in `webapp-admin/grails-app/conf/application.yml` so the admin app permanently uses 8081 in dev. The customer app keeps 8080.
+* `--args='--server.port=8081'` in `grails-doc/src/en/ref/Command Line/bootRun.adoc`
+* `-Dgrails.server.port=8081` in `grails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc`
-Production deployment uses `bootJar` instead of `bootRun`:
+Use either form, but keep it consistent across your team.
+
+Production packaging uses `bootJar` for the two webapps and `jar` for the plugin:
[source,bash]
----
+./gradlew :shared-core:jar
./gradlew :webapp-customer:bootJar
./gradlew :webapp-admin:bootJar
-ls webapp-customer/build/libs/ # webapp-customer-0.1.jar
-ls webapp-admin/build/libs/ # webapp-admin-0.1.jar
----
-Each `bootJar` embeds the `shared-core` plugin classes (and only the *transitive* runtime dependencies the plugin actually needs). The two jars deploy independently to different hosts, different containers, different release cadences. The data model they share is the byte-for-byte identical compiled `Book.class` produced once by the `shared-core:compileGroovy` task and packaged into both.
+`shared-core` does not produce a deployable `bootJar`. `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsPluginGradlePlugin.groovy` disables `bootJar` and keeps the plain jar as the plugin artifact. The two webapps are the only deployable applications in this workspace.
+
+For the day-to-day inner loop where you save a file in `shared-core` and want a running `bootRun` to pick it up, the next chapter covers the Grails 8 supported reload path.
+
+Files touched:
+
+- None, unless you choose a fixed port in `webapp-admin/grails-app/conf/application.yml`
diff --git a/guides/grails-multi-module/v8/guide/sharedCore.adoc b/guides/grails-multi-module/v8/guide/sharedCore.adoc
index 1d694352317..c48b0b9eb76 100644
--- a/guides/grails-multi-module/v8/guide/sharedCore.adoc
+++ b/guides/grails-multi-module/v8/guide/sharedCore.adoc
@@ -1,4 +1,16 @@
-The plugin module holds the domain model and the GORM data services. A simple Book domain in `shared-core/grails-app/domain/example/Book.groovy`:
+`shared-core` is a Grails web plugin, not a plain Groovy library. Keep the generated plugin descriptor class:
+
+[source,groovy]
+.shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy
+----
+include::../snippets/shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy[]
+----
+
+That class name and location are conventions, not decoration. Grails discovers plugins from a class under `src/main/groovy/<package>/` whose name ends in `GrailsPlugin` and whose base class is `grails.plugins.Plugin`. The lifecycle hooks available to you - `doWithSpring`, `doWithApplicationContext`, `doWithDynamicMethods`, `onChange`, `onConfigChange`, and `onShutdown` - come from `grails-core/src/main/groovy/grails/plugins/Plugin.groovy`.
+
+Core Grails plugins use additional descriptor conventions as plain properties on that class: `profiles`, `watchedResources`, `observe`, `loadAfter`, and `pluginExcludes`. See `grails-domain-class/src/main/groovy/org/grails/plugins/domain/DomainClassGrailsPlugin.groovy` and `grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovy` for live Grails 8 examples.
+
+The plugin module then owns the shared domain model and GORM data services. Start with a simple `Book` domain:
[source,groovy]
.shared-core/grails-app/domain/example/Book.groovy
@@ -6,7 +18,7 @@ The plugin module holds the domain model and the GORM data services. A simple Bo
include::../snippets/shared-core/grails-app/domain/example/Book.groovy[]
----
-And a GORM data service that both webapps consume:
+And a GORM data service that both webapps inject:
[source,groovy]
.shared-core/grails-app/services/example/BookService.groovy
@@ -14,9 +26,19 @@ And a GORM data service that both webapps consume:
include::../snippets/shared-core/grails-app/services/example/BookService.groovy[]
----
-Two things to highlight:
+The domain auto-discovery story in Grails 8 is now easy to verify in source:
+
+* `grails-core/src/main/groovy/org/grails/core/artefact/DomainClassArtefactHandler.java` recognises domain artefacts from `grails-app/domain` and `@Entity`.
+* `grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultMappingContextFactoryBean.groovy` adds every `DomainClassArtefactHandler.TYPE` artefact to the mapping context.
+* `grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovy` collects those same artefacts from `grailsApplication.getArtefacts(...)` and hands them to `HibernateDatastoreSpringInitializer`.
+
+That is why a sibling plugin's `Book` domain becomes part of the consuming webapp's Hibernate model without any manual registration step.
+
+Keep `apply plugin: 'org.apache.grails.gradle.grails-plugin'` in `shared-core/build.gradle`. In `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsPluginGradlePlugin.groovy` that Gradle plugin applies `java-library`, disables `bootJar` for the plugin project, packages a plain jar, and prepares plugin resources. If you remove it, `shared-core` stops behaving like a Grails plugin build.
-* The `@Service(Book)` interface is auto-implemented by GORM at compile time. The webapps inject `BookService bookService` directly; there is no `BookServiceImpl` to write.
-* The package is `example`, not `shared` or `core`. This is the *domain* package - it is what the data layer is "about", not what module owns it. The two webapps' UI controllers will live in `customer.*` and `admin.*` so the package separation makes the layering explicit.
+Files touched:
-The plugin's `build.gradle` should already contain `apply plugin: 'org.apache.grails.gradle.grails-plugin'` (the forge generates this for `web_plugin`). That is what registers the plugin's domain classes and services for GORM auto-discovery in any consumer.
+- shared-core/build.gradle
+- shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy
+- shared-core/grails-app/domain/example/Book.groovy
+- shared-core/grails-app/services/example/BookService.groovy
diff --git a/guides/grails-multi-module/v8/guide/testingPerModule.adoc b/guides/grails-multi-module/v8/guide/testingPerModule.adoc
index c3df0bbb678..8e9fc508085 100644
--- a/guides/grails-multi-module/v8/guide/testingPerModule.adoc
+++ b/guides/grails-multi-module/v8/guide/testingPerModule.adoc
@@ -1,23 +1,34 @@
Tests live with the module they cover.
-* `shared-core` has its own integration suite (`shared-core/src/integration-test/`) that runs against an in-memory H2 datasource and exercises the domain constraints + GORM data service queries directly. No webapp boots.
-* Each webapp has its own functional suite (`webapp-customer/src/integration-test/`, `webapp-admin/src/integration-test/`) that boots the corresponding `bootJar` and drives it with Geb specs.
-* Unit tests (Spock-based, no Spring context) live in each module's `src/test/`.
+* `shared-core` keeps its own unit and integration tests focused on the domain model and GORM data services.
+* `webapp-customer` keeps its own web and functional tests for the customer-facing UI.
+* `webapp-admin` keeps its own CRUD and admin-side functional tests.
Run them granularly during development:
[source,bash]
----
-./gradlew :shared-core:integrationTest
-./gradlew :webapp-customer:integrationTest
-./gradlew :webapp-admin:integrationTest
+./gradlew :shared-core:test :shared-core:integrationTest
+./gradlew :webapp-customer:test :webapp-customer:integrationTest
+./gradlew :webapp-admin:test :webapp-admin:integrationTest
----
-Or fan out the whole test suite from the root:
+Or fan out the whole suite from the workspace root:
[source,bash]
----
./gradlew test integrationTest
----
-The matching link:../grails-github-actions-cicd/8/guide/index.html[GitHub Actions CI/CD guide] shows how to model these as separate jobs in a single workflow so a failing functional test in `webapp-admin` does not stop the `shared-core` suite from reporting its result.
+Because the root `build.gradle` applies `tasks.withType(Test).configureEach { useJUnitPlatform() }`, every `Test` task in the workspace follows the same Grails 8 starter pattern as `grails-profiles/base/skeleton/build.gradle`.
+
+The matching link:../grails-github-actions-cicd/8/guide/index.html[GitHub Actions CI/CD guide] shows how to split these module suites into separate jobs so a failing admin functional test does not hide a passing `shared-core` result.
+
+Files touched:
+
+- shared-core/src/test/groovy/**
+- shared-core/src/integration-test/groovy/**
+- webapp-customer/src/test/groovy/**
+- webapp-customer/src/integration-test/groovy/**
+- webapp-admin/src/test/groovy/**
+- webapp-admin/src/integration-test/groovy/**
diff --git a/guides/grails-multi-module/v8/guide/webapps.adoc b/guides/grails-multi-module/v8/guide/webapps.adoc
index 156bd7c7050..460203b7e5b 100644
--- a/guides/grails-multi-module/v8/guide/webapps.adoc
+++ b/guides/grails-multi-module/v8/guide/webapps.adoc
@@ -1,13 +1,20 @@
-Both webapps consume `shared-core` via a single line in their `dependencies` block:
+Each webapp should consume `shared-core` through the Grails plugin block in its `build.gradle`, not through a plain `dependencies { ... }` entry:
[source,groovy]
+.webapp-customer/build.gradle and webapp-admin/build.gradle
----
-implementation project(':shared-core')
+grails {
+ plugins {
+ implementation project(':shared-core')
+ }
+}
----
-That line goes immediately under `profile "org.apache.grails.profiles:web"` in each webapp's `build.gradle`. Once it is in place, the plugin's `Book` domain and `BookService` data service are visible to the consumer's controllers, services, and GSPs at compile time, *and* GORM treats the consumer's persistent context as the place those entities live at runtime.
+That is the Grails-aware multi-project path documented in `grails-doc/src/en/guide/plugins/creatingAndInstallingPlugins.adoc`. Under the hood, `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/PluginDefiner.groovy` tags project plugin dependencies so `bootRun` can request the exploded plugin variant, while `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/exploded/GrailsExplodedPlugin.groovy` publishes that exploded classes/resources variant from the plug [...]
-Each webapp owns its own controller package. The customer webapp uses `customer.*`:
+Once wired, the plugin's `example.Book` domain and `example.BookService` data service are available to both webapps at compile time and runtime.
+
+The customer webapp owns its own controller package:
[source,groovy]
.webapp-customer/grails-app/controllers/customer/CatalogController.groovy
@@ -15,7 +22,7 @@ Each webapp owns its own controller package. The customer webapp uses `customer.
include::../snippets/webapp-customer/grails-app/controllers/customer/CatalogController.groovy[]
----
-The admin webapp uses `admin.*`:
+The admin webapp owns a different controller package:
[source,groovy]
.webapp-admin/grails-app/controllers/admin/BookController.groovy
@@ -23,10 +30,15 @@ The admin webapp uses `admin.*`:
include::../snippets/webapp-admin/grails-app/controllers/admin/BookController.groovy[]
----
-Three patterns to highlight:
+Three patterns to keep:
+
+* Both controllers import `example.Book` and `example.BookService` from the plugin. The shared data model still has exactly one owner.
+* The customer-side `CatalogController` is intentionally hand-rolled and read-only.
+* The admin-side `BookController` extends `RestfulController<Book>` and gets the CRUD-oriented web surface that belongs only in the admin app.
-* Both controllers reference `example.Book` and `example.BookService` from the plugin. Neither one duplicates a single line of domain or service code.
-* The customer-side `CatalogController` is hand-rolled; it deliberately does not extend `RestfulController`. Customers should not see `/v1/books` (POST/PUT/DELETE).
-* The admin-side `BookController` extends `RestfulController<Book>`. The full CRUD surface comes for free; the admin webapp picks it up by reference.
+Files touched:
-The package separation (`customer.*` vs `admin.*` for controllers, `example.*` for domain/service) is not enforced by Grails; it is a convention. But it is the convention that keeps refactors honest: a piece of code that lives under `example.*` belongs to the data layer, and any UI concern that creeps in there (a controller, a tag library, a flash-message string) is by definition misplaced.
+- webapp-customer/build.gradle
+- webapp-admin/build.gradle
+- webapp-customer/grails-app/controllers/customer/CatalogController.groovy
+- webapp-admin/grails-app/controllers/admin/BookController.groovy
diff --git a/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc b/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc
index 7c6825fb645..ff24d375b92 100644
--- a/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc
+++ b/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc
@@ -5,19 +5,24 @@ By the end of the guide your workspace will look like:
grails-multi-module/
|-- settings.gradle
|-- build.gradle
-|-- shared-core/ # Grails Plugin
+|-- shared-core/ # Grails web plugin, packaged as a plain jar
| |-- build.gradle
+| |-- src/main/groovy/example/SharedCoreGrailsPlugin.groovy
| `-- grails-app/
| |-- domain/example/Book.groovy
| `-- services/example/BookService.groovy
|-- webapp-customer/ # Grails web app (read-only catalog)
-| |-- build.gradle # `implementation project(':shared-core')`
+| |-- build.gradle # `grails { plugins { implementation project(':shared-core') } }`
| `-- grails-app/
| `-- controllers/customer/CatalogController.groovy
`-- webapp-admin/ # Grails web app (CRUD)
- |-- build.gradle # `implementation project(':shared-core')`
+ |-- build.gradle # `grails { plugins { implementation project(':shared-core') } }`
`-- grails-app/
`-- controllers/admin/BookController.groovy
----
-Three modules, one root, two webapps that build to two `bootJar`s, one plugin every consumer reuses. Each webapp can be `bootRun` independently; both can be built together with `./gradlew build`.
+Three modules, one root, two deployable `bootJar`s, and one shared plugin jar. `shared-core` is not a third application. It is the reusable Grails plugin that both webapps compile against, package, and reload from during development.
+
+Files touched:
+
+- None yet
diff --git a/guides/grails-multi-module/v8/snippets/build.gradle b/guides/grails-multi-module/v8/snippets/build.gradle
index 49533f2b59b..2c72ef8d3a6 100644
--- a/guides/grails-multi-module/v8/snippets/build.gradle
+++ b/guides/grails-multi-module/v8/snippets/build.gradle
@@ -1,9 +1,7 @@
/*
- * Root build script. Per-module build.gradle files keep their own
- * dependency lists; this root file only carries cross-cutting concerns:
- * the JDK release level, the plain-old `useJUnitPlatform()` for every
- * Test task, and the same Apache Grails snapshot repositories every
- * module needs.
+ * Root build script.
+ * Keep per-module dependencies in the generated Grails projects.
+ * This file only centralizes repositories and JUnit Platform.
*/
subprojects {
@@ -14,8 +12,6 @@ subprojects {
url = 'https://repository.apache.org/content/groups/snapshots'
content {
includeVersionByRegex('org[.]apache[.]grails.*', '.*', '.*-SNAPSHOT')
- }
- content {
includeVersionByRegex('org[.]apache[.]groovy.*', 'groovy.*', '.*-SNAPSHOT')
}
mavenContent { snapshotsOnly() }
@@ -24,17 +20,13 @@ subprojects {
url = 'https://repository.apache.org/content/groups/staging'
content {
includeVersionByRegex('org[.]apache[.]grails[.]gradle', 'grails-publish', '.*')
- }
- content {
includeVersionByRegex('org[.]apache[.]groovy.*', 'groovy.*', '.*')
}
mavenContent { releasesOnly() }
}
}
- plugins.withId('groovy') {
- tasks.withType(Test).configureEach {
- useJUnitPlatform()
- }
+ tasks.withType(Test).configureEach {
+ useJUnitPlatform()
}
}
diff --git a/guides/grails-multi-module/v8/snippets/settings.gradle b/guides/grails-multi-module/v8/snippets/settings.gradle
index a8cfc8f8639..ba025707f0e 100644
--- a/guides/grails-multi-module/v8/snippets/settings.gradle
+++ b/guides/grails-multi-module/v8/snippets/settings.gradle
@@ -2,13 +2,12 @@
* Multi-project root settings file.
*
* Three modules participate:
- * shared-core : Grails Plugin holding the domain model, GORM services,
- * and security-config bits the two webapps share.
+ * shared-core : Grails plugin holding the domain model and GORM services.
* webapp-customer : customer-facing Grails web app (read-only catalog).
* webapp-admin : back-office Grails web app with full CRUD.
*
- * Both webapps depend on shared-core via `implementation project(':shared-core')`.
- * GORM picks up the plugin's domain classes automatically.
+ * Both webapps consume shared-core via their `grails { plugins { ... } }` block
+ * so bootRun can request the exploded plugin variant in development.
*/
rootProject.name = 'grails-multi-module'
diff --git a/guides/grails-multi-module/v8/snippets/shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy b/guides/grails-multi-module/v8/snippets/shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy
new file mode 100644
index 00000000000..24c0b63321b
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy
@@ -0,0 +1,29 @@
+package example
+
+import grails.plugins.Plugin
+
+class SharedCoreGrailsPlugin extends Plugin {
+
+ def profiles = ['web']
+
+ Closure doWithSpring() {
+ { ->
+ // Optional shared beans go here.
+ }
+ }
+
+ void doWithDynamicMethods() {
+ }
+
+ void doWithApplicationContext() {
+ }
+
+ void onChange(Map<String, Object> event) {
+ }
+
+ void onConfigChange(Map<String, Object> event) {
+ }
+
+ void onShutdown(Map<String, Object> event) {
+ }
+}