Chapter 21Lesson 02~245 minutes

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.

Gradle 9.7.1project()includeBuild()Substitutionbuild-logic

Learning objectives

  • Create a disposable root build with :core and :app subprojects and prove their project dependency graph.
  • Move repeated Java settings into a small build-logic convention-plugin included build.
  • Create an independently versioned external-lib build 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?

What changed when includeBuild("external-lib") was added?

Why keep the app dependency as dev.academy:external-lib:1.0.0?

Why does build-logic have its own repositories block?

Which command best proves why external-lib resolved from local source?

Does targeting :core:build imply :app must run?

Official references and version notes

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.