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.
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
- Preserve evidence: command, concise error, dependency insight, requested task path, and current settings files.
- Confirm identity: Wrapper Gradle version and runtime JDK.
-
Inspect settings: which projects are included
with
include(); which builds are included withincludeBuild(). - Inspect graph: project dependencies, module dependencies, substitution reasons, task dry-run.
- Inspect filesystem/cache: only after the model is understood; use a disposable user home if cache state is suspect.
- Apply the narrowest correction: fix a path, coordinate, substitution mapping, or ownership edge.
- 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?
The build script is trying to address an included build through the main build’s project namespace.
How do you prove an external module was substituted by local source?
Use dependencyInsight and inspect the
includeBuild()/substitution settings.
Why use a separate no-substitution configuration?
It isolates the published-module comparison without changing normal composite behavior for every configuration.
What is wrong with build-logic importing app runtime classes?
It couples build-time configuration to product output and can create an invalid/circular ownership relationship.
Should slow configuration trigger immediate cache deletion?
No. Preserve timings/model evidence and reproduce in isolated state only when cache/global state is a plausible cause.
How should an included build with mismatched publication coordinates be fixed?
Prefer aligning project identity with real coordinates; otherwise declare a narrow explicit substitution mapping.
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.