Multi Project 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 (the current release on 2026-10-16). Every example was run on JDK 26 through the Gradle wrapper on 2026-10-16, on macOS, with the configuration cache enabled (org.gradle.configuration-cache=true in gradle.properties), and every example on this page is compatible with it unless the text says otherwise. The Kotlin DSL is shown first and the Groovy DSL second wherever the syntax differs, and both versions were run. Paths of the scratch workspace are shortened to <work>; lines that were left out of an output are marked with .... Gradle 8.14 was not available here: what differs in it is described from the official documentation in the section "Gradle 8.14 and Gradle 9" and is labelled "not run here".

A build that outgrows one source tree is split into projects. In Gradle that is a multi-project build: one root project and any number of subprojects, all declared by the single settings file and all configured, scheduled and cached by the one build around them. The vocabulary (build, project, task) is on Core Concepts, the settings file on Settings & Build Scripts, and the phases in which the projects are configured on Build Lifecycle. This page is about what is specific to having several projects: how they find each other, what a dependency between them means for the task graph, how to run part of the build, why configuring one project from another is discouraged, how to hand a file from one project to another without reaching into it, and how to turn a single-project build into several projects. Sharing build logic between the projects is the subject of Convention Plugins & Sharing Build Logic, and combining separate builds that live in different repositories is that of Composite Builds.

The layout of a multi-project build

The running example of the section is tidepool, a small tide prediction toolkit with a model library, an engine library on top of it and a command line application. As a Gradle build it is one root project and three subprojects:

tidepool
├── gradle
│   ├── libs.versions.toml
│   └── wrapper
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts            (settings.gradle for the Groovy DSL)
├── build.gradle.kts               (build.gradle; the root project's script, here nearly empty)
├── data
│   └── stations.csv
├── tidepool-model
│   ├── build.gradle.kts
│   └── src/{main,test}/java/io/tidepool/model/...
├── tidepool-engine
│   ├── build.gradle.kts
│   └── src/{main,test}/java/io/tidepool/engine/...
└── tidepool-cli
    ├── build.gradle.kts
    └── src/main/java/io/tidepool/cli/...
graph TD root["root project 'tidepool': settings, base plugin, no sources"] cli["tidepool-cli: application"] engine["tidepool-engine: java-library"] model["tidepool-model: java-library"] root -.->|include| cli root -.->|include| engine root -.->|include| model cli -->|implementation| engine engine -->|api| model

Dotted arrows are the include calls of the settings file; solid arrows are the dependencies between the projects. The settings file is the only place where the projects are listed, and the user manual’s advice is that a build has exactly one settings file.

Kotlin DSL: settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = "tidepool"

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL: settings.gradle
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = 'tidepool'

include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'

The root script has nothing to configure. The base plugin gives the root project the lifecycle tasks (assemble, check, build, clean), and nothing else is applied:

Kotlin DSL: build.gradle.kts
plugins {
    base
}
Groovy DSL: build.gradle
plugins {
    id 'base'
}

The group and the version are properties set once in the gradle.properties of the root, which Gradle applies to every project of the build; the jar of the model project is therefore tidepool-model-1.0.0.jar. The catalog gradle/libs.versions.toml declares the libraries and the JUnit version (see Version Catalogs).

gradle.properties
org.gradle.configuration-cache=true
group=io.tidepool
version=1.0.0
gradle/libs.versions.toml (the same for both DSLs; highlight.js has no TOML grammar, so it is shown as plain text)
[versions]
commons-csv = "1.14.1"
picocli = "4.7.7"
junit = "6.1.3"

[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }

The projects task prints the tree Gradle built from the settings file:

./gradlew projects
Projects:

------------------------------------------------------------
Root project 'tidepool'
------------------------------------------------------------

Location: <work>

Project hierarchy:

Root project 'tidepool'
+--- Project ':tidepool-cli'
+--- Project ':tidepool-engine'
\--- Project ':tidepool-model'

Project locations:

project ':tidepool-cli' - /tidepool-cli
project ':tidepool-engine' - /tidepool-engine
project ':tidepool-model' - /tidepool-model

To see a list of the tasks of a project, run gradlew <project-path>:tasks
For example, try running gradlew :tidepool-cli:tasks

The subprojects are listed in alphabetical order of their names, not in the order of the include call, and their locations are shown relative to the root.

Project paths and names

Projects are addressed by their path: : is the root project, :tidepool-model a direct child, and :libs:model a project model below a project libs. The path decides the default directory (libs/model), and a task is addressed with the path of its project in front (:tidepool-engine:test), as explained on Command-Line Interface. Nested projects, a project directory mapped with projectDir, and the strict check of 9.0 that an included project must have a directory are shown on Settings & Build Scripts and not repeated here.

The user manual gives three recommendations for names, and the example follows them:

  • keep the default project name, which is the name of its directory, instead of mapping names to other folders;

  • use lower-case words separated by hyphens (tidepool-engine, not TidePoolEngine);

  • set rootProject.name in the settings file. Without it, the name of the build is the name of the directory it was checked out in, which differs from machine to machine (and is generated at random at the root of a file system), and appears in reports and Build Scans.

Project dependencies

A project declares that it needs another project with a project dependency, written with the path of the other project. The three build scripts of the example are the whole of the wiring. The model has no dependencies, the engine exposes the model in its API and uses Commons CSV internally, and the command line application uses the engine and picocli:

Kotlin DSL: tidepool-model/build.gradle.kts
plugins {
    `java-library`
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
}

testing {
    suites {
        named<JvmTestSuite>("test") {
            useJUnitJupiter(libs.versions.junit)
        }
    }
}
Groovy DSL: tidepool-model/build.gradle
plugins {
    id 'java-library'
}

tasks.withType(JavaCompile).configureEach {
    options.release = 21
}

testing {
    suites {
        test {
            useJUnitJupiter(libs.versions.junit)
        }
    }
}
Kotlin DSL: tidepool-engine/build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    api(project(":tidepool-model"))
    implementation(libs.commons.csv)
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
}

