Chapter 21Lesson 04~205 minutes

Gradle Multi-Project Builds, Composite Builds, Included Builds, and Build Structure Design: Diagnostics, Failure Modes, Security, and Performance

Diagnose project-path mistakes, unexpected dependency substitution, task namespace errors, build-logic cycles, and configuration-cost growth without flattening independent builds into one mental model.

DiagnosticsTask pathsSubstitutionBuild logicPerformance

Learning objectives

  • Apply a consistent diagnostic sequence to build-structure failures before changing settings or caches.
  • Recognize the error signature of confusing a main-build project path with an included-build module coordinate.
  • Prove when composite substitution has changed dependency selection and disable it only in a controlled comparison configuration.
  • Diagnose incorrect task paths and understand build-tree task namespaces.
  • Repair circular/invalid build-logic coupling by restoring one-way ownership boundaries.
  • Measure configuration cost without using cache deletion or broad restructuring as a first response.

1. Diagnostic sequence for structure failures

  1. Preserve evidence: command, concise error, dependency insight, requested task path, and current settings files.
  2. Confirm identity: Wrapper Gradle version and runtime JDK.
  3. Inspect settings: which projects are included with include(); which builds are included with includeBuild().
  4. Inspect graph: project dependencies, module dependencies, substitution reasons, task dry-run.
  5. Inspect filesystem/cache: only after the model is understood; use a disposable user home if cache state is suspect.
  6. Apply the narrowest correction: fix a path, coordinate, substitution mapping, or ownership edge.
  7. Controlled rebuild: verify both the intended composite path and, where relevant, the published/no-substitution path.

2. Intentionally broken example: project path confused with module coordinate

Suppose external-lib is declared with includeBuild("external-lib"). This app dependency is wrong:

dependencies {
    implementation(project(":external-lib")) // wrong: not a subproject
}
set +e
./gradlew :app:dependencies --configuration runtimeClasspath > evidence/wrong-project-path.txt 2>&1
status=$?
set -e
printf 'wrong-project-path status=%s
' "$status"
sed -n '1,180p' evidence/wrong-project-path.txt

The expected failure is a project lookup/configuration error because :external-lib does not exist in the main project hierarchy. Preserve the error. Repair the dependency to implementation("dev.academy:external-lib:1.0.0") and let the composite substitute it.

3. Failure: local substitution changes an external dependency unexpectedly

A developer expects to test the repository-published JAR, but dependencyInsight reports a project from an included build. That is expected composite behavior when coordinates match.

First inspect:

./gradlew :app:dependencyInsight --dependency external-lib --configuration runtimeClasspath
sed -n '1,220p' settings.gradle.kts

If you need an explicit published-artifact comparison, create a separate resolvable configuration with global included-build substitution disabled. Do not disable substitution globally just to make one diagnostic pass.

// app/build.gradle.kts — diagnostic configuration
val publishedRuntimeClasspath by configurations.creating {
    isCanBeConsumed = false
    isCanBeResolved = true
    extendsFrom(configurations.runtimeClasspath.get())
    resolutionStrategy.useGlobalDependencySubstitutionRules = false
    attributes.attribute(
        org.gradle.api.attributes.Usage.USAGE_ATTRIBUTE,
        objects.named(org.gradle.api.attributes.Usage.JAVA_RUNTIME)
    )
}

Resolution of that configuration will now require an actual repository module. In the chapter’s local-only fixture, it is expected to fail unless you deliberately publish the artifact to a disposable repository. That failure proves the difference between composite source substitution and repository availability.

4. Failure: task path targets the wrong namespace

Main-build task paths look like :app:build. Do not invent a path such as :app:external-lib:build to cross into another build. Use an included-build task reference:

tasks.register("externalBuild") {
    dependsOn(gradle.includedBuild("external-lib").task(":build"))
}
./gradlew tasks --all | grep -E 'externalBuild|app:build|core:build' || true
./gradlew externalBuild

If a build path/name collision exists, Gradle requires unique build-tree paths; rename the included build in settings rather than relying on ambiguous abbreviations.

5. Failure: build logic depends back on product code

A convention-plugin build must not depend on :app merely to reuse an application class. It has a separate project hierarchy, and conceptually the dependency direction would be circular: app needs build logic before app can be configured/built, while build logic would need app output.

Repair options:

  • Move the reusable build-time abstraction into build-logic itself.
  • If it is genuinely shared runtime/library code, put it in an independent module with a clean publication contract and let both sides consume it only if that does not create a cycle.
  • Prefer Gradle public APIs/data types for convention configuration; do not import application runtime classes into build scripts.

6. Failure: included build identity does not match the requested coordinates

Automatic substitution uses project group/name. If external-lib forgets group = "dev.academy", the main dependency dev.academy:external-lib:1.0.0 may not be substituted. Inspect the included build identity and either align it with publication coordinates or add an explicit narrow substitution rule.

includeBuild("external-lib") {
    dependencySubstitution {
        substitute(module("dev.academy:external-lib")).using(project(":"))
    }
}

Aligning project identity with real publication coordinates is usually clearer because it lets default substitution work consistently.

7. Failure/performance: one giant build amplifies configuration work

Symptoms include slow help/projects commands, root scripts eagerly iterating every project, or automatic included-build substitution configuring many projects merely to discover what they provide. Preserve timing evidence before restructuring:

./gradlew help --profile
./gradlew :app:build --dry-run
./gradlew projects

Then inspect cross-project configuration and substitution discovery. Move shared policy into convention plugins; consider explicit substitution mappings when the included build is large and documentation indicates discovery overhead. Do not claim a composite split improves performance until the same workload is measured before and after.

8. Security-sensitive global state

Init scripts, global gradle.properties, repository credentials, plugin repositories, and shared caches can alter behavior across otherwise independent builds. Do not “fix” a composite by copying credentials into source or by dumping --debug logs publicly. If a global init script is suspected, reproduce with an isolated GRADLE_USER_HOME and fake/local repositories where possible.

9. Cache diagnosis without destructive cleanup

export GRADLE_USER_HOME="$PWD/.fresh-gradle-home"
./gradlew --version
./gradlew :app:dependencyInsight --dependency external-lib --configuration runtimeClasspath

If the same substitution/path failure persists in a fresh isolated home, the structural model—not stale normal cache state—is the likely cause. Remove only .fresh-gradle-home after the diagnostic.

10. Production repair pattern

Failure Evidence Least-destructive repair
Unknown project for included build projects + settings Use module coordinates; do not force a fake project path.
Unexpected local substitution dependencyInsight Run no-substitution diagnostic/published lane; keep composite behavior explicit.
Wrong task namespace Task error + build tree Use main project task path or includedBuild().task() as appropriate.
Build-logic cycle Build/plugin compile/config error Restore one-way dependency; move shared abstraction to proper layer.
Slow giant build Profile/configuration evidence Remove hidden cross-project configuration; measure explicit boundaries/substitution rules.
Suspected cache/init contamination Repro in isolated user home Delete only disposable diagnostic state, not normal ~/.gradle.

Knowledge check

What does an UnknownProjectException for :external-lib usually indicate in this chapter?

How do you prove an external module was substituted by local source?

Why use a separate no-substitution configuration?

What is wrong with build-logic importing app runtime classes?

Should slow configuration trigger immediate cache deletion?

How should an included build with mismatched publication coordinates be fixed?

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.