Gradle Multi-Project Builds, Composite Builds, Included Builds, and Build Structure Design: Guided Hands-On Workflow and Core Operations
Build a disposable workspace with subprojects, an included build for reusable conventions, and an independently versioned library build whose published coordinates are substituted by local source.
Learning objectives
-
Create a disposable root build with
:coreand:appsubprojects and prove their project dependency graph. -
Move repeated Java settings into a small
build-logicconvention-plugin included build. -
Create an independently versioned
external-libbuild and consume it by module coordinates through composite substitution. - Inspect project, dependency, substitution, and task state before and after each structural change.
- Run targeted tasks in the main build and invoke one included-build task through the supported included-build API.
- Explain which files/caches each command reads or mutates and finish with a boundary-choice challenge.
1. Lab outcome and safety boundary
You will create gradle-structure-lab. The main build
owns two subprojects, :core and :app. An
explicit build-logic included build supplies a Java
convention plugin. A second independent build named
external-lib declares the module identity
dev.academy:external-lib:1.0.0; the app declares that
module coordinate and the composite substitutes the local source
build.
The lab uses only source directories beneath the lab root and an
isolated GRADLE_USER_HOME. Do not delete or repurpose
normal ~/.gradle state.
2. Preflight: trusted Wrapper and runtime identity
Start from the verified Gradle 9.7.1 Wrapper produced in Chapter 15, or copy that trusted wrapper into this disposable lab. Do not bootstrap a wrapper by executing an unreviewed build script.
cd gradle-structure-lab
export GRADLE_USER_HOME="$PWD/.gradle-user-home"
./gradlew --version
java -version
printf 'GRADLE_USER_HOME=%s
' "$GRADLE_USER_HOME"
Expected course baseline: Gradle 9.7.1, JDK 21 running Gradle, Java
17 target later in convention logic. --version reads
the wrapper-selected distribution and runtime state; it should not
mutate project topology.
3. Create one build with two subprojects
mkdir -p core/src/main/java/dev/academy/core app/src/main/java/dev/academy/app
cat > settings.gradle.kts <<'EOF'
rootProject.name = "gradle-structure-lab"
include("core", "app")
EOF
cat > build.gradle.kts <<'EOF'
// Root build intentionally contains no cross-project configuration.
EOF
// core/build.gradle.kts
plugins { `java-library` }
java {
toolchain { languageVersion = JavaLanguageVersion.of(17) }
}
// app/build.gradle.kts
plugins { application }
java {
toolchain { languageVersion = JavaLanguageVersion.of(17) }
}
dependencies {
implementation(project(":core"))
}
application { mainClass = "dev.academy.app.App" }
// core/src/main/java/dev/academy/core/Message.java
package dev.academy.core;
public final class Message {
private Message() {}
public static String text() { return "core"; }
}
// app/src/main/java/dev/academy/app/App.java
package dev.academy.app;
import dev.academy.core.Message;
public final class App {
public static void main(String[] args) {
System.out.println(Message.text());
}
}
The include() calls mutate the settings model and
create project descriptors.
implementation(project(":core")) creates a project
dependency edge; it does not publish or resolve a repository module.
4. Inspect the main project hierarchy and targeted graph
mkdir -p evidence
./gradlew projects | tee evidence/projects.before.txt
./gradlew :app:dependencies --configuration runtimeClasspath | tee evidence/app-deps.before.txt
./gradlew :app:build --dry-run | tee evidence/app-dry-run.before.txt
./gradlew :core:build
Expected: projects lists :core and
:app. The app dependency report shows a project
dependency on :core. Running
:core:build should not schedule the app merely because
both are subprojects. Conversely, :app:build can
schedule required producer work from :core.
5. Extract Java policy into an explicit build-logic included build
Create a separate plugin build. Because the convention plugin is
resolved by the project plugins {} blocks, include the
plugin build from pluginManagement:
mkdir -p build-logic/src/main/kotlin
cat > build-logic/settings.gradle.kts <<'EOF'
pluginManagement { repositories { gradlePluginPortal() } }
rootProject.name = "build-logic"
EOF
cat > build-logic/build.gradle.kts <<'EOF'
plugins { `kotlin-dsl` }
repositories { gradlePluginPortal() }
EOF
cat > build-logic/src/main/kotlin/dev.academy.java-conventions.gradle.kts <<'EOF'
plugins { java }
java {
toolchain { languageVersion = JavaLanguageVersion.of(17) }
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
}
EOF
Now replace the root settings file and subproject plugin/toolchain blocks:
// settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
repositories { gradlePluginPortal() }
}
rootProject.name = "gradle-structure-lab"
include("core", "app")
// core/build.gradle.kts
plugins {
id("dev.academy.java-conventions")
`java-library`
}
// app/build.gradle.kts
plugins {
id("dev.academy.java-conventions")
application
}
dependencies {
implementation(project(":core"))
}
application { mainClass = "dev.academy.app.App" }
The build-logic directory is a complete build with its
own plugin dependencies/repositories. Applying its plugin changes
the project configuration model, but the convention code remains
separate from product source code and can be tested independently.
6. Add an independently versioned library build
mkdir -p external-lib/src/main/java/dev/academy/external
cat > external-lib/settings.gradle.kts <<'EOF'
rootProject.name = "external-lib"
EOF
cat > external-lib/build.gradle.kts <<'EOF'
plugins { `java-library` }
group = "dev.academy"
version = "1.0.0"
java {
toolchain { languageVersion = JavaLanguageVersion.of(17) }
}
EOF
cat > external-lib/src/main/java/dev/academy/external/ExternalMessage.java <<'EOF'
package dev.academy.external;
public final class ExternalMessage {
private ExternalMessage() {}
public static String text() { return "external-source"; }
}
EOF
This build is deliberately self-contained: it has its own settings
and build script and can run with the same Wrapper via
./gradlew -p external-lib build from the lab root (or
../gradlew build after changing into
external-lib/). In a real independent repository it
would normally carry its own verified Wrapper.
7. Compose the independent library and observe substitution
Add a top-level included build to the main settings file, separate from plugin-resolution inclusion:
// settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
repositories { gradlePluginPortal() }
}
rootProject.name = "gradle-structure-lab"
include("core", "app")
includeBuild("external-lib")
Update the app dependency and source:
// app/build.gradle.kts
dependencies {
implementation(project(":core"))
implementation("dev.academy:external-lib:1.0.0")
}
// app/src/main/java/dev/academy/app/App.java
package dev.academy.app;
import dev.academy.core.Message;
import dev.academy.external.ExternalMessage;
public final class App {
public static void main(String[] args) {
System.out.println(Message.text() + ":" + ExternalMessage.text());
}
}
./gradlew :app:dependencyInsight --dependency external-lib --configuration runtimeClasspath | tee evidence/external-insight.txt
./gradlew :app:run | tee evidence/app-run.txt
Expected output contains core:external-source. The app
still declares an external module coordinate; the composite build
supplies the source project through substitution. That is different
from rewriting the dependency as project().
8. Run targeted tasks across build boundaries
// root build.gradle.kts
tasks.register("checkExternal") {
group = "verification"
description = "Run check in the external-lib included build"
dependsOn(gradle.includedBuild("external-lib").task(":check"))
}
./gradlew :core:build
./gradlew :app:build --dry-run
./gradlew :app:build
./gradlew checkExternal
:core:build is a project-task path in the main build.
gradle.includedBuild("external-lib").task(":check")
explicitly references a task in another build. The two syntaxes are
intentionally different because the ownership boundaries are
different.
9. Inspect state and cache locations
printf '%s
' 'Main project state:'
ls -ld .gradle core/build app/build 2>/dev/null || true
printf '%s
' 'Included build state:'
ls -ld external-lib/.gradle external-lib/build build-logic/.gradle build-logic/build 2>/dev/null || true
printf '%s
' 'Isolated user home:'
ls -ld "$GRADLE_USER_HOME"
Each build has project-local state. They can share the isolated Gradle User Home during this invocation, but that shared cache does not merge their settings, project hierarchies, or release identities.
10. Challenge: choose the correct boundary
A team owns a library that must release independently and be
consumed by several repositories. Developers want source-level
co-development with one application. Should you move the library
under :app as a permanent subproject?
The stronger model is usually to preserve the library as an independent build/module coordinate and use a composite during co-development. A permanent project dependency is appropriate only if the team intentionally gives up the independent release boundary and moves to one build lifecycle.
11. Verification and cleanup
./gradlew projects
./gradlew :app:dependencyInsight --dependency external-lib --configuration runtimeClasspath
./gradlew :app:run
./gradlew checkExternal
cd ..
rm -rf gradle-structure-lab
Delete only the disposable lab directory. The command must never target the normal Gradle User Home. In a real repository, keep the evidence and source changes instead of deleting them.
Knowledge check
What changed when
implementation(project(":core")) was added?
A project dependency edge was added inside the main build; Gradle can schedule the producer project and consume its outgoing variant.
What changed when includeBuild("external-lib") was
added?
A second build entered the build tree and its matching module coordinates became eligible for composite dependency substitution.
Why keep the app dependency as
dev.academy:external-lib:1.0.0?
It preserves the published-module contract while allowing the composite to substitute local source during co-development.
Why does build-logic have its own repositories block?
It is a separate build and does not inherit dependency/plugin repositories from the main build.
Which command best proves why external-lib resolved from local source?
dependencyInsight on the app runtime classpath,
together with the composite settings.
Does targeting :core:build imply
:app must run?
No. A dependency edge from app to core points the other direction; building core alone does not require app.
Official references and version notes
- Gradle Multi-Project Builds — root/subproject structure, project paths, and project dependencies.
-
Gradle Composite Builds (Included Builds)
—
includeBuild(), task interaction, dependency substitution, isolation, limitations, and disabling substitution. - Declaring Dependencies — module dependencies versus project dependencies.
-
Best Practices for Structuring Builds
— current guidance favoring explicit
build-logicincluded builds for scalable build logic. - Convention Plugins — reusable project policy and included-build/buildSrc placement.
-
Settings File Basics
— root project identity and
include(). - Gradle 9.7.1 Release Notes — pinned chapter baseline.
Version-sensitive behavior was rechecked against current Gradle
documentation on 2026-08-24. The mandatory labs use Gradle core
plugins, the project Wrapper, JDK 21 as the Gradle runtime, Java 17
as the project target, and an isolated
GRADLE_USER_HOME. No hosted CI, commercial repository,
or paid service is required.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.