testing {
    suites {
        named<JvmTestSuite>("test") {
            useJUnitJupiter(libs.versions.junit)
        }
    }
}
Groovy DSL: tidepool-engine/build.gradle
plugins {
    id 'java-library'
}

dependencies {
    api project(':tidepool-model')
    implementation libs.commons.csv
}

tasks.withType(JavaCompile).configureEach {
    options.release = 21
}

testing {
    suites {
        test {
            useJUnitJupiter(libs.versions.junit)
        }
    }
}
Kotlin DSL: tidepool-cli/build.gradle.kts
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation(libs.picocli)
}

application {
    mainClass = "io.tidepool.cli.Main"
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
}
Groovy DSL: tidepool-cli/build.gradle
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation libs.picocli
}

application {
    mainClass = 'io.tidepool.cli.Main'
}

tasks.withType(JavaCompile).configureEach {
    options.release = 21
}

(The same three lines options.release = 21 appear in every script. That repetition is deliberate: the ways to remove it, and what each of them costs, are the subject of "Configuring projects from the root" below.) Run with the Kotlin and then the Groovy scripts, ./gradlew build built all three projects, ran the two test classes, and ./gradlew :tidepool-cli:run printed the same station:

./gradlew :tidepool-cli:run --args="../data/stations.csv SF" -q
San Francisco: -0.869 m

(The decimal separator follows the locale of the JVM; the run used -Duser.language=en -Duser.country=US.)

A project dependency does two things, and the user manual puts them in one sentence: the required project is built first, and its compiled classes and its transitive dependencies are added to the classpath of the consuming project. Starting from a clean tree, one task of the command line application shows the order:

./gradlew clean -q
./gradlew :tidepool-cli:compileJava
Calculating task graph as no cached configuration is available for tasks: :tidepool-cli:compileJava
> Task :tidepool-model:compileJava
> Task :tidepool-engine:compileJava
> Task :tidepool-cli:compileJava

BUILD SUCCESSFUL in 941ms
3 actionable tasks: 3 executed
Configuration cache entry stored.

Nothing in the command line named the model or the engine. Gradle followed the dependencies from tidepool-cli, found that its compile classpath contains the classes of the engine and, through the engine’s api dependency, those of the model, and scheduled their compilation first. No jar was built: the compile classpath of a java-library project uses the classes directory of its dependency, and only the runtime classpath uses the jar. That is why the :jar tasks appear when something needs a runtime classpath (running the application, running the tests of the engine), not when something only compiles. The classpaths themselves are shown by the dependencies task, here for the compile and the runtime classpath of the command line application:

./gradlew :tidepool-cli:dependencies --configuration compileClasspath
./gradlew :tidepool-cli:dependencies --configuration runtimeClasspath
compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-engine'
|    \--- project ':tidepool-model'
\--- info.picocli:picocli:4.7.7

runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-engine'
|    +--- project ':tidepool-model'
|    \--- 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

(Each output was cut to its tree, and the A web-based, searchable dependency report hint that follows it was left out.) This is the difference between api and implementation made visible. The model is on the compile classpath of the command line application because the engine declared it with api; Commons CSV, declared with implementation in the engine, is a runtime detail of the engine and absent from the compile classpath of its consumers, so the application cannot import it by accident. Both declarations, the configurations behind them and dependencyInsight are the subject of Declaring Dependencies & Configurations.

Type-safe project accessors

The path of project(":tidepool-model") is a string, so a typo or a renamed project is found only when the build is configured. The type-safe project accessors replace the string with a generated accessor, projects.tidepoolModel, which an IDE completes and the compiler checks. They are an incubating feature and must be switched on in the settings file before they exist. Without the switch the Kotlin DSL does not know the name, and the message is not about the missing feature:

e: file://<work>/tidepool-cli/build.gradle.kts:6:20: Unresolved reference. None of the following candidates is applicable because of a receiver type mismatch:
val TaskContainer.projects: TaskProvider<ProjectReportTask>

The Groovy DSL fails with Could not get unknown property 'tidepoolEngine' for task ':tidepool-cli:projects' of type org.gradle.api.tasks.diagnostics.ProjectReportTask. In both languages projects resolved to the projects task described above. With the feature preview enabled, both DSLs compile and build:

