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/03 20:10:02 UTC

(grails-static-website) 01/01: Add grails-multi-module v8 guide

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch add-grails-multi-module-v8
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit 9a44ae9eeb401a9983bd5feaa3c3f6f8cb173e62
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Sun May 3 16:09:54 2026 -0400

    Add grails-multi-module v8 guide
    
    Real-world Grails 8 multi-project layout: one shared-core Grails Plugin
    (domain model + GORM data services), two web apps (customer + admin)
    that depend on it via implementation project(':shared-core') and build
    to independent bootJars sharing the same data model.
    
    Companion conf/guides.yml change repoints the existing v4 entry from
    grails-guides/grails-multi-project-build to the freshly-renamed
    grails-guides/grails-multi-module. The original master branch (and
    forensic/grails4 etc.) is preserved on the renamed repo.
    
    The 12 chapters cover:
    
    - Getting Started (whatYouWillBuild, requirements, howto)
    - Generate the Three Module Starters (web_plugin + 2 web)
    - Root settings.gradle and build.gradle (subprojects { repositories,
      useJUnitPlatform } pattern)
    - The shared-core Plugin (Book domain + @Service(Book) BookService data
      service)
    - The Two Webapps Consume shared-core (single-line dependency, package
      separation customer.* / admin.* / example.*)
    - Running and Packaging Each Webapp (independent bootRun + bootJar)
    - Per-Module Testing (each module owns its own test suite)
    - Extracting a Plugin From a Monolith (six-step migration recipe)
    - Failure Modes (circular deps, GORM mapping conflicts, schema
      migration coordination)
    
    Verified locally:
    
      ./gradlew validateGuides -PvalidationMode=both
        [validateGuides] mode=both: 86 guide(s) parsed, 0 SKIP-warned, 0 errors
    
      ./gradlew renderGuide_grails_multi_module_8 --rerun-tasks --no-configuration-cache
        Renders all 12 chapter HTML pages, no Unresolved directive errors.
    
    The mode=both pass also confirms the renamed repo + branch resolve.
    
    Assisted-by: claude-code:claude-opus-4-7
---
 conf/guides.yml                                    | 48 +++++++++++++++++++++-
 .../v8/guide/extractingPlugin.adoc                 | 10 +++++
 .../grails-multi-module/v8/guide/failureModes.adoc | 12 ++++++
 .../v8/guide/generateModules.adoc                  | 22 ++++++++++
 .../v8/guide/gettingStarted.adoc                   |  5 +++
 .../v8/guide/helpWithGrails.adoc                   |  1 +
 guides/grails-multi-module/v8/guide/howto.adoc     | 15 +++++++
 .../grails-multi-module/v8/guide/requirements.adoc |  4 ++
 guides/grails-multi-module/v8/guide/rootBuild.adoc | 21 ++++++++++
 .../grails-multi-module/v8/guide/runningEach.adoc  | 25 +++++++++++
 .../grails-multi-module/v8/guide/sharedCore.adoc   | 22 ++++++++++
 .../v8/guide/testingPerModule.adoc                 | 23 +++++++++++
 guides/grails-multi-module/v8/guide/webapps.adoc   | 32 +++++++++++++++
 .../v8/guide/whatYouWillBuild.adoc                 | 23 +++++++++++
 .../grails-multi-module/v8/snippets/build.gradle   | 40 ++++++++++++++++++
 .../v8/snippets/settings.gradle                    | 18 ++++++++
 .../grails-app/domain/example/Book.groovy          | 21 ++++++++++
 .../grails-app/services/example/BookService.groovy | 16 ++++++++
 .../controllers/admin/BookController.groovy        | 22 ++++++++++
 .../controllers/customer/CatalogController.groovy  | 33 +++++++++++++++
 20 files changed, 412 insertions(+), 1 deletion(-)

diff --git a/conf/guides.yml b/conf/guides.yml
index 9a0bcf68f2c..873c4366e1c 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -2904,6 +2904,52 @@ guides:
           helpWithGrails:
             title: Help with Grails
 
