Automating Grails Build with Gradle Composite Builds

A Grails application often begins as a single project, with domain classes, services, controllers and configuration living in one repository. As the codebase grows, teams commonly extract reusable components into libraries, build conventions or internal plugins. Managing those pieces manually can make local development slower and continuous integration harder to reproduce.

Gradle composite builds provide a practical way to connect several independent Gradle builds without publishing every change to a Maven repository. A Grails application can consume a local shared library as though it were a normal dependency, while Gradle substitutes the published module with the source project during development.

This approach suits teams that want a repeatable Grails build pipeline without immediately converting every repository into one large multi-project build. Each included build retains its own lifecycle, tests and versioning, while the main application coordinates the complete workflow.

For Australian development teams, this can be especially useful when developers work across Sydney, Melbourne, Brisbane, Adelaide or Perth and need consistent commands in local environments and CI runners. A well-designed composite build reduces “works on my machine” differences and gives Java and Groovy developers a clear path from a local checkout to a deployable application.

Understanding Composite Builds In Grails

A composite build is a collection of separate Gradle builds joined through a settings.gradle or settings.gradle.kts file. The main Grails application remains one build, while a shared library, convention plugin or build utility is included from another directory. Gradle then resolves matching dependencies from the included source build instead of downloading the corresponding external module.

Suppose a Grails application depends on an internal library:

dependencies {
    implementation 'au.example:customer-domain:1.4.0'
}

The library can remain an independent project with its own build.gradle file. In the application’s settings.gradle, include its location:

rootProject.name = 'customer-portal'

includeBuild('../customer-domain')

Gradle uses dependency substitution when the included build publishes the same group and module name. The included library should therefore define coordinates that match the dependency:

group = 'au.example'
version = '1.4.0'

The version still matters when Gradle decides whether substitution applies, so keeping project coordinates accurate avoids confusing resolution results. If automatic substitution does not match the project layout, explicit rules can be added:

includeBuild('../customer-domain') {
    dependencySubstitution {
        substitute module('au.example:customer-domain') using project(':')
    }
}

This arrangement differs from a conventional multi-project build. A multi-project build usually shares one settings file and one project graph. A composite build connects independent builds, allowing the library to preserve its own wrapper, plugins and release process. That separation is valuable when a component may later be used by several Grails applications or published for external consumption.

Preparing The Grails Application

Start with a normal Grails project and confirm that it builds successfully before introducing an included build. The baseline commands should work without the shared component:

./gradlew clean test
./gradlew bootRun

The exact tasks vary with the Grails version and chosen plugins, but the principle remains the same: establish a healthy application build before adding dependency substitution. This makes later failures easier to attribute to the composite configuration rather than to Grails itself.

A typical Grails dependency declaration may look like this:

repositories {
    mavenCentral()
    maven { url = uri('../internal-repository') }
}

dependencies {
    implementation 'au.example:customer-domain:1.4.0'
    runtimeOnly 'org.postgresql:postgresql:42.7.3'
}

During local development, includeBuild('../customer-domain') causes Gradle to use the checked-out source. In a CI job that does not have the sibling repository, the build can resolve the same module from an authenticated repository instead. This gives developers fast feedback while preserving a conventional release path.

Grails applications also need careful handling of generated sources, database migrations and environment-specific configuration. Keep those concerns in the application unless they genuinely belong in a reusable library. For example, a shared domain module should usually provide domain classes and services, while database URLs, secrets and deployment-specific logging remain in the Grails application.

Teams can pair this workflow with a practical Grails learning path when onboarding Java developers who understand Gradle but are new to Grails conventions. A consistent explanation of profiles, commands and project structure helps developers understand which tasks belong to Grails and which are supplied by Gradle.

Structuring Shared Libraries And Build Logic

A useful included build should have a narrow responsibility. Common examples include a shared domain model, a security library, a collection of Groovy utilities or a company Gradle convention plugin. Avoid placing unrelated applications into one composite simply because their repositories are stored beside each other.

The shared project needs a normal Gradle structure. For a Java or Groovy library, that might include src/main/groovy, src/main/java, src/test/groovy and a build file that applies the relevant language plugins. It does not need to apply the Grails application plugin unless the included project is itself a Grails application.

Build logic can be isolated in a dedicated repository:

pluginManagement {
    includeBuild('../company-build-logic')
}

The included build may provide convention plugins such as au.example.grails-service or au.example.library. These plugins can standardise Java toolchains, compiler warnings, test reporting, dependency locking and repository configuration across several Grails applications.

Use a fixed Java toolchain so that developers and CI runners compile with the same language level:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

Grails versions have specific Java compatibility requirements, so the toolchain should match the framework’s supported range. A developer in Perth using a newer local JDK should receive the same compilation behaviour as a CI runner in Sydney. Toolchains reduce accidental dependence on whichever JDK happens to be installed globally.

When a shared library changes, Gradle can compile and test it directly as part of the application build. This avoids the repetitive cycle of editing source, publishing a snapshot, changing a version, refreshing dependencies and eventually cleaning up temporary repository artefacts.

Automating Local And Continuous Integration Builds

The most useful composite builds expose a small set of predictable commands. A developer should be able to clone the required repositories and run:

./gradlew clean check
./gradlew assemble
./gradlew bootRun

check typically runs unit tests and verification tasks, while assemble creates application artefacts without necessarily executing every verification task. For a release pipeline, use a stricter sequence that includes dependency checks, integration tests, packaging and any Grails-specific smoke tests.

A CI workflow can validate repository availability before Gradle starts:

test -d ../customer-domain
./gradlew --no-daemon clean check assemble

If the application and shared library are stored in separate repositories, the pipeline should check out both at known commits. A manifest, a reusable workflow or a small bootstrap script can define the compatible revision. Without that control, the application may build against a moving branch and produce results that cannot be reproduced later.

Composite builds are also suitable for GitHub Actions, GitLab CI, Jenkins and cloud-hosted runners. Cache the Gradle wrapper distribution and dependency directories, but avoid caching compiled outputs in a way that hides source changes. Dependency locking and checksum verification provide additional protection when a build downloads libraries from public or private repositories.

Australian teams should schedule nightly integration jobs with the local operating model in mind. A job launched at 11 pm in Sydney may run during business hours in Europe, while a Perth team operates two hours behind Sydney during standard time. Use UTC in pipeline schedules and display local times in notifications so release windows, support rotations and deployment approvals are unambiguous.

Handling Configuration, Security And Deployment

A composite build should not become a route for leaking configuration. Passwords, API keys and signing credentials belong in a secret manager or protected CI variables, not in gradle.properties, application.yml or an included library repository. The shared component can define configuration interfaces and safe defaults, while the Grails application supplies environment values.

Logging deserves similar separation. A reusable library should avoid forcing production appenders or file paths onto every consumer. Application-level configuration can select console, JSON or structured logging for development, testing and production. Guidance on environment-specific logging is useful when the same Grails codebase moves between local development, CI and hosted infrastructure.

Security checks can be applied consistently through convention plugins. Examples include dependency vulnerability scanning, banned repository rules, secret detection and verification that production builds do not include development-only dependencies. Gradle’s dependency insight task is helpful when a Grails plugin introduces an unexpected transitive library:

./gradlew dependencyInsight \
  --dependency jackson-databind \
  --configuration runtimeClasspath

Australian organisations should account for the Privacy Act 1988 when shared modules process personal information. A library containing customer or patient data models should have clear ownership, retention expectations and test data rules. Never place real customer records in fixtures simply because the repository is internal.

For deployment, the application build can create a container image or packaged archive after all included builds pass. A team hosting in AWS may deploy to the Sydney region, while another organisation chooses Melbourne or a service in another jurisdiction. The composite build does not dictate hosting; it supplies a repeatable artefact that can be promoted through environments according to security and data-residency requirements.

Diagnosing Resolution And Build Failures

The first diagnostic step is to confirm that Gradle has recognised the included build:

./gradlew projects
./gradlew buildEnvironment
./gradlew dependencies --configuration runtimeClasspath

If the dependency still comes from a remote repository, check the included project’s group, published name and project path. Automatic substitution usually expects the included build’s coordinates to align with the requested module. A typo in au.example versus com.example, or a library published as customer-domain-core instead of customer-domain, is enough to bypass substitution.

Use Gradle’s refresh and information options carefully:

./gradlew clean check --refresh-dependencies --info

--info can reveal substitution and task execution decisions, while --scan provides a deeper build record when build scans are permitted by organisational policy. Do not publish scan data containing proprietary source paths or sensitive environment information without reviewing the service’s terms and internal rules.

A common problem occurs when a library works after publication but fails when included directly. This can indicate that the published artefact contains generated resources, metadata or relocation rules that the source project does not expose. Test the included build independently, compare its outgoing variants and ensure resources are located under the expected source directories.

Another issue appears when a Grails plugin or application assumes a specific classpath order. Keep shared libraries focused and avoid duplicate versions of framework modules. Gradle’s version catalogues, dependency constraints and a central convention plugin can establish consistent versions without forcing every repository into one project.

Finally, document the expected checkout layout. A short CONTRIBUTING.md file can show that customer-portal and customer-domain should sit beside each other, list the required Java version and provide the exact commands for a clean build. Clear documentation saves more time than repeated troubleshooting, particularly when contractors or distributed teams join an established Australian project.

Create a small composite example in a separate branch, add a shared library with one test, and make the Grails application consume it through dependency substitution. Then run the same clean check assemble workflow locally and in CI, inspect the resolved dependencies, and record the repository layout for the team. This gives the project a dependable foundation for faster Grails development and controlled releases.