Kotlin DSL: settings.gradle.kts (added line) and tidepool-cli/build.gradle.kts
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

// tidepool-cli/build.gradle.kts
dependencies {
    implementation(projects.tidepoolEngine)
    implementation(libs.picocli)
}
Groovy DSL: settings.gradle (added line) and tidepool-cli/build.gradle
enableFeaturePreview('TYPESAFE_PROJECT_ACCESSORS')

// tidepool-cli/build.gradle
dependencies {
    implementation projects.tidepoolEngine
    implementation libs.picocli
}
Type-safe project accessors is an incubating feature.
> Task :tidepool-model:compileJava
> Task :tidepool-engine:compileJava
> Task :tidepool-cli:compileJava

BUILD SUCCESSFUL in 1s

The hyphenated project name became a camel-case accessor. The rest of this page keeps the string form, which works without any switch.

A Project object is not a dependency notation

Since Gradle 9.6, handing a Project object to a dependency configuration is deprecated: val model = project(":tidepool-model") outside a dependencies block returns the Project, and api(model) then uses it as a notation. The build works and prints a warning (shown here for the Kotlin DSL; the Groovy DSL printed the same message):

Using a Project object as a dependency notation has been deprecated. This will fail with an error in Gradle 10. Please use the project(String) method on DependencyHandler or the createProjectDependency(String) method on DependencyFactory instead. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_9.html#dependency_project_notation

Inside the dependencies block project(":tidepool-model") is the method of the dependency handler and is fine.

Running tasks across projects

Which tasks run, and in which projects, is decided by the path on the command line. The selectors (test in every project that has it, :tidepool-cli:run for one) are documented with their variations on Command-Line Interface. Three things are specific to multi-project builds and are shown here.

From a subdirectory, and with -p

Gradle looks for the settings file in the working directory and then in its parents. A command run inside a subproject therefore still runs in the whole build, and the task names that are not qualified are resolved against the project of the working directory:

cd tidepool-engine
../gradlew test
> Task :tidepool-engine:processTestResources NO-SOURCE
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-model:classes UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-engine:classes UP-TO-DATE
> Task :tidepool-model:jar
> Task :tidepool-engine:compileTestJava
> Task :tidepool-engine:testClasses
> Task :tidepool-engine:test

BUILD SUCCESSFUL in 2s
5 actionable tasks: 3 executed, 2 up-to-date

(The first line of the output, about the task graph, was left out.) Only the test task of the engine ran; the test of the model did not. The model contributed what the engine needs to run its tests: its classes and, because a test runs on the runtime classpath, its jar. ./gradlew -p tidepool-engine test from the root does the same without changing directory, and so does ./gradlew :tidepool-engine:test; the three spellings differ only in where the command is typed.

buildNeeded and buildDependents

The Java plugins give every project two lifecycle tasks whose names say what they do: buildNeeded builds the project and every project it depends on, buildDependents builds the project and every project that depends on it. They are the Gradle counterparts of mvn -am and mvn -amd. The difference to build is the tests of the other projects. A dry run of the three tasks for the engine and the model, with the list cut to the tasks that name the lifecycle (the order of tasks that do not depend on each other is not fixed and differs between runs):

./gradlew :tidepool-engine:build --dry-run | grep -E ':(test|build|buildNeeded|buildDependents) '
./gradlew :tidepool-engine:buildNeeded --dry-run | grep -E ':(test|build|buildNeeded|buildDependents) '
./gradlew :tidepool-model:buildDependents --dry-run | grep -E ':(test|build|buildNeeded|buildDependents) '
:tidepool-engine:test SKIPPED
:tidepool-engine:build SKIPPED

:tidepool-engine:test SKIPPED
:tidepool-engine:build SKIPPED
:tidepool-model:test SKIPPED
:tidepool-model:build SKIPPED
:tidepool-model:buildNeeded SKIPPED
:tidepool-engine:buildNeeded SKIPPED

:tidepool-cli:test SKIPPED
:tidepool-cli:build SKIPPED
:tidepool-cli:buildDependents SKIPPED
:tidepool-engine:test SKIPPED
:tidepool-engine:build SKIPPED
:tidepool-engine:buildDependents SKIPPED
:tidepool-model:test SKIPPED
:tidepool-model:build SKIPPED
:tidepool-model:buildDependents SKIPPED

(The three outputs are separated by blank lines here.) :tidepool-engine:build tests only the engine; buildNeeded adds the build, and thus the tests, of the model; buildDependents of the model adds the engine and the command line application. Both tasks are deprecated since Gradle 9.6 and scheduled for removal in Gradle 10, because their implementation is incompatible with configuration on demand and with Isolated Projects and too rigid for the range of uses. Running one prints the warning, with --warning-mode=all for the text:

> Task :tidepool-model:build
> Task :tidepool-model:buildNeeded
The buildNeeded task has been deprecated. This is scheduled to be removed in Gradle 10. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_9.html#deprecate_build_needed_build_dependents_tasks
> Task :tidepool-engine:build
> Task :tidepool-engine:buildNeeded