+  - name: 'grails-multi-module'
+    title: 'Grails 8 Multi-Project Build: Shared Plugin + Two Webapps'
+    subtitle: 'Lay out a real-world Grails 8 multi-project build: one shared-core Grails Plugin holding the domain model and GORM data services, plus two separate web apps (customer + admin) that consume the plugin and deploy independently.'
+    authors:
+      - 'James Fredley'
+    category: 'Advanced Grails'
+    publicationDate: '2026-05-03'
+    versions:
+      '8':
+        sourcePath: guides/grails-multi-module/v8
+        publicationDate: '2026-05-03'
+        tags:
+          - 'multi-project'
+          - 'grails-plugin'
+          - 'gradle'
+          - 'gorm'
+          - 'monorepo'
+          - 'grails8'
+        sampleRef:
+          repo: 'grails-guides/grails-multi-module'
+          branch: 'grails8'
+        toc:
+          gettingStarted:
+            title: Getting Started
+            whatYouWillBuild: What You Will Build
+            requirements: What You Will Need
+            howto: How to Complete the Guide
+          generateModules:
+            title: Generate the Three Module Starters
+          rootBuild:
+            title: Root settings.gradle and build.gradle
+          sharedCore:
+            title: The shared-core Plugin
+          webapps:
+            title: The Two Webapps Consume shared-core
+          runningEach:
+            title: Running and Packaging Each Webapp
+          testingPerModule:
+            title: Per-Module Testing
+          extractingPlugin:
+            title: Extracting a Plugin From a Monolith
+          failureModes:
+            title: Failure Modes
+          helpWithGrails:
+            title: Do you need help with Grails?
+
   - name: 'grails-multi-project-build'
     title: 'Grails Multi-Project Build'
     subtitle: 'Learn how to leverage Gradle capabilities in a Grails application to create Multi-Project builds'
@@ -2920,7 +2966,7 @@ guides:
           - 'multi-project'
           - 'grails4'
         sampleRef:
-          repo: 'grails-guides/grails-multi-project-build'
+          repo: 'grails-guides/grails-multi-module'
           branch: 'master'
         toc:
           training:
