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`. 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 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 { + + 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 + } +}