The upgrade guide names the replacements: run the task you need directly (./gradlew :tidepool-engine:build and let Gradle resolve its dependencies), run a task by name in every project (./gradlew test) and rely on up-to-date checks and the build cache to skip the projects that did not change, use test report aggregation to run the tests of all dependencies, or, in build logic, use artifact views. ./gradlew :tidepool-model:build :tidepool-engine:build is the explicit spelling of what buildNeeded did for the engine.

Work avoidance across projects

The reason to split a build is that Gradle then skips the projects an edit cannot have affected. With the three projects built and up to date, an edit to the body of a method in the engine (no change to any signature) recompiled and repackaged the engine, and nothing else:

./gradlew build | grep -E 'compileJava|:jar|BUILD|actionable'
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-model:jar UP-TO-DATE
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:jar
> Task :tidepool-cli:compileJava UP-TO-DATE
> Task :tidepool-cli:jar UP-TO-DATE
BUILD SUCCESSFUL in 1s
13 actionable tasks: 6 executed, 7 up-to-date

The command line application was not recompiled even though it depends on the engine, because the public interface of the engine’s classes did not change (compile avoidance); the six executed tasks are the compilation and jar of the engine, its tests, and the packaging tasks of the application that take the engine jar into the distribution. After adding a method to a class of the model (a change to its public interface), every project that compiles against the model was recompiled:

> Task :tidepool-model:compileJava
> Task :tidepool-model:jar
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:jar UP-TO-DATE
> Task :tidepool-cli:compileJava
> Task :tidepool-cli:jar UP-TO-DATE
BUILD SUCCESSFUL in 1s
13 actionable tasks: 11 executed, 2 up-to-date

The jars of the engine and the application were up to date because recompiling their unchanged sources produced identical classes. A single project holding the same sources has no such boundaries, which is the first of the user manual’s reasons to modularize. How Gradle decides that a task is up to date is on Incremental Build. The user manual’s advice on where to cut is to split early and along natural lines (API and implementation, front end and back end, core logic and UI, vertical feature slices, the inputs of a code generator and its users), and to prefer more projects to fewer; projects containing a single class are the extreme at which this stops paying off.

Parallel execution and configuration on demand

Two switches make a multi-project build faster, and both are covered elsewhere: org.gradle.parallel lets independent tasks run at the same time (see Build Environment & Properties), and configuration on demand configures only the projects needed for the requested tasks (see Build Lifecycle). With the configuration cache enabled, as in every example here, independent tasks already run in parallel, within a project as well as across projects, and --no-parallel has no effect (see Worker API & Build Services), so the build logic of the projects must not rely on the order of tasks that are not connected by a dependency.

Configuring projects from the root

The three options.release = 21 blocks are the smallest possible example of a problem every multi-project build has: what the projects have in common. The oldest answer is to configure the subprojects from the root script, with allprojects { } (the root and all subprojects), subprojects { } (all subprojects) or project(":tidepool-cli") { } (one of them). It is shown here because the existing builds are full of it, and because its failure modes explain why the manual steers away from it.

The root script runs first

The configuration phase evaluates the root script before the scripts of the subprojects (see Build Lifecycle). A subprojects { } block that reaches into something a plugin of the subproject creates therefore finds nothing yet. This root script adds a JUnit dependency to every subproject:

Kotlin DSL: build.gradle.kts
plugins {
    base
}

subprojects {
    dependencies {
        "testImplementation"("org.junit.jupiter:junit-jupiter:6.1.3")
    }
}
Groovy DSL: build.gradle
plugins {
    id 'base'
}

subprojects {
    dependencies {
        testImplementation 'org.junit.jupiter:junit-jupiter:6.1.3'
    }
}
* What went wrong:
Configuration with name 'testImplementation' not found.

The Groovy DSL fails in the same place with Could not find method testImplementation() for arguments [org.junit.jupiter:junit-jupiter:6.1.3] on object of type org.gradle.api.internal.artifacts.dsl.dependencies.DefaultDependencyHandler. The testImplementation configuration is created by the Java plugin, which the subprojects apply in their own scripts, after this block ran. The cure is to react to the plugin instead of assuming it:

Kotlin DSL: build.gradle.kts
plugins {
    base
}

subprojects {
    plugins.withType<JavaPlugin> {
        tasks.withType<JavaCompile>().configureEach {
            options.release = 21
        }
        println("${project.path}: java plugin applied, release configured")
    }
}
Groovy DSL: build.gradle
plugins {
    id 'base'
}

subprojects {
    plugins.withType(JavaPlugin) {
        tasks.withType(JavaCompile).configureEach {
            options.release = 21
        }
        println "${project.path}: java plugin applied, release configured"
    }
}
> Configure project :tidepool-cli
:tidepool-cli: java plugin applied, release configured
> Configure project :tidepool-engine
:tidepool-engine: java plugin applied, release configured
> Configure project :tidepool-model
:tidepool-model: java plugin applied, release configured
BUILD SUCCESSFUL in 4s

The build works, and the subprojects were configured in alphabetical order of their names, not in the order of the include call. It also shows the cost of the style: the build script of each project no longer says everything about that project, a reader of tidepool-model/build.gradle.kts has to know that the root adds the compiler option, and the order in which scripts are evaluated has become part of the build’s contract.

