Show Navigation

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 Plugin

shared-core

web

Web Application

webapp-customer

web

Web Application

webapp-admin

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:

settings.gradle
/*
 * 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'
build.gradle
/*
 * 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.java for the default starter repositories.

  • grails-profiles/base/skeleton/build.gradle plus grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.raw for the per-project tasks.withType(Test).configureEach { useJUnitPlatform() } block.

Three details matter:

  • rootProject.name = 'grails-multi-module' gives the whole workspace a stable Gradle identity.

  • subprojects { repositories { …​ } } centralises the same Maven repos every generated Grails 8 module would otherwise repeat. On the live 8.0.x line that includes filtered Apache snapshot and staging repos, because the current forge default still adds them for snapshot Grails builds.

  • tasks.withType(Test).configureEach { useJUnitPlatform() } is the exact Grails 8 starter pattern. Lifting that block to the root keeps test and integrationTest aligned across all three modules without changing their dependency lists.

Do not move ordinary library dependencies to the root. The generated module builds already know which Grails plugins and starters they need. This root file should only carry cross-cutting build policy.

Files touched:

  • settings.gradle

  • build.gradle

4 The shared-core Plugin

shared-core is a Grails web plugin, not a plain Groovy library. Keep the generated plugin descriptor class:

shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy
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:

shared-core/grails-app/domain/example/Book.groovy
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:

shared-core/grails-app/services/example/BookService.groovy
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.java recognises domain artefacts from grails-app/domain and @Entity.

  • grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultMappingContextFactoryBean.groovy adds every DomainClassArtefactHandler.TYPE artefact to the mapping context.

  • grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovy collects those same artefacts from grailsApplication.getArtefacts(…​) and hands them to HibernateDatastoreSpringInitializer.

That is why a sibling plugin’s Book domain becomes part of the consuming webapp’s Hibernate model without any manual registration step.

Keep apply plugin: 'org.apache.grails.gradle.grails-plugin' in shared-core/build.gradle. In grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsPluginGradlePlugin.groovy that Gradle plugin applies java-library, disables bootJar for the plugin project, packages a plain jar, and prepares plugin resources. If you remove it, shared-core stops behaving like a Grails plugin build.

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:

webapp-customer/build.gradle and webapp-admin/build.gradle
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:

webapp-customer/grails-app/controllers/customer/CatalogController.groovy
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:

webapp-admin/grails-app/controllers/admin/BookController.groovy
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.Book and example.BookService from the plugin. The shared data model still has exactly one owner.

  • The customer-side CatalogController is intentionally hand-rolled and read-only.

  • The admin-side BookController extends RestfulController<Book> and gets the CRUD-oriented web surface that belongs only in the admin app.

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' in grails-doc/src/en/ref/Command Line/bootRun.adoc

  • -Dgrails.server.port=8081 in grails-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 lets bootRun request 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 shared-core/grails-app/views/

yes

The view layer is resolved from plugin resources during development

Static asset

yes

Served from source/build outputs in dev mode

Add or change a domain class field

no

Hibernate metadata is built once at startup

Edit application.yml

no

Configuration is read at startup

Edit *GrailsPlugin.groovy

no

Plugin lifecycle hooks run during startup

The older manual bootRun { doFirst { classpath = …​ } } patch can still work because bootRun ultimately runs with a mutable classpath. But on Grails 8 it is now a fallback for unusual cases, not the guide’s primary recipe, because the framework already includes a first-class exploded-plugin mechanism.

Files touched:

  • shared-core/build.gradle

  • webapp-customer/build.gradle

  • webapp-admin/build.gradle

8 Per-Module Testing

Tests live with the module they cover.

  • shared-core keeps its own unit and integration tests focused on the domain model and GORM data services.

  • webapp-customer keeps its own web and functional tests for the customer-facing UI.

  • webapp-admin keeps its own CRUD and admin-side functional tests.

Run them granularly during development:

./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.

  1. Generate shared-core as a Grails web_plugin, not as a plain library project.

  2. Move only genuinely shared code into the plugin: domain classes, GORM data services, shared taglibs, and reusable Spring beans. Leave Application.groovy, UrlMappings.groovy, controllers, GSPs, JSON views, and BootStrap classes in the webapps.

  3. Keep or recreate the plugin descriptor class (SharedCoreGrailsPlugin.groovy). If the extracted code needs plugin ordering or watched resources, express that there using the normal Plugin conventions.

  4. Wire the original app to the plugin with grails { plugins { implementation project(':shared-core') } }. If you want live reload while the split is in flight, also apply org.apache.grails.gradle.grails-exploded in shared-core/build.gradle.

  5. Create the second webapp, wire the same plugin, and move the customer-facing or admin-facing controllers and views into their target module.

  6. Choose one webapp to own database migrations and startup data changes.

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 dependsOn and loadAfter properties on a Grails plugin descriptor are runtime ordering hints from grails-core/src/main/groovy/grails/plugins/Plugin.groovy. They do not replace Gradle project dependencies. If a webapp depends on shared-core, then shared-core must not depend back on that webapp. Keep the dependency arrow one-way: webapps depend on the plugin.

  • Mapping collisions in the Hibernate model. Grails 8 collects every domain artefact into the mapping context and Hibernate bootstrap path. You can see that in grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultMappingContextFactoryBean.groovy and grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovy. If the plugin and a webapp both define separate entities that map to the same table or incompatible association metadata, startup fails or the schema becomes ambiguous. Keep one authoritative domain model in shared-core.

  • Both webapps try to run startup migrations. grails-data-hibernate5/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/DatabaseMigrationGrailsPlugin.groovy runs Liquibase updates at startup when grails.plugin.databasemigration.updateOnStart* flags are enabled. Liquibase locking prevents corruption, but it still leaves startup order and deploy timing to chance. Pick one app to own migrations. Let the other app boot against an already-migrated schema.

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.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.

Files touched:

  • None