diff --git a/guides/grails-multi-module/v8/guide/extractingPlugin.adoc b/guides/grails-multi-module/v8/guide/extractingPlugin.adoc
new file mode 100644
index 00000000000..3e966c508a9
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/extractingPlugin.adoc
@@ -0,0 +1,10 @@
+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:
+
+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).
+
+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.
diff --git a/guides/grails-multi-module/v8/guide/failureModes.adoc b/guides/grails-multi-module/v8/guide/failureModes.adoc
new file mode 100644
index 00000000000..fc433f0913d
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/failureModes.adoc
@@ -0,0 +1,12 @@
+Three failure modes show up in real multi-module Grails layouts. Recognise them early, address them once.
+
+* *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.
+
+* *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.
+
+* *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.
+
+[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.
+====
diff --git a/guides/grails-multi-module/v8/guide/generateModules.adoc b/guides/grails-multi-module/v8/guide/generateModules.adoc
new file mode 100644
index 00000000000..7d854e30b7e
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/generateModules.adoc
@@ -0,0 +1,22 @@
+Each of the three modules is a fresh forge zip. The plugin is the `web_plugin` application type; the two webapps are the `web` type:
+
+[source,bash]
+----
+mkdir grails-multi-module && cd grails-multi-module && mkdir initial complete && cd complete
+
+# shared-core - the plugin that holds the shared domain model + services
+curl -L -o p.zip "https://prev-snapshot.grails.org/create/web_plugin/example.shared?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21"
+unzip p.zip && mv shared shared-core && rm p.zip
+
+# webapp-customer - read-only catalog
+curl -L -o c.zip "https://prev-snapshot.grails.org/create/web/example.customer?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21"
+unzip c.zip && mv customer webapp-customer && rm c.zip
+
+# webapp-admin - back-office CRUD
+curl -L -o a.zip "https://prev-snapshot.grails.org/create/web/example.admin?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21"
+unzip a.zip && mv admin webapp-admin && rm a.zip
+----
+
+Patch `compileJava.options.release = 17` to `21` in each of the three `build.gradle` files (the forge ships JDK 17 by default even when `javaVersion=JDK_21` is requested).
+
+Each subdirectory is currently a stand-alone Grails project. The next chapter wires them into a single multi-project build.
diff --git a/guides/grails-multi-module/v8/guide/gettingStarted.adoc b/guides/grails-multi-module/v8/guide/gettingStarted.adoc
new file mode 100644
index 00000000000..49ea6c91987
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/gettingStarted.adoc
@@ -0,0 +1,5 @@
+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.
+
+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.
+
+This guide targets Apache Grails 8 / JDK 21.
diff --git a/guides/grails-multi-module/v8/guide/helpWithGrails.adoc b/guides/grails-multi-module/v8/guide/helpWithGrails.adoc
new file mode 100644
index 00000000000..e062f614b1a
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/helpWithGrails.adoc
@@ -0,0 +1 @@
+include::{commondir}/common-helpWithGrails.adoc[]
diff --git a/guides/grails-multi-module/v8/guide/howto.adoc b/guides/grails-multi-module/v8/guide/howto.adoc
new file mode 100644
index 00000000000..214f2de8d13
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/howto.adoc
@@ -0,0 +1,15 @@
+You can either build the workspace from scratch by following the chapters or skip ahead and clone the finished sample:
+
+[source,bash]
+----
+git clone -b grails8 https://github.com/grails-guides/grails-multi-module.git
+cd grails-multi-module/complete
+./gradlew :webapp-customer:bootRun     # http://localhost:8080
+# in a second shell, with the customer port freed:
+./gradlew :webapp-admin:bootRun        # http://localhost:8080
+----
+
+The repository contains two top-level directories:
+
+* `initial/` - intentionally empty; the README points at the three forge URLs that produce each module starter.
+* `complete/` - the assembled three-module workspace this guide builds.
diff --git a/guides/grails-multi-module/v8/guide/requirements.adoc b/guides/grails-multi-module/v8/guide/requirements.adoc
new file mode 100644
index 00000000000..53a90b001e6
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/requirements.adoc
@@ -0,0 +1,4 @@
+To complete this guide you will need:
+
+* JDK 21
+* About 45 minutes
diff --git a/guides/grails-multi-module/v8/guide/rootBuild.adoc b/guides/grails-multi-module/v8/guide/rootBuild.adoc
new file mode 100644
index 00000000000..48c38528536
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/rootBuild.adoc
@@ -0,0 +1,21 @@
+A root `settings.gradle` and a root `build.gradle` turn three side-by-side Grails projects into one Gradle build:
+
+[source,groovy]
+.settings.gradle
+----
+include::../snippets/settings.gradle[]
+----
+
+[source,groovy]
+.build.gradle
+----
+include::../snippets/build.gradle[]
+----
+
+Three patterns to point at:
+
+* `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.
+
+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.
diff --git a/guides/grails-multi-module/v8/guide/runningEach.adoc b/guides/grails-multi-module/v8/guide/runningEach.adoc
new file mode 100644
index 00000000000..1b0561c883f
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/runningEach.adoc
@@ -0,0 +1,25 @@
+Run each webapp independently:
+
+[source,bash]
+----
+./gradlew :webapp-customer:bootRun
+# In another shell:
+./gradlew :webapp-admin:bootRun
+----
+
+Both default to port 8080, so you cannot run them at the same time without overrides. For dev work you have two cheap options:
+
+* `./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.
+
+Production deployment uses `bootJar` instead of `bootRun`:
+
+[source,bash]
+----
+./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.
diff --git a/guides/grails-multi-module/v8/guide/sharedCore.adoc b/guides/grails-multi-module/v8/guide/sharedCore.adoc
new file mode 100644
index 00000000000..1d694352317
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/sharedCore.adoc
@@ -0,0 +1,22 @@
+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`:
+
+[source,groovy]
+.shared-core/grails-app/domain/example/Book.groovy
+----
+include::../snippets/shared-core/grails-app/domain/example/Book.groovy[]
+----
+
+And a GORM data service that both webapps consume:
+
+[source,groovy]
+.shared-core/grails-app/services/example/BookService.groovy
+----
+include::../snippets/shared-core/grails-app/services/example/BookService.groovy[]
+----
+
+Two things to highlight:
+
+* 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.
+
+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.
diff --git a/guides/grails-multi-module/v8/guide/testingPerModule.adoc b/guides/grails-multi-module/v8/guide/testingPerModule.adoc
new file mode 100644
index 00000000000..c3df0bbb678
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/testingPerModule.adoc
@@ -0,0 +1,23 @@
+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/`.
+
+Run them granularly during development:
+
+[source,bash]
+----
+./gradlew :shared-core:integrationTest
+./gradlew :webapp-customer:integrationTest
+./gradlew :webapp-admin:integrationTest
+----
+
+Or fan out the whole test suite from the 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.
diff --git a/guides/grails-multi-module/v8/guide/webapps.adoc b/guides/grails-multi-module/v8/guide/webapps.adoc
new file mode 100644
index 00000000000..156bd7c7050
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/webapps.adoc
@@ -0,0 +1,32 @@
+Both webapps consume `shared-core` via a single line in their `dependencies` block:
+
+[source,groovy]
+----
+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.
+
+Each webapp owns its own controller package. The customer webapp uses `customer.*`:
+
+[source,groovy]
+.webapp-customer/grails-app/controllers/customer/CatalogController.groovy
+----
+include::../snippets/webapp-customer/grails-app/controllers/customer/CatalogController.groovy[]
+----
+
+The admin webapp uses `admin.*`:
+
+[source,groovy]
+.webapp-admin/grails-app/controllers/admin/BookController.groovy
+----
+include::../snippets/webapp-admin/grails-app/controllers/admin/BookController.groovy[]
+----
+
+Three patterns to highlight:
+
+* 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.
+
+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.
diff --git a/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc b/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc
new file mode 100644
index 00000000000..7c6825fb645
--- /dev/null
+++ b/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc
@@ -0,0 +1,23 @@
+By the end of the guide your workspace will look like:
+
+[source,text]
+----
+grails-multi-module/
+|-- settings.gradle
+|-- build.gradle
+|-- shared-core/                # Grails Plugin
+|   |-- build.gradle
+|   `-- grails-app/
+|       |-- domain/example/Book.groovy
+|       `-- services/example/BookService.groovy
+|-- webapp-customer/            # Grails web app (read-only catalog)
+|   |-- build.gradle            # `implementation project(':shared-core')`
+|   `-- grails-app/
+|       `-- controllers/customer/CatalogController.groovy
+`-- webapp-admin/               # Grails web app (CRUD)
+    |-- build.gradle            # `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`.
diff --git a/guides/grails-multi-module/v8/snippets/build.gradle b/guides/grails-multi-module/v8/snippets/build.gradle
new file mode 100644
index 00000000000..49533f2b59b
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/build.gradle
@@ -0,0 +1,40 @@
+/*
+ * 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.
+ */
+
+subprojects {
+    repositories {
+        mavenCentral()
+        maven { url = 'https://repo.grails.org/grails/restricted' }
+        maven {
+            url = 'https://repository.apache.org/content/groups/snapshots'
+            content {
+                includeVersionByRegex('org[.]apache[.]grails.*', '.*', '.*-SNAPSHOT')
+            }
+            content {
+                includeVersionByRegex('org[.]apache[.]groovy.*', 'groovy.*', '.*-SNAPSHOT')
+            }
+            mavenContent { snapshotsOnly() }
+        }
+        maven {
+            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()
+        }
+    }
+}
diff --git a/guides/grails-multi-module/v8/snippets/settings.gradle b/guides/grails-multi-module/v8/snippets/settings.gradle
new file mode 100644
index 00000000000..a8cfc8f8639
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/settings.gradle
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ *   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.
+ */
+
+rootProject.name = 'grails-multi-module'
+
+include 'shared-core'
+include 'webapp-customer'
+include 'webapp-admin'
diff --git a/guides/grails-multi-module/v8/snippets/shared-core/grails-app/domain/example/Book.groovy b/guides/grails-multi-module/v8/snippets/shared-core/grails-app/domain/example/Book.groovy
new file mode 100644
index 00000000000..a10ffb0a9a4
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/shared-core/grails-app/domain/example/Book.groovy
@@ -0,0 +1,21 @@
+package example
+
+import grails.persistence.Entity
+
+@Entity
+class Book {
+
+    String  title
+    String  isbn
+    Integer pageCount
+    Date    publishedOn
+
+    static constraints = {
+        title       blank: false, maxSize: 255
+        isbn        blank: false, unique: true, matches: /^(97(8|9))?\d{9}(\d|X)$/
+        pageCount   nullable: true, min: 1
+        publishedOn nullable: true
+    }
+
+    String toString() { title }
+}
diff --git a/guides/grails-multi-module/v8/snippets/shared-core/grails-app/services/example/BookService.groovy b/guides/grails-multi-module/v8/snippets/shared-core/grails-app/services/example/BookService.groovy
new file mode 100644
index 00000000000..2e02921cbfa
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/shared-core/grails-app/services/example/BookService.groovy
@@ -0,0 +1,16 @@
+package example
+
+import grails.gorm.services.Service
+
+@Service(Book)
+interface BookService {
+
+    Book   get(Serializable id)
+    List<Book> list(Map args)
+    Long   count()
+    Book   save(Book book)
+    Book   delete(Serializable id)
+
+    Book   findByIsbn(String isbn)
+    Long   countByPublishedOnGreaterThanEquals(Date threshold)
+}
diff --git a/guides/grails-multi-module/v8/snippets/webapp-admin/grails-app/controllers/admin/BookController.groovy b/guides/grails-multi-module/v8/snippets/webapp-admin/grails-app/controllers/admin/BookController.groovy
new file mode 100644
index 00000000000..3e90a56e24c
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/webapp-admin/grails-app/controllers/admin/BookController.groovy
@@ -0,0 +1,22 @@
+package admin
+
+import example.Book
+import example.BookService
+import grails.rest.RestfulController
+
+/**
+ * Admin webapp's BookController.
+ *
+ * Reuses example.Book and example.BookService from the shared-core
+ * plugin; this module only owns the controller. The package separation
+ * (admin.* vs example.*) keeps app-specific UI concerns out of the
+ * shared module.
+ */
+class BookController extends RestfulController<Book> {
+
+    static responseFormats = ['html', 'json']
+
+    BookService bookService
+
+    BookController() { super(Book) }
+}
diff --git a/guides/grails-multi-module/v8/snippets/webapp-customer/grails-app/controllers/customer/CatalogController.groovy b/guides/grails-multi-module/v8/snippets/webapp-customer/grails-app/controllers/customer/CatalogController.groovy
new file mode 100644
index 00000000000..c7ed4bc1795
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/webapp-customer/grails-app/controllers/customer/CatalogController.groovy
@@ -0,0 +1,33 @@
+package customer
+
+import example.Book
+import example.BookService
+
+/**
+ * Customer webapp's read-only catalog.
+ *
+ * Reuses example.Book and example.BookService from the shared-core
+ * plugin. Notice this controller does not extend RestfulController -
+ * the customer side does not need create/update/delete; it only needs
+ * to render the catalog as GSP pages with whatever filtering the UI
+ * exposes.
+ */
+class CatalogController {
+
+    static allowedMethods = [index: 'GET', show: 'GET']
+
+    BookService bookService
+
+    def index() {
+        Integer max    = Math.min(params.int('max', 25), 100)
+        Integer offset = params.int('offset', 0)
+        respond bookService.list([max: max, offset: offset, sort: 'title']),
+                model: [bookCount: bookService.count()]
+    }
+
+    def show(Long id) {
+        Book book = bookService.get(id)
+        if (!book) { response.status = 404; return }
+        respond book
+    }
+}