Why the manual discourages it

The user manual’s chapter on sharing build logic calls subprojects { } and allprojects { } an improper way to share build logic: the logic injected into a subproject is not obvious from its build script, it usually grows in complexity and becomes a burden, and it introduces configuration-time coupling between projects, which can stop optimizations such as configuration on demand from working. It names two common uses and their replacements. Applying plugins or configuration to the subprojects of a certain type is better modelled by a convention plugin, which each such project applies with one line, so that a project’s script states its own conventions (see Convention Plugins & Sharing Build Logic). Extracting information from subprojects of a certain type is better modelled with outgoing configuration variants, which is the subject of the next section.

The coupling is not only a matter of taste. With Isolated Projects (incubating, see below) the projects of a build must not reach into each other’s mutable state, and the root script above is exactly that. This root script configures the compile tasks of the subprojects directly:

Kotlin DSL: build.gradle.kts
plugins {
    base
}

subprojects {
    tasks.withType<JavaCompile>().configureEach {
        options.release = 21
    }
}
Groovy DSL: build.gradle
plugins {
    id 'base'
}

subprojects {
    tasks.withType(JavaCompile).configureEach {
        options.release = 21
    }
}

Run with the flag --isolated-projects, it failed:

./gradlew build --isolated-projects
Isolated Projects is an incubating feature.
Calculating task graph as no cached configuration is available for tasks: build
1 problem was found storing the configuration cache.
- Build file 'build.gradle.kts': Project ':' cannot access 'Project.tasks' functionality on subprojects
...
FAILURE: Build failed with an exception.
* Where:
Build file '<work>/build.gradle.kts' line: 6
* What went wrong:
Project ':' cannot access 'Project.tasks' functionality on subprojects
...
BUILD FAILED in 1s
Configuration cache entry discarded with 1 problem.

(Report locations and the hints at the end were left out.) The Groovy DSL reported the same problem at line 6 of build.gradle. The project(":x") form fails in the same way when it is used from another project’s script, as shown in "Sharing outputs between projects" below.

Gradle’s other recommendation for the root is the one the example follows: it should not contain source files, and plugins that only affect source code should not be applied to it. The root project is the entry point that tells Gradle which projects exist, and a root project with the java-library plugin and its own src/main mixes that role with a module’s. Likewise, every project the settings file includes should be intended. include(":subs:features:search") creates the projects :subs and :subs:features as well, empty ones, which clutter the reports and receive whatever an allprojects { } or subprojects { } block applies; the user manual advises mapping projectDir so that only the intended project exists.

The implicit lookup in parent projects

The other implicit coupling is about names. When a property or method is not found in a project, Gradle looks in its parent, then in the grandparent, up to the root. Since Gradle 9.6 that walk emits a deprecation warning, and Gradle 10 will remove it. The warning, and the cases it covers (findProperty, property, hasProperty, bare names and the Kotlin by project delegate), are shown on Build Environment & Properties. The feature preview that switches to the Gradle 10 behaviour early is the part that belongs here. The root declared extra["codename"] = "spring-tide" (Groovy: ext.codename = 'spring-tide') and the engine’s script ran println("engine sees codename = ${findProperty("codename")}"). First without, then with the preview in the settings file:

enableFeaturePreview("NO_IMPLICIT_LOOKUP_IN_PARENT_PROJECTS")
> Configure project :tidepool-engine
Implicit lookup of properties in parent projects has been deprecated. This will fail with an error in Gradle 10. Property 'codename' was not declared in project ':tidepool-engine' and was resolved from root project 'tidepool'. This lookup was initiated by 'findProperty()'. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_9.html#deprecated_implicit_lookup_in_parent_projects
	at Build_gradle.<init>(build.gradle.kts:22)
	(Run with --stacktrace to get the full stack trace of this deprecation warning.)
engine sees codename = spring-tide
...
> Configure project :tidepool-engine
engine sees codename = null

(The Groovy DSL printed the same two results. The second block is the run with the preview enabled.) With the preview on there is no walk: findProperty returns null, and the property must be passed explicitly. The upgrade guide gives the options: build-wide values in the root gradle.properties read with providers.gradleProperty, shared logic in a convention plugin, or an explicit rootProject.extra, which preserves the coupling and is not compatible with Isolated Projects. Under Isolated Projects the implicit lookup does not happen at all.

Sharing outputs between projects

Sometimes a project needs a file another project produces: not its classes, but a generated index, a schema, a report. The tempting way is the task-to-task wire, which the manual discourages because it couples two projects through the internals of one of them:

Kotlin DSL: tidepool-cli/build.gradle.kts, the discouraged form
tasks.register<Copy>("copyStationIndex") {
    dependsOn(":tidepool-model:stationIndex")
    from(project(":tidepool-model").layout.buildDirectory.dir("station-index"))
    into(layout.buildDirectory.dir("station-index"))
}
Groovy DSL: tidepool-cli/build.gradle, the discouraged form
tasks.register('copyStationIndex', Copy) {
    dependsOn(':tidepool-model:stationIndex')
    from(project(':tidepool-model').layout.buildDirectory.dir('station-index'))
    into(layout.buildDirectory.dir('station-index'))
}

It works: the model’s stationIndex task ran, then the copy. But the consumer knows the producer’s task name and the producer’s output directory, and under Isolated Projects it is a violation:

1 problem was found storing the configuration cache.
- Build file 'tidepool-cli/build.gradle.kts': Project ':tidepool-cli' cannot access 'Project.layout' functionality on another project ':tidepool-model'
...
* What went wrong:
A problem occurred configuring project ':tidepool-cli'.
> Could not create task ':tidepool-cli:copyStationIndex'.
   > Project ':tidepool-cli' cannot access 'Project.layout' functionality on another project ':tidepool-model'

(Groovy: Build file 'tidepool-cli/build.gradle': line 21: Project ':tidepool-cli' cannot access 'Project.layout' functionality on another project ':tidepool-model'.)

The supported way is the one project dependencies already use: the producer publishes the file as an outgoing variant with attributes that describe it, and the consumer asks for a variant with matching attributes. Gradle then picks the file, and runs the task that produces it, without either project naming the other’s tasks or directories.

flowchart LR subgraph producer ["tidepool-model (producer)"] t["task stationIndex"] --> f["build/station-index/stations.txt"] f --> v["consumable stationIndexElements: usage = tidepool-station-index"] end subgraph consumer ["tidepool-cli (consumer)"] d["dependencyScope stationIndexDeps: project(':tidepool-model')"] --> r["resolvable stationIndexFiles: usage = tidepool-station-index"] r --> c["Copy task copyStationIndex"] end v -.->|"selected by matching attributes"| r

The producer. The stationIndex task writes one station id per line of data/stations.csv; the consumable configuration stationIndexElements exposes its output, tagged with a custom value of the standard Usage attribute so that it matches nothing else the model project offers (its Java variants have the usages java-api and java-runtime):

Kotlin DSL: tidepool-model/build.gradle.kts (added)
val stationIndex = tasks.register("stationIndex") {
    val csv = layout.settingsDirectory.file("data/stations.csv")
    val index = layout.buildDirectory.file("station-index/stations.txt")
    inputs.file(csv)
    outputs.file(index)
    doLast {
        val ids = csv.asFile.readLines().drop(1).map { it.substringBefore(",") }
        index.get().asFile.apply { parentFile.mkdirs() }.writeText(ids.joinToString("\n", postfix = "\n"))
    }
}

val stationIndexUsage = objects.named<Usage>("tidepool-station-index")

configurations.consumable("stationIndexElements") {
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, stationIndexUsage)
    }
    outgoing.artifact(layout.buildDirectory.file("station-index/stations.txt")) {
        builtBy(stationIndex)
    }
}
Groovy DSL: tidepool-model/build.gradle (added)
def stationIndex = tasks.register('stationIndex') {
    def csv = layout.settingsDirectory.file('data/stations.csv')
    def index = layout.buildDirectory.file('station-index/stations.txt')
    inputs.file(csv)
    outputs.file(index)
    doLast {
        def ids = csv.asFile.readLines().drop(1).collect { it.substring(0, it.indexOf(',')) }
        def out = index.get().asFile
        out.parentFile.mkdirs()
        out.text = ids.join('\n') + '\n'
    }
}

configurations.consumable('stationIndexElements') {
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, 'tidepool-station-index'))
    }
    outgoing.artifact(layout.buildDirectory.file('station-index/stations.txt')) {
        builtBy(stationIndex)
    }
}

The consumer separates the two roles of a configuration, as the manual recommends: stationIndexDeps only declares the dependency (it cannot be resolved), and stationIndexFiles extends it, carries the requested attributes and is what a task reads. A Copy task takes the resolved files as its source, and because the files come from a configuration, Gradle adds the task dependency on the producer’s task by itself:

Kotlin DSL: tidepool-cli/build.gradle.kts (added)
val stationIndexDeps = configurations.dependencyScope("stationIndexDeps").get()
val stationIndexFiles = configurations.resolvable("stationIndexFiles") {
    extendsFrom(stationIndexDeps)
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named<Usage>("tidepool-station-index"))
    }
}

dependencies {
    stationIndexDeps(project(":tidepool-model"))
}

tasks.register<Copy>("copyStationIndex") {
    from(stationIndexFiles)
    into(layout.buildDirectory.dir("station-index"))
}
Groovy DSL: tidepool-cli/build.gradle (added)
def stationIndexDeps = configurations.dependencyScope('stationIndexDeps')
def stationIndexFiles = configurations.resolvable('stationIndexFiles') {
    extendsFrom(stationIndexDeps.get())
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, 'tidepool-station-index'))
    }
}

dependencies {
    add('stationIndexDeps', project(':tidepool-model'))
}

tasks.register('copyStationIndex', Copy) {
    from(stationIndexFiles)
    into(layout.buildDirectory.dir('station-index'))
}
./gradlew :tidepool-cli:copyStationIndex
cat tidepool-cli/build/station-index/stations.txt
> Task :tidepool-model:stationIndex
> Task :tidepool-cli:copyStationIndex

