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 includeBuild line.

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 build-logic build holds convention plugins. buildSrc is essentially such a build that Gradle includes for you.

Convention Plugins & Sharing Build Logic

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?

project(":tidepool-model") between projects of the same build.

By external coordinates, "io.tidepool:tidepool-model:1.0.0", replaced by the source build.

Can a part be built alone?

Only with -p or --project-dir, and it still needs the root settings file.

Yes, it is a build; run ./gradlew build in its directory.

Does ./gradlew build build everything?

Yes, for every project that has a build task.

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.

flowchart LR subgraph app["tidepool-app (root of the composite)"] A["settings.gradle(.kts)<br/>includeBuild(...)"] end A -->|"io.tidepool:tidepool-model:1.0.0<br/>(automatic, by group and name)"| M["tidepool-model<br/>group io.tidepool, version 1.0.0"] A -->|"org.example:tide-format:2.3<br/>(declared substitution)"| F["tide-format-fork<br/>no group, no version"] A -->|"pluginManagement { includeBuild }"| L["build-logic<br/>plugin tidepool.banner"]

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.

Kotlin DSL (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"
Groovy DSL (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.

Kotlin DSL (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(":"))
    }
}
Groovy DSL (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:

Kotlin DSL (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")
}
Groovy DSL (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:

Kotlin DSL (tide-format-fork/build.gradle.kts)
plugins {
    `java-library`
}

// No group and no version on purpose: the app declares a substitution instead.
Groovy DSL (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 its group and rootProject.name with the expected coordinates so the automatic rule works in both directions.

  • A substituted project dependency always points at the default configuration 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:

Kotlin DSL
configurations.create("publishedRuntimeClasspath") {
    resolutionStrategy.useGlobalDependencySubstitutionRules = false
    extendsFrom(configurations.runtimeClasspath.get())
    isCanBeConsumed = false
    attributes.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
}
Groovy DSL
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:

Kotlin DSL (appended to 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") })
}
Groovy DSL (appended to 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:

Kotlin DSL
includeBuild("../other/tidepool-model") {
    name = "other-tidepool-model"
}
Groovy DSL
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 default configuration

The substituted dependency points at the target’s default configuration, so custom maven-publish logic, a tweaked POM or ivy.xml, or a capability or variant defined only in publication metadata are not seen in the composite.

Test the published module too, with the publishedRuntimeClasspath pattern above; keep the project’s variants in its configurations rather than in publication hooks.

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 build to cover every included build

Only what the root’s tasks need runs.

Qualified task paths on the command line, or a root task that depends on gradle.includedBuild(...).task(...).

project(":included") between builds

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 -P options or environment variables, or use a convention plugin from an included build.

Same directory name for two builds

Build path clash.

includeBuild(path) { name = "..." }.

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 dependencyInsight, whose header says "by composite build".

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.

--dry-run in composites

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

pluginManagement { includeBuild(...) }, since 7.0. Not run here on 8.14.

= assignment, as in repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS

Kotlin DSL property assignment since 8.2; on earlier versions use repositoriesMode.set(...). Not run here on 8.14.

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 buildSrc. The usual way of testing a library change in another build at that time was to publish the library to a local repository first, which is the step a composite removes. The maven plugin and uploadArchives were removed in 7.0.

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 mvn install the library and use the version.

Call the library’s build file (ant, subant) and put its output on the class path yourself; there is no substitution.

Build independent projects together

includeBuild, --include-build, gradle.includedBuilds.

A parent POM with <modules> (<subprojects> in Maven 4).

A driver build file with subant.

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

Build a part alone

./gradlew build in the included build’s directory.

mvn -pl module -am in the reactor, or mvn in the module’s directory.

ant in the sub-build’s directory.