Grails 8 Multi-Project Build: Shared Plugin + Two Webapps
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
Grails Version: 8
1 Getting Started
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).
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 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
1.1 What You Will Build
By the end of the guide your workspace will look like:
grails-multi-module/
|-- settings.gradle
|-- build.gradle
|-- 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 # `grails { plugins { implementation project(':shared-core') } }`
| `-- grails-app/
| `-- controllers/customer/CatalogController.groovy
`-- webapp-admin/ # Grails web app (CRUD)
|-- build.gradle # `grails { plugins { implementation project(':shared-core') } }`
`-- grails-app/
`-- controllers/admin/BookController.groovy
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
1.2 What You Will Need
To complete this guide you will need:
-
JDK 21
-
Internet access to download three starters from start.grails.org
-
About 45 minutes
Files touched:
-
None yet
1.3 How to Complete the Guide
You can either build the workspace from scratch by following the chapters or skip ahead and clone the finished sample:
git clone -b grails8 https://github.com/grails-guides/grails-multi-module.git
cd grails-multi-module/complete
./gradlew :webapp-customer:bootRun
# In a second shell:
./gradlew :webapp-admin:bootRun --args='--server.port=8081'
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.
Each chapter ends with the paths you should have touched by that point, so you can compare your workspace against the finished sample.
Files touched:
-
None yet
2 Generate the Three Module Starters
Open start.grails.org in three browser tabs and generate three starters with the same baseline settings: JDK 21, Groovy, Gradle, Spock, and package example.
| Forge application type | UI label | Application name |
|---|---|---|
|
Web Plugin |
|
|
Web Application |
|
|
Web Application |
|
The naming here is straight from the Grails 8 forge source: ApplicationType.WEB_PLUGIN in grails-forge/grails-forge-core/src/main/java/org/grails/forge/application/ApplicationType.java and the @Named("web_plugin") registry in grails-forge/grails-forge-core/src/main/java/org/grails/forge/application/WebPluginAvailableFeatures.java.
Each downloaded archive uses the application name as the zip filename, so unzip the three archives under one parent directory:
mkdir grails-multi-module
cd grails-multi-module
unzip ~/Downloads/shared-core.zip
unzip ~/Downloads/webapp-customer.zip
unzip ~/Downloads/webapp-admin.zip
If you selected JDK 21 in the browser, keep the generated compileJava.options.release = 21 lines exactly as they are. Grails Forge writes that value from the selected target JDK in grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.raw, so there is no Grails 8 era "patch 17 to 21" cleanup step anymore.
Files touched:
-
shared-core/build.gradle
-
shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy
-
webapp-customer/build.gradle
-
webapp-admin/build.gradle
3 Root settings.gradle and build.gradle
A root settings.gradle and a root build.gradle turn the three generated starters into one Gradle build:
/*
* Multi-project root settings file.
*
* Three modules participate:
* 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 consume shared-core via their `grails { plugins { ... } }` block
* so bootRun can request the exploded plugin variant in development.
*/
rootProject.name = 'grails-multi-module'
include 'shared-core'
include 'webapp-customer'
include 'webapp-admin'
/*
* Root build script.
* Keep per-module dependencies in the generated Grails projects.
* This file only centralizes repositories and JUnit Platform.
*/
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')
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', '.*')
includeVersionByRegex('org[.]apache[.]groovy.*', 'groovy.*', '.*')
}
mavenContent { releasesOnly() }
}
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
}
}
This root pattern intentionally mirrors two Grails 8 authority sources:
-
grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/gradle/GradleRepository.javafor the default starter repositories. -
grails-profiles/base/skeleton/build.gradleplusgrails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.rawfor the per-projecttasks.withType(Test).configureEach { useJUnitPlatform() }block.
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 live8.0.xline 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 keepstestandintegrationTestaligned 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
4 The shared-core Plugin
shared-core is a Grails web plugin, not a plain Groovy library. Keep the generated plugin descriptor class:
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) {
}
}
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:
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 }
}
And a GORM data service that both webapps inject:
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)
}
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.javarecognises domain artefacts fromgrails-app/domainand@Entity. -
grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultMappingContextFactoryBean.groovyadds everyDomainClassArtefactHandler.TYPEartefact to the mapping context. -
grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovycollects those same artefacts fromgrailsApplication.getArtefacts(…)and hands them toHibernateDatastoreSpringInitializer.
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.
Files touched:
-
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
5 The Two Webapps Consume shared-core
Each webapp should consume shared-core through the Grails plugin block in its build.gradle, not through a plain dependencies { … } entry:
grails {
plugins {
implementation project(':shared-core')
}
}
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 plugin project. A plain dependencies { implementation project(':shared-core') } declaration still compiles, but it bypasses that built-in reload path.
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:
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
}
}
The admin webapp owns a different controller package:
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) }
}
Three patterns to keep:
-
Both controllers import
example.Bookandexample.BookServicefrom the plugin. The shared data model still has exactly one owner. -
The customer-side
CatalogControlleris intentionally hand-rolled and read-only. -
The admin-side
BookControllerextendsRestfulController<Book>and gets the CRUD-oriented web surface that belongs only in the admin app.
Files touched:
-
webapp-customer/build.gradle
-
webapp-admin/build.gradle
-
webapp-customer/grails-app/controllers/customer/CatalogController.groovy
-
webapp-admin/grails-app/controllers/admin/BookController.groovy
6 Running and Packaging Each Webapp
Run each webapp independently:
./gradlew :webapp-customer:bootRun
# In another shell:
./gradlew :webapp-admin:bootRun --args='--server.port=8081'
Both generated webapps default to port 8080. Grails 8 documents two accurate override styles:
-
--args='--server.port=8081'ingrails-doc/src/en/ref/Command Line/bootRun.adoc -
-Dgrails.server.port=8081ingrails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc
Use either form, but keep it consistent across your team.
Production packaging uses bootJar for the two webapps and jar for the plugin:
./gradlew :shared-core:jar
./gradlew :webapp-customer:bootJar
./gradlew :webapp-admin:bootJar
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
7 Dev-Mode Auto-Reload Across Projects
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 letsbootRunrequest that exploded variant automatically
In shared-core/build.gradle, add the exploded plugin:
apply plugin: 'org.apache.grails.gradle.grails-exploded'
In each webapp build.gradle, keep the plugin dependency inside the grails block:
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:
./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:
| Change | Reloads? | Reason |
|---|---|---|
Service or controller method body |
yes |
DevTools restart picks up the new bean classes |
GSP under |
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 |
no |
Configuration is read at startup |
Edit |
no |
Plugin lifecycle hooks run during startup |
|
The older manual |
Files touched:
-
shared-core/build.gradle
-
webapp-customer/build.gradle
-
webapp-admin/build.gradle
8 Per-Module Testing
Tests live with the module they cover.
-
shared-corekeeps its own unit and integration tests focused on the domain model and GORM data services. -
webapp-customerkeeps its own web and functional tests for the customer-facing UI. -
webapp-adminkeeps its own CRUD and admin-side functional tests.
Run them granularly during development:
./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 suite from the workspace root:
./gradlew test integrationTest
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 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/**
9 Extracting a Plugin From a Monolith
Most multi-module workspaces start from a monolith that already exists. The split is mechanical if you keep the ownership lines clear.
-
Generate
shared-coreas a Grailsweb_plugin, not as a plain library project. -
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, andBootStrapclasses in the webapps. -
Keep or recreate the plugin descriptor class (
SharedCoreGrailsPlugin.groovy). If the extracted code needs plugin ordering or watched resources, express that there using the normalPluginconventions. -
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 applyorg.apache.grails.gradle.grails-explodedinshared-core/build.gradle. -
Create the second webapp, wire the same plugin, and move the customer-facing or admin-facing controllers and views into their target module.
-
Choose one webapp to own database migrations and startup data changes.
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
10 Failure Modes
Three failure modes show up again and again in real Grails multi-module workspaces.
-
Circular module dependencies, disguised as plugin wiring. The
dependsOnandloadAfterproperties on a Grails plugin descriptor are runtime ordering hints fromgrails-core/src/main/groovy/grails/plugins/Plugin.groovy. They do not replace Gradle project dependencies. If a webapp depends onshared-core, thenshared-coremust not depend back on that webapp. Keep the dependency arrow one-way: webapps depend on the plugin. -
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.groovyandgrails-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 association metadata, startup fails or the schema becomes ambiguous. Keep one authoritative domain model inshared-core. -
Both webapps try to run startup migrations.
grails-data-hibernate5/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/DatabaseMigrationGrailsPlugin.groovyruns Liquibase updates at startup whengrails.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.
Files touched:
-
shared-core/build.gradle
-
webapp-customer/build.gradle
-
webapp-admin/build.gradle
11 Do you need help with Grails?
Help with Apache Grails
Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.
Files touched:
-
None