BUILD SUCCESSFUL in 5s
2 actionable tasks: 2 executed
Configuration cache entry stored.
SF
BOS
HNL

The command named only the consumer’s task, and the model’s stationIndex ran first; a second run reported both tasks UP-TO-DATE and reused the configuration cache entry. Both DSLs were also run with --isolated-projects, and both passed. The two diagnostic tasks show the variant from each side:

./gradlew :tidepool-model:outgoingVariants --variant stationIndexElements
./gradlew :tidepool-cli:resolvableConfigurations --configuration stationIndexFiles
--------------------------------------------------
Variant stationIndexElements
--------------------------------------------------
Capabilities
    - io.tidepool:tidepool-model:1.0.0 (default capability)
Attributes
    - org.gradle.usage = tidepool-station-index
Artifacts
    - build/station-index/stations.txt (artifactType = txt)
--------------------------------------------------
Configuration stationIndexFiles
--------------------------------------------------
Attributes
    - org.gradle.usage = tidepool-station-index
Extended Configurations
    - stationIndexDeps
...

(The second report continues with the compatibility and disambiguation rules of the attributes, which were left out.) How Gradle matches the attributes, what happens when more than one variant matches, and the compatibility rules are the subject of Variant-Aware Resolution & Module Metadata.

Converting a single-project build

Most multi-project builds start as one project. The conversion is mechanical, and the user manual describes it as: create a directory for each new subproject and move its sources there, list the directories in the settings file, give each subproject its own build script holding the dependencies and plugins only it needs, and then modify or delete the root script. The tidepool build below starts as a single project with all packages in one source tree, one build.gradle.kts with the application plugin, and the dependencies of the engine and the command line application side by side:

.
├── build.gradle.kts
├── settings.gradle.kts            (rootProject.name = "tidepool", no include)
├── data/stations.csv
└── src
    ├── main/java/io/tidepool/{model,engine,cli}/...
    └── test/java/io/tidepool/{model,engine}/...
Kotlin DSL: build.gradle.kts of the single project
plugins {
    application
}

dependencies {
    implementation(libs.commons.csv)
    implementation(libs.picocli)
}

application {
    mainClass = "io.tidepool.cli.Main"
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
}

testing {
    suites {
        named<JvmTestSuite>("test") {
            useJUnitJupiter(libs.versions.junit)
        }
    }
}
Groovy DSL: build.gradle of the single project
plugins {
    id 'application'
}

dependencies {
    implementation libs.commons.csv
    implementation libs.picocli
}

application {
    mainClass = 'io.tidepool.cli.Main'
}

tasks.withType(JavaCompile).configureEach {
    options.release = 21
}

testing {
    suites {
        test {
            useJUnitJupiter(libs.versions.junit)
        }
    }
}
./gradlew build
./gradlew run --args="data/stations.csv SF" -q
./gradlew projects -q
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :compileJava
> Task :classes
> Task :jar
> Task :compileTestJava
> Task :testClasses
> Task :startScripts
> Task :distTar
> Task :distZip
> Task :assemble
> Task :test
> Task :check
> Task :build
BUILD SUCCESSFUL in 5s
7 actionable tasks: 7 executed
...
San Francisco: -0.869 m
...
Root project 'tidepool'
No sub-projects

The conversion itself, with the sources moved and the settings file extended (shown for the Kotlin DSL; the Groovy conversion used include 'tidepool-model', 'tidepool-engine', 'tidepool-cli' in settings.gradle and build.gradle files):

for m in model engine cli; do
  mkdir -p tidepool-$m/src/main/java/io/tidepool
  mv src/main/java/io/tidepool/$m tidepool-$m/src/main/java/io/tidepool/
done
for m in model engine; do
  mkdir -p tidepool-$m/src/test/java/io/tidepool
  mv src/test/java/io/tidepool/$m tidepool-$m/src/test/java/io/tidepool/
done
rm -r src
echo 'include("tidepool-model", "tidepool-engine", "tidepool-cli")' >> settings.gradle.kts
rm build.gradle.kts           # then write one script per project, as shown above

After the three scripts of the section "Project dependencies" were written, ./gradlew build ran 13 tasks (BUILD SUCCESSFUL, 13 actionable tasks, 13 executed), and ./gradlew :tidepool-cli:run --args="../data/stations.csv SF" -q printed the same San Francisco: -0.869 m, with the Groovy conversion giving the same results. Three points about it:

  • The run task works in the directory of its project, so the relative path of the data file changed from data/stations.csv to ../data/stations.csv. Paths relative to the root are better taken from layout.settingsDirectory than from the working directory (see Working with Files).

  • There is exactly one settings file; the user manual says there should be no other.

  • The root script is optional. The converted build ran without one, and base is only added to the root when it should have lifecycle tasks of its own.

Isolated Projects constraints

