Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion conf/guides.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions guides/grails-multi-module/v8/guide/extractingPlugin.adoc
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions guides/grails-multi-module/v8/guide/failureModes.adoc
Original file line number Diff line number Diff line change
@@ -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.
====
22 changes: 22 additions & 0 deletions guides/grails-multi-module/v8/guide/generateModules.adoc
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions guides/grails-multi-module/v8/guide/gettingStarted.adoc
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions guides/grails-multi-module/v8/guide/helpWithGrails.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include::{commondir}/common-helpWithGrails.adoc[]
15 changes: 15 additions & 0 deletions guides/grails-multi-module/v8/guide/howto.adoc
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions guides/grails-multi-module/v8/guide/requirements.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
To complete this guide you will need:

* JDK 21
* About 45 minutes
21 changes: 21 additions & 0 deletions guides/grails-multi-module/v8/guide/rootBuild.adoc
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions guides/grails-multi-module/v8/guide/runningEach.adoc
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions guides/grails-multi-module/v8/guide/sharedCore.adoc
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions guides/grails-multi-module/v8/guide/testingPerModule.adoc
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions guides/grails-multi-module/v8/guide/webapps.adoc
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc
Original file line number Diff line number Diff line change
@@ -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`.
40 changes: 40 additions & 0 deletions guides/grails-multi-module/v8/snippets/build.gradle
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
18 changes: 18 additions & 0 deletions guides/grails-multi-module/v8/snippets/settings.gradle
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading