Gradle Composite Builds
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon and the compiler both ran on JDK 26; no toolchain was involved). Every example was run through the wrapper in scratch copies of a small tidepool composite, once with Kotlin DSL build files and once with Groovy DSL build files, and the console output is the same in both apart from the timings and the order of unrelated tasks, which Gradle does not fix. The scratch copies pointed their wrapper at a local copy of the same distribution archive; the properties file that a real project commits is
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
The commands use --console=plain. In the listings the "Consider enabling configuration cache" hint, the link to the
problems report, the Try: hints of failures and the daemon start-up line are left out, and the scratch directory is shown as
/work/composite. Where a listing is shortened the shortening is marked on its own line. Examples run with
--configuration-cache say so, and each example states whether it is compatible with the configuration cache. Gradle
8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not run
here".
A composite build is a build that includes other complete builds, each with its own settings file, its own projects and its own plugins. The including build is the root of the composite and the builds it includes are included builds. Where a multi-project build puts many projects under one settings file (see Multi Project Builds), a composite stitches several independent builds together, and Gradle swaps a binary dependency for the matching source build when the two meet. That swap is the whole point: it lets you change a library and the application that uses it at the same time, without publishing the library in between.
When to use a composite build
The manual names three situations. Each one is about a boundary that you do not want to turn into a single build.
| Situation | What a composite gives you | Where it is covered |
|---|---|---|
Developing a library and its consumer together |
The consumer’s dependency on the library is replaced by the library’s source, so an edit is visible to the consumer at
once. When the change is released, remove the |
This page. |
Organising a large code base |
Independent builds, each openable and buildable on its own (in an IDE too), that can also be built together from an "uber-root" build, the monorepo layout. |
This page. |
Sharing build logic |
An included |
The best-practices chapter goes one step further and recommends an included build-logic build over buildSrc for build
logic, because a change in an included build invalidates fewer tasks and because an included build is a complete build
that can be opened and built alone. Source:
Gradle User Manual: Favor build-logic Composite Builds
Composite or multi-project?
A multi-project build and a composite are not alternatives for the same job; they answer different questions.
| Question | Multi-project build | Composite build |
|---|---|---|
How many settings files? |
One. Every project is configured by it. |
One per build. The root’s settings file includes the others. |
Share plugins, repositories, version catalog and properties? |
Yes, through the root build and its convention plugins. |
No. Each build configures itself; see What is shared between builds. |
How is a project used by another one? |
|
By external coordinates, |
Can a part be built alone? |
Only with |
Yes, it is a build; run |
Does |
Yes, for every project that has a |
No; Running tasks across builds shows what runs. |
Typical use |
One product made of modules that always ship together. |
A library and the applications that consume it, or a monorepo of products with separate lifecycles. |
The Maven counterpart of the reactor is the multi-module build (see
Multi-Module Builds & the Reactor),
and Maven resolves a module from the reactor as Gradle resolves a project; Maven has no direct equivalent of including a
whole separate build and substituting its output, which is why a library is normally mvn install-ed into the local
repository before its consumer picks it up. Ant calls other build files with ant and subant (see
Multi-File Builds: import, include & subant)
but it has no dependency model to substitute.
The scenario
The page uses four sibling builds that are cut from the tidepool project of the other pages. The application lives in
tidepool-app; the station model, which every other part uses, has been moved out into its own build, tidepool-model;
a stand-in for a third-party library that we want to patch is a fourth build, tide-format-fork; and build-logic holds a
tiny plugin. No build refers to another one by anything except a directory name in a settings file.
The layout on disk is the co-development layout of the manual: the included builds sit next to the consumer and are included temporarily or permanently by relative path.
composite/
├── tidepool-app/ the consumer; its settings file includes the others
│ ├── settings.gradle(.kts)
│ ├── build.gradle(.kts)
│ ├── data/stations.csv
│ └── src/main/java/io/tidepool/{engine,cli}/...
├── tidepool-model/ included build: a java-library, io.tidepool:tidepool-model:1.0.0
│ ├── settings.gradle(.kts)
│ ├── build.gradle(.kts)
│ └── src/main/java/io/tidepool/model/...
├── tide-format-fork/ included build: a java-library with no coordinates
│ ├── settings.gradle(.kts)
│ ├── build.gradle(.kts)
│ └── src/main/java/org/example/tides/TideFormat.java
└── build-logic/ included plugin build: the tidepool.banner plugin
├── settings.gradle(.kts)
├── build.gradle(.kts)
└── src/main/java/io/tidepool/logic/BannerPlugin.java
The included library build
The library is an ordinary build with a settings file and a build file. The only thing that matters to a composite is that
its project has a group, a name and a version that line up with what consumers declare.
tidepool-model/settings.gradle.kts and tidepool-model/build.gradle.kts)rootProject.name = "tidepool-model"
plugins {
`java-library`
}
group = "io.tidepool"
version = "1.0.0"
tidepool-model/settings.gradle and tidepool-model/build.gradle)rootProject.name = 'tidepool-model'
plugins {
id 'java-library'
}
group = 'io.tidepool'
version = '1.0.0'
Nothing in these files mentions the composite, and the build works alone: ./gradlew build run inside tidepool-model
succeeds with the two tasks it has (compileJava, jar) and nothing else to configure. That independence is what the
manual means by an included build being "a complete Gradle build".
These files are this page’s own setup for the examples, not an example taken from the manual.
Defining a composite in the settings file
The consumer is a normal application build. Its settings file lists the builds to include with
Settings.includeBuild(Object); relative paths are resolved from the directory of the settings file that contains the call,
and the path must point at the root directory of the included build, the directory that holds its settings file. Absolute
paths work but the manual discourages them, because they tie the build to one machine.
tidepool-app/settings.gradle.kts)pluginManagement {
includeBuild("../build-logic")
}
rootProject.name = "tidepool-app"
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
includeBuild("../tidepool-model")
includeBuild("../tide-format-fork") {
dependencySubstitution {
substitute(module("org.example:tide-format")).using(project(":"))
}
}
tidepool-app/settings.gradle)pluginManagement {
includeBuild('../build-logic')
}
rootProject.name = 'tidepool-app'
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
includeBuild('../tidepool-model')
includeBuild('../tide-format-fork') {
dependencySubstitution {
substitute module('org.example:tide-format') using project(':')
}
}
The settings file has three kinds of includeBuild, and the rest of the page takes them in turn: a plain one for
tidepool-model (substitution by group and name, Dependency substitution), one with a dependencySubstitution block
for tide-format-fork (Declaring substitutions explicitly) and one inside pluginManagement for build-logic
(Included plugin builds). The pluginManagement block must come first in a settings file, in both DSLs. The
repositories are declared once in the settings file, as on the other Gradle pages; they apply to the consumer only.
The application’s build file declares its dependencies the way it would if the library had been published to a repository:
tidepool-app/build.gradle.kts)plugins {
application
id("tidepool.banner")
}
dependencies {
implementation("io.tidepool:tidepool-model:1.0.0")
implementation("org.example:tide-format:2.3")
implementation("org.apache.commons:commons-csv:1.14.1")
implementation("info.picocli:picocli:4.7.7")
}
application {
mainClass = "io.tidepool.cli.Main"
}
tasks.named<JavaExec>("run") {
args("data/stations.csv", "SF")
}
tidepool-app/build.gradle)plugins {
id 'application'
id 'tidepool.banner'
}
dependencies {
implementation 'io.tidepool:tidepool-model:1.0.0'
implementation 'org.example:tide-format:2.3'
implementation 'org.apache.commons:commons-csv:1.14.1'
implementation 'info.picocli:picocli:4.7.7'
}
application {
mainClass = 'io.tidepool.cli.Main'
}
tasks.named('run') {
args 'data/stations.csv', 'SF'
}
Neither io.tidepool:tidepool-model:1.0.0 nor org.example:tide-format:2.3 exists in any repository the build can reach;
the build can only work because the composite provides them. Running it from a clean state on the Gradle 9.8.1 wrapper
(with --configuration-cache) shows the included builds being built on demand, interleaved with the consumer’s own tasks:
$ ./gradlew --console=plain --configuration-cache run
Calculating task graph as no cached configuration is available for tasks: run
> Task :build-logic:compileJava
> Task :build-logic:pluginDescriptors
> Task :build-logic:processResources
> Task :build-logic:classes
> Task :build-logic:jar
> Task :tide-format-fork:processResources NO-SOURCE
> Task :processResources NO-SOURCE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tide-format-fork:compileJava
> Task :tide-format-fork:classes
> Task :tide-format-fork:jar
> Task :tidepool-model:compileJava
> Task :tidepool-model:classes
> Task :tidepool-model:jar
> Task :compileJava
> Task :classes
> Task :run
San Francisco: -0.869 m (formatted by the local fork)
BUILD SUCCESSFUL in 4s
10 actionable tasks: 10 executed
Configuration cache entry stored.
The task paths show the structure. A task of the root build is :compileJava; a task of an included build is prefixed with
the build’s path, :tidepool-model:compileJava. The consumer’s compileJava needs the library’s jar on its class path, so
Gradle ran :tidepool-model:jar and everything that jar needs, without anyone asking for it. The example is compatible with
the configuration cache (second run in Configuration cache and configuration on demand); the Groovy DSL build prints the same lines.
Defining a composite on the command line
--include-build turns the build that is being run into a composite for one invocation, without editing any file. The
settings file can then stay free of includeBuild calls, which suits a one-off check of a local library change. Here the
includeBuild("../tidepool-model") line was temporarily removed from the settings file of the consumer, and the library was
added on the command line instead (the fork and build-logic were still included by the settings file):
$ ./gradlew --console=plain --include-build ../tidepool-model run
(build-logic tasks omitted)
> Task :processResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tide-format-fork:compileJava UP-TO-DATE
> Task :tide-format-fork:processResources NO-SOURCE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tide-format-fork:classes UP-TO-DATE
> Task :tidepool-model:classes UP-TO-DATE
> Task :tide-format-fork:jar UP-TO-DATE
> Task :tidepool-model:jar UP-TO-DATE
> Task :compileJava UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :run
San Francisco: -0.869 m (formatted by the local fork)
BUILD SUCCESSFUL in 1s
10 actionable tasks: 1 executed, 9 up-to-date
The flag can be given more than once (--include-build ../tidepool-model --include-build ../tide-format-fork listed both
builds under "Included builds" in ./gradlew projects), and the path in the example is relative to the directory the
command was run in. The same substitution happens as with the settings file. For plugins the manual prefers includeBuild in the settings file; see Included plugin builds.
Dependency substitution
When a build is included, Gradle configures it to find out which external modules it can stand in for. By default it takes
the group and the project name of every project in the included build and substitutes a project dependency for any
external dependency that matches ${project.group}:${project.name} (the version is not part of the match). tidepool-model
has group = "io.tidepool" and the root project name tidepool-model, so it replaces io.tidepool:tidepool-model with no
further code. dependencyInsight says where a resolved module came from:
$ ./gradlew dependencyInsight --dependency tidepool-model --configuration runtimeClasspath
(build-logic tasks omitted)
> Task :dependencyInsight
project ':tidepool-model' (by composite build)
Variant runtimeElements:
| Attribute Name | Provided | Requested |
|--------------------------------|--------------|--------------|
| org.gradle.category | library | library |
| org.gradle.dependency.bundling | external | external |
| org.gradle.jvm.version | 26 | 26 |
| org.gradle.libraryelements | jar | jar |
| org.gradle.usage | java-runtime | java-runtime |
| org.gradle.jvm.environment | | standard-jvm |
io.tidepool:tidepool-model:1.0.0 -> project ':tidepool-model'
\--- runtimeClasspath
A web-based, searchable dependency report is available by adding the --scan option.
BUILD SUCCESSFUL in 1s
5 actionable tasks: 1 executed, 4 up-to-date
The header project ':tidepool-model' (by composite build) and the line
io.tidepool:tidepool-model:1.0.0 -> project ':tidepool-model' are the substitution. Without the includeBuild line the same dependency is looked up in the repository
and fails (the settings file was edited to remove the line for this run):
$ ./gradlew --console=plain compileJava
(build-logic tasks omitted)
FAILURE: Build failed with an exception.
* What went wrong:
Could not determine the dependencies of task ':compileJava'.
> Could not resolve all dependencies for configuration ':compileClasspath'.
> Could not find io.tidepool:tidepool-model:1.0.0.
Searched in the following locations:
- https://repo.maven.apache.org/maven2/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
Required by:
root project 'tidepool-app'
BUILD FAILED in 1s
Two details follow from the rule. The version is ignored, so a consumer that asks for 1.0.0 gets whatever version the
included build has; and by default only the included builds provide substitutions, not the root build, so subprojects of
the consumer are not addressable as group:name unless the root includes itself with includeBuild(".").
Declaring substitutions explicitly
The automatic rule needs the project’s coordinates to be the ones the consumer asks for. When they are not, the settings
file declares the substitution itself. tide-format-fork has no group and no version at all, which is what a patched copy
of a third-party library often looks like on the day it is checked out, so the consumer says which module the build
replaces:
tide-format-fork/build.gradle.kts)plugins {
`java-library`
}
// No group and no version on purpose: the app declares a substitution instead.
tide-format-fork/build.gradle)plugins {
id 'java-library'
}
// No group and no version on purpose: the app declares a substitution instead.
The includeBuild("../tide-format-fork") { dependencySubstitution { ... } } block in the settings file above is the other
half. substitute(module(...)).using(project(":")) reads "wherever a dependency asks for this module, use the root project
of this included build". Without the block, the included build would offer the module undefined:tide-format-fork and the
dependency on org.example:tide-format:2.3 would go to the repository:
$ ./gradlew --console=plain compileJava
(build-logic tasks omitted)
FAILURE: Build failed with an exception.
* What went wrong:
Could not determine the dependencies of task ':compileJava'.
> Could not resolve all dependencies for configuration ':compileClasspath'.
> Could not find org.example:tide-format:2.3.
Searched in the following locations:
- https://repo.maven.apache.org/maven2/org/example/tide-format/2.3/tide-format-2.3.pom
Required by:
root project 'tidepool-app'
BUILD FAILED in 1s
For this run the block was cut down to a plain includeBuild("../tide-format-fork"). With the block in place the same
dependencyInsight shows the fork:
$ ./gradlew dependencyInsight --dependency tide-format --configuration runtimeClasspath
(build-logic tasks omitted)
> Task :dependencyInsight
project ':tide-format-fork' (by composite build)
Variant runtimeElements:
| Attribute Name | Provided | Requested |
|--------------------------------|--------------|--------------|
| org.gradle.category | library | library |
| org.gradle.dependency.bundling | external | external |
| org.gradle.jvm.version | 26 | 26 |
| org.gradle.libraryelements | jar | jar |
| org.gradle.usage | java-runtime | java-runtime |
| org.gradle.jvm.environment | | standard-jvm |
org.example:tide-format:2.3 -> project ':tide-format-fork'
\--- runtimeClasspath
A web-based, searchable dependency report is available by adding the --scan option.
BUILD SUCCESSFUL in 15s
5 actionable tasks: 1 executed, 4 up-to-date
The manual lists the cases in which a declared substitution is required: the artifact name is changed with
archivesBaseName; a configuration other than default is published; the maven-publish or ivy-publish plugin is used
and the publication coordinates differ from ${project.group}:${project.name}; or one project defines several
publications. Two more properties of declared substitutions matter:
-
Substitutions are not transitive across builds. The rule is active for the build that declares it. If you run a task in the fork’s own directory that needs
org.example:tide-format, resolution fails unless the fork declares the rule too, or, as the manual recommends, aligns itsgroupandrootProject.namewith the expected coordinates so the automatic rule works in both directions. -
A substituted project dependency always points at the
defaultconfiguration of the target. A build whose published metadata differs from that configuration may behave differently in a composite; see Limitations and pitfalls.
Using a local fork of a library
The "fork" build above is the manual’s recipe for patching a dependency you do not own: keep a checkout of the module’s
sources (it must itself be built with Gradle), include it, and keep the consumer’s dependency declaration unchanged. If the
fork’s build file has the same group, name and version as the dependency, Gradle substitutes it automatically and the
consumer needs no dependencySubstitution block at all; in that case keep the repository (such as mavenCentral()) in the
consumer, because other modules still come from there. When Gradle still tries to download the module, check that the
fork’s coordinates match exactly and that the includeBuild path is right.
The fork on this page patches TideFormat.line(...), which prints the suffix "formatted by the local fork"; the output of
run above shows it, and removing includeBuild("../tide-format-fork") would put the build back on the published binary
(not run here, since no such binary exists in this scratch set-up).
Turning substitution off for one configuration
The substitution rules are applied to every configuration of the build. To compare the local build with what is published,
create a configuration that opts out with resolutionStrategy.useGlobalDependencySubstitutionRules = false:
configurations.create("publishedRuntimeClasspath") {
resolutionStrategy.useGlobalDependencySubstitutionRules = false
extendsFrom(configurations.runtimeClasspath.get())
isCanBeConsumed = false
attributes.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
}
configurations.create('publishedRuntimeClasspath') {
resolutionStrategy.useGlobalDependencySubstitutionRules = false
extendsFrom(configurations.runtimeClasspath)
canBeConsumed = false
attributes.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
}
Here neither library is published anywhere, so the new configuration shows what it means that Gradle no longer swaps them for the included builds:
$ ./gradlew dependencies --configuration publishedRuntimeClasspath
(build-logic tasks omitted)
> Task :dependencies
------------------------------------------------------------
Root project 'tidepool-app'
------------------------------------------------------------
publishedRuntimeClasspath
+--- io.tidepool:tidepool-model:1.0.0 FAILED
+--- org.example:tide-format:2.3 FAILED
+--- org.apache.commons:commons-csv:1.14.1
| +--- commons-io:commons-io:2.20.0
| \--- commons-codec:commons-codec:1.19.0
\--- info.picocli:picocli:4.7.7
A web-based, searchable dependency report is available by adding the --scan option.
BUILD SUCCESSFUL in 1s
5 actionable tasks: 1 executed, 4 up-to-date
Source: Gradle User Manual: Disabling Substitutions for a Configuration. Substitution rules in general, including dependencySubstitution in a single build, are on
Resolution Rules & Substitution.
project() across builds
The manual says that project(":other-build:module") cannot reach a project of an included build and ends in an
UnknownProjectException, and tells you to use the external coordinates. That is the advice to follow, because a build file
that names a coordinate keeps working in a standalone build and after the library is released. The behaviour on Gradle
9.8.1 was less strict than the text: with the dependency written as implementation(project(":tidepool-model")) (and
implementation project(':tidepool-model') in the Groovy DSL) the build compiled, and the library’s task ran from the
consumer’s build:
$ ./gradlew --console=plain compileJava
(build-logic tasks omitted)
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tide-format-fork:compileJava UP-TO-DATE
> Task :compileJava UP-TO-DATE
BUILD SUCCESSFUL in 1s
7 actionable tasks: 7 up-to-date
In a scratch copy in which tidepool-model had a subproject api, project(":tidepool-model:api") resolved too (its
compileJava ran), while a path that does not exist, project(":nonexistent"), failed with Project with path
':nonexistent' could not be found. These observations are from this one scratch composite and were not traced to a release
note, so treat the manual as the supported contract: use coordinates, not project(), across builds.
Running tasks across builds
Tasks of an included build can be run from the command line with their fully qualified path, build path first:
./gradlew :tidepool-model:jar. This works since Gradle 6.8, which introduced running included-build tasks directly. Build,
project and task names can be abbreviated as on any other task path, and --exclude-task takes the qualified path too. A
composite does not behave like a multi-project build in one respect that surprises people: a lifecycle task such as build
runs in the root build only, and the included builds are built only as far as the root’s tasks need them. build in
the consumer ran these tasks (the libraries' own build tasks did not run):
$ ./gradlew --console=plain build
(build-logic tasks omitted)
> Task :processResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tide-format-fork:compileJava UP-TO-DATE
> Task :tidepool-model:processResources NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :tidepool-model:classes UP-TO-DATE
> Task :tide-format-fork:processResources NO-SOURCE
> Task :tide-format-fork:classes UP-TO-DATE
> Task :tidepool-model:jar UP-TO-DATE
> Task :tide-format-fork:jar UP-TO-DATE
> Task :compileJava UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :jar
> Task :startScripts
> Task :distTar
> Task :distZip
> Task :assemble
> Task :compileTestJava NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test NO-SOURCE
> Task :check UP-TO-DATE
> Task :build
BUILD SUCCESSFUL in 1s
13 actionable tasks: 4 executed, 9 up-to-date
Only compile, processResources, classes and jar of the two libraries ran, because those were needed for the
application’s class path. No :tidepool-model:build, :tidepool-model:check or :tidepool-model:test appears. To get that
behaviour the composite must say so. Included builds are reached through Gradle.getIncludedBuilds() or
Gradle.includedBuild(String), and IncludedBuild.task(String) yields a task reference that can be a dependency. Included
builds cannot depend on each other this way, but the root can depend on all of them:
tidepool-app/build.gradle.kts)tasks.register("buildAll") {
group = "build"
description = "Builds this project and every included library build."
dependsOn(tasks.named("build"))
dependsOn(gradle.includedBuilds.map { it.task(":build") })
}
tidepool-app/build.gradle)tasks.register('buildAll') {
group = 'build'
description = 'Builds this project and every included library build.'
dependsOn tasks.named('build')
dependsOn gradle.includedBuilds*.task(':build')
}
$ ./gradlew --console=plain buildAll
(lines omitted)
...
> Task :tidepool-model:test NO-SOURCE
> Task :tide-format-fork:test NO-SOURCE
> Task :tidepool-model:check UP-TO-DATE
> Task :tide-format-fork:check UP-TO-DATE
> Task :tide-format-fork:build UP-TO-DATE
> Task :tidepool-model:build UP-TO-DATE
> Task :compileJava UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :startScripts UP-TO-DATE
> Task :distTar UP-TO-DATE
> Task :distZip UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :compileTestJava NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test NO-SOURCE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE
> Task :build-logic:validatePlugins
> Task :build-logic:check
> Task :build-logic:build
> Task :buildAll
BUILD SUCCESSFUL in 2s
15 actionable tasks: 2 executed, 13 up-to-date
The line ... marks lines left out of the middle of the listing. Two things are worth reading in it. All the included builds'
build tasks ran, and :build-logic:build is among them: gradle.includedBuilds also lists the plugin build that was
included from pluginManagement, so a buildAll of this kind builds the build logic as well. Filter on the build name if
that is not wanted. The manual also shows the same idea in one expression for a task with the same path in every included
build (dependsOn(gradle.includedBuilds.map { it.task(":publishMavenPublicationToMavenRepository") })), for example to
publish all the libraries of a monorepo. The order of unrelated tasks differs between runs and between the two DSLs, and
that is normal.
./gradlew projects lists the included builds beside the projects of the root build:
$ ./gradlew projects
(build-logic tasks omitted)
> Task :projects
------------------------------------------------------------
Root project 'tidepool-app'
------------------------------------------------------------
Location: /work/composite/tidepool-app
Project hierarchy:
Root project 'tidepool-app'
No sub-projects
Included builds:
+--- Included build ':build-logic'
+--- Included build ':tidepool-model'
\--- Included build ':tide-format-fork'
To see a list of the tasks of a project, run gradlew <project-path>:tasks
For example, try running gradlew :tasks
BUILD SUCCESSFUL in 1s
5 actionable tasks: 1 executed, 4 up-to-date
Change a source file in the library and run the consumer again, and the edit is picked up with no publishing step. A comment
was appended to Height.java of the library and the application was run with the configuration cache; the library is
recompiled (its jar stays up to date because the class files did not change), and the cache entry is reused because the
build’s configuration inputs did not change:
$ ./gradlew --console=plain --configuration-cache run
Reusing configuration cache.
> Task :processResources NO-SOURCE
> Task :tide-format-fork:processResources NO-SOURCE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tide-format-fork:compileJava UP-TO-DATE
> Task :tide-format-fork:classes UP-TO-DATE
> Task :tide-format-fork:jar UP-TO-DATE
> Task :tidepool-model:compileJava
> Task :tidepool-model:classes
> Task :tidepool-model:jar UP-TO-DATE
> Task :compileJava UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :run
San Francisco: -0.869 m (formatted by the local fork)
BUILD SUCCESSFUL in 9s
6 actionable tasks: 2 executed, 4 up-to-date
Configuration cache entry reused.
Gradle 9.1 fixed --dry-run for composites: before that release tasks of included builds could still run in the execution
phase of a dry run. Tasks of included builds may still run during configuration if a build does that in its own logic. Since
4.1 included builds are executed in parallel, and since 4.10 included builds can be nested; a
composite that includes another composite is flattened, so all the builds share one parent.
Included plugin builds
A build that provides plugins is included like any other build, but where it is included decides what the plugin can be
applied to. For plugins applied in project build scripts, put includeBuild inside pluginManagement of the settings file
(as in tidepool-app/settings.gradle(.kts) above); the plugin is then used from the plugins {} block by its id:
$ ./gradlew banner
(build-logic tasks omitted)
> Task :banner
== tidepool-app ==
BUILD SUCCESSFUL in 964ms
5 actionable tasks: 1 executed, 4 up-to-date
build-logic itself is a plain java-gradle-plugin build that registers tidepool.banner
(gradlePlugin { plugins { register("banner") { ... } } } in the Kotlin DSL); how to write that kind of build, and how
to share conventions through it, is the subject of
Convention Plugins & Sharing Build Logic,
Binary Plugins and
Testing & Publishing Plugins; the
plugins {} block and pluginManagement are on
Plugins Overview. A settings plugin, which is
applied in the settings file itself, must come from a build included in pluginManagement, because it is needed before
projects are configured; the best-practices chapter suggests a separate minimal build for settings plugins so that this early
inclusion does not hurt build caching.
The manual calls --include-build less reliable for plugins used in the plugins {} block, especially for unpublished
plugins, and recommends the settings file. In this scratch composite the command line worked: with the pluginManagement
block removed, ./gradlew --include-build ../build-logic banner printed the same == tidepool-app == and applied the
plugin. One run does not make it reliable for every case (the manual warns about unpublished plugins in particular), so
the settings file remains the recommended place.
Release history of the feature, each point from the release notes of that Gradle version: composite builds appeared in 3.1
with --include-build; 4.1 ran included builds in parallel and avoided executing included-build tasks twice; 4.10 allowed
nesting; 5.1 allowed plugins from included builds in the plugins {} block; 6.8 allowed running their tasks from the
command line and made the configuration cache work with composites; 7.0 allowed settings plugins from included builds with
pluginManagement { includeBuild(...) }.
What is shared between builds
Included builds are executed in isolation. They do not share repositories, plugins, plugin management, version catalogs or
user-defined properties with the root or with each other. The settings file of the root build owns the one thing that is
shared: the dependency substitution. A practical consequence is that a library can have its own repositories and plugin
versions without the consumer knowing, and equally that the consumer’s dependencyResolutionManagement does not reach the
library.
Properties show the rule most clearly. The consumer got a gradle.properties with greeting=hello, and both build files
printed findProperty("greeting") during configuration. The first run reads the property from the file, the second passes
it with -Pgreeting=cli:
$ ./gradlew --console=plain compileJava
> Configure project :tidepool-model
tidepool-model sees greeting = null
(build-logic tasks omitted)
> Configure project :
tidepool-app sees greeting = hello
> Task :tide-format-fork:compileJava UP-TO-DATE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :compileJava UP-TO-DATE
BUILD SUCCESSFUL in 1s
7 actionable tasks: 7 up-to-date
$ ./gradlew --console=plain -Pgreeting=cli compileJava
> Configure project :tidepool-model
tidepool-model sees greeting = cli
(build-logic tasks omitted)
> Configure project :
tidepool-app sees greeting = cli
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tide-format-fork:compileJava UP-TO-DATE
> Task :compileJava UP-TO-DATE
BUILD SUCCESSFUL in 821ms
7 actionable tasks: 7 up-to-date
The gradle.properties of the root is not visible to the included build, while -P and -D options reach every build.
The manual adds that Gradle’s own runtime properties, such as org.gradle.configuration-cache, are read from the root build
only and apply to the whole invocation; the same properties in an included build’s gradle.properties are ignored (the
runtime-property half was not run here). To share credentials or common configuration across a composite, the manual
suggests environment variables or a shared convention plugin included through pluginManagement. The properties are
described in
Build Environment & Properties.
Unique build paths
Every build has a build path, derived from the directory name of the included build, and every project is addressed by its
build path plus its project path. Two rules keep the addressing unambiguous: no two included builds can have the same build
path, and an included build path cannot clash with a project path of the root build. A second tidepool-model directory
in another place collides with the first:
$ ./gradlew --console=plain compileJava
FAILURE: Build failed with an exception.
* What went wrong:
Included build /work/composite/other/tidepool-model has build path :tidepool-model which is the same as included build /work/composite/tidepool-model
BUILD FAILED in 1s
The fix is to give one of them a different name in the settings file:
includeBuild("../other/tidepool-model") {
name = "other-tidepool-model"
}
includeBuild('../other/tidepool-model') {
name = 'other-tidepool-model'
}
The renaming snippet is from the manual and was not run here; the collision message above was. The name matters when you
reference the build: in gradle.includedBuild("...") and in task paths such as :other-tidepool-model:jar.
Configuration cache and configuration on demand
The runs with --configuration-cache above stored an entry on the first run and reused it on the second (the "Reusing
configuration cache" line in the source-edit run). Composites have worked with the configuration cache since Gradle 6.8,
and the examples on this page, including the included builds and the substitution, are compatible with it. The cached
entry stays valid when only source files of the included library change, as shown. It is not reused when a build file of an
included build changes: after a line was appended to tidepool-model/build.gradle.kts the next run printed "Calculating task
graph as configuration cache cannot be reused because file '../tidepool-model/build.gradle.kts' has changed." See
Configuration Cache & Isolated Projects.
Configuration on demand is a different matter and not run here. The manual says it can hurt a composite: with the default rules Gradle has to inspect every project of an included build to find out which group and name it provides, so the included build is always fully configured; with explicit substitutions Gradle trusts them and configures the included build only when a substituted project is needed. The flip side is that with configuration on demand you must declare substitutions for every project of the included build you want to use, because projects without a rule are treated as external.
IDEs
An IDE imports a Gradle build through the Tooling API (see
Init Scripts & the Tooling API),
and the manual says composite builds "can be imported into the IDE" like a multi-project build. What an IDE shows
(included builds as separate modules or projects next to the root, navigation from the consumer into the library’s sources,
a refresh after editing includeBuild) depends on the IDE and its version, and none of it was run here. Two things are
independent of the IDE and were run: each included build is a full build that can be opened alone (the standalone
./gradlew build in tidepool-model above), and a change in the library is visible to the consumer without publishing, so
the edit-run loop works in the IDE the same way as on the command line. The Gradle 6.8 release notes add that IDE support for
running included-build tasks directly could vary by IDE at that time, and suggested creating a Gradle run configuration
that runs the task as on the command line. For the cross-tool view of IDE integration see
CI & IDE Integration.
Limitations and pitfalls
| Limitation | What happens | What to do |
|---|---|---|
A publication that differs from the project’s |
The substituted dependency points at the target’s |
Test the published module too, with the |
Building the same included build from two composites at once |
Two composites that include the same build can conflict when run in parallel, because Gradle does not share the project lock of a shared included build between invocations. |
Do not run two invocations on the same shared build at the same time. |
Configuration on demand |
Default substitutions force full configuration of the included build. |
Declare explicit substitutions, or do not combine the two. |
Expecting |
Only what the root’s tasks need runs. |
Qualified task paths on the command line, or a root task that depends on |
|
Not supported by the manual; it happened to work in the scratch composite. |
Use external coordinates. |
Sharing configuration between builds |
Nothing is shared: repositories, plugin management, catalogs and user-defined properties are per build. |
Repeat them, pass |
Same directory name for two builds |
Build path clash. |
|
Dependency version in the consumer |
The version is ignored for substitution, so the consumer can silently use a different version of the library than it asks for. |
Align versions, or look at |
What changed from Gradle 8.14 and from the Gradle 1.x book
Nothing on this page was run on Gradle 8.14.6; the notes below come from the official documentation and the release notes and are labelled "not run here".
| Item | Status |
|---|---|
Composite builds in general |
Present in the whole 8.x line and unchanged in concept. Not run here on 8.14. |
|
Fixed in 9.1.0: before it, tasks of included builds could still run in the execution phase of a dry run. Not run here on 8.14. |
Settings plugins from included builds |
|
|
Kotlin DSL property assignment since 8.2; on earlier versions use |
Configuration cache |
Stable since 8.1, the preferred mode in 9.0 and announced as the default for Gradle 10; composites are supported since 6.8. |
Gradle in Action (Gradle 1.7) |
Written before composite builds (3.1, 2016). Its tools for splitting a project are multi-project builds and |
Source: Gradle User Manual: Upgrading within Gradle 8.x and the release notes cited above
The same idea in the other two tools
| Concept | Gradle | Maven | Ant |
|---|---|---|---|
Use the source of a library instead of its binary |
Included build with automatic or declared substitution. |
A multi-module reactor resolves a module from the reactor; otherwise |
Call the library’s build file ( |
Build independent projects together |
|
A parent POM with |
A driver build file with |
Order of the builds |
Derived from the dependencies; included builds run on demand. |
The reactor sorts modules by their dependencies. |
You order them, or use Ivy’s |
Build a part alone |
|
|
|
See Multi-Module Builds & the Reactor, Multi-File Builds: import, include & subant, Ivy: Publishing & Multi-Project Builds and the cross-tool view in Comparing Ant, Maven & Gradle.