Isolated Projects is the feature that the examples above kept hitting. It runs on top of the configuration cache and lets Gradle configure the projects in parallel and cache each project’s configuration on its own, which needs the projects not to read or change each other. It is incubating since Gradle 9.7 and is enabled with org.gradle.isolated-projects=true in gradle.properties or --isolated-projects on the command line (the unsafe spellings of the property are deprecated since 9.7). The rules that matter to the structure of a multi-project build are short:

  • A project’s build logic must not use the mutable state of another project: project(":x").layout, rootProject.tasks, subprojects { tasks... } and allprojects { } that touch other projects are violations (both shown above).

  • There is no implicit lookup of properties and methods in parent projects.

  • Dependencies between projects are fine, because a project dependency is data, not access; so is sharing outputs through variants, as the example above passed with --isolated-projects.

  • Convention plugins are the way to apply the same logic to every project, which is why they are the migration path.

The feature, its status, the diagnostics mode and the migration are on Configuration Cache & Isolated Projects.

Multi-project or composite

A multi-project build is one build: one settings file, one set of rules for repositories and plugins, one task graph, one configuration cache entry. When the parts have their own repositories, release cycles and settings, the manual’s guidance is to keep them as separate builds and combine them with includeBuild, which substitutes a local checkout for a published dependency, and the build logic of the projects as an included build too. That is the subject of Composite Builds. Publishing the projects of a multi-project build (and the 9.3 deprecation of publishing a project that depends on a project with no publication) is covered by Publishing.

Gradle 8.14 and Gradle 9

The structure of a multi-project build is the same in both lines. What changed between 8.14 and 9.8.1 is mostly deprecations that 9.x added around it (the 8.14 column is from the official documentation and release notes, not run here):

Topic Gradle 8.14 Gradle 9.8.1

Included project without a directory

Gradle allowed it silently (as the 9.0 documentation describes the earlier behaviour).

The build fails if the directory is missing or read-only (9.0.0).

Instances of Project

The same instance for the same logical project.

Referential equality is no longer guaranteed (9.3); compare paths.

buildNeeded, buildDependents

No deprecation.

Deprecated, to be removed in Gradle 10 (9.6); the warning was run above.

A Project object as a dependency notation

No deprecation.

Deprecated (9.6); the warning was run above.

Implicit lookup of properties in parent projects

No warning.

Warning (9.6) and the feature preview NO_IMPLICIT_LOOKUP_IN_PARENT_PROJECTS; removed in Gradle 10.

Type-safe project accessors

Incubating; enabled with a feature preview.

Still incubating and still a feature preview (run above).

Isolated Projects

Pre-alpha; enabled with -Dorg.gradle.unsafe.isolated-projects=true.

Incubating (9.7); org.gradle.isolated-projects or --isolated-projects (run above).

Configuration cache

Stable since 8.1, opt in.

Preferred mode (9.0), still opt in; the examples above enabled it.

Equivalents in Ant and Maven

Gradle Maven Ant

Projects declared by include in settings.gradle(.kts)

Modules declared by <modules> in the aggregator POM; see Inheritance, Aggregation & the Effective POM.

No project tree: build files call each other with subant, ant or import; see Multi-File Builds: import, include & subant.

Order from the dependencies between projects

The reactor sorts the modules by their dependencies; see Multi-Module Builds & the Reactor.

The order of the subant calls, or Ivy’s buildlist; see Ivy: Publishing & Multi-Project Builds.

:tidepool-engine:build

mvn -pl tidepool-engine verify, which resolves the sibling modules from the local repository; add -am to build them in the same run.

ant -f tidepool-engine/build.xml.

buildNeeded (deprecated)

-am, --also-make.

No equivalent.

buildDependents (deprecated)

-amd, --also-make-dependents.

No equivalent.

./gradlew test (a selector in every project)

mvn test from the aggregator.

Calling the same target in every directory with subant.

--continue

-fae, --fail-at-end.

failonerror="false" on the subant.

No resume option: run the build again, and the up-to-date checks and the build cache skip the finished work

-rf, --resume-from, and -r in Maven 4.

No equivalent.

Project dependency project(":x")

A dependency on the module’s coordinates; the reactor resolves it to the module’s output.

A classpath that points to the other project’s jar or build directory.

Convention plugins and allprojects/subprojects

Parent POM inheritance and <pluginManagement>.

import of a shared build file.

Where the books are outdated

Gradle in Action (Muschko, Manning 2014) was written for Gradle 1.7 and has a chapter on multi-project builds. The model it teaches (a root and subprojects, settings.gradle with include, dependencies between projects) is still correct. What changed around it:

  • compile project(':x'). The compile, runtime, testCompile and testRuntime configurations were removed in Gradle 7.0. A project dependency is declared with implementation or api (run above).

  • Configuration injection from the root. allprojects { } and subprojects { } were the way to share configuration. They still work, but the manual now recommends convention plugins, and Isolated Projects rejects them (run above).

  • buildNeeded and buildDependents. They date from Gradle 0.8 and were in the book’s era the way to build a project and what it needs. They are deprecated since Gradle 9.6.

  • The project object used freely at execution time. The configuration cache, preferred since Gradle 9.0, does not allow it, and the cross-project access of Isolated Projects does not either.

For the same topics in the other tools see Maven Multi-Module Builds & the Reactor and Ant Multi-File Builds.

Where to go next