Gradle Multi-Project Builds, Composite Builds, Included Builds, and Build Structure Design: Concepts, Architecture, and Mental Model
Separate one-build project structure from composite build structure so project paths, module coordinates, task paths, build logic, and ownership boundaries remain explicit.
Learning objectives
- Distinguish a multi-project build from a composite/included-build structure by project hierarchy, build identity, and lifecycle boundary.
-
Explain why
project(":x")can only address a project in the same Gradle build, while an included build is consumed by module coordinates or included-build task references. -
Describe automatic composite dependency substitution and the role
of
groupplus project name in the default mapping. -
Separate reusable build logic from product projects and compare
buildSrcwith an explicitbuild-logicincluded build. - Identify project-local state, included-build state, Gradle User Home state, repository metadata, and CI evidence as separate stores.
- Inspect build/project/task/dependency topology before changing the structure.
1. The practical problem: “module” is not one namespace
Chapter 20 centralized dependency intent and resolved state. As the codebase grows, another ambiguity appears: teams say “module” for a Gradle subproject, a published dependency coordinate, an independently released repository, and sometimes even a task group. Those objects can line up, but Gradle does not treat them as identical.
A single multi-project build is optimized for projects that share one build lifecycle. A composite build joins independent builds so they can be developed or orchestrated together without pretending they have one project hierarchy. That distinction affects dependency notation, task paths, settings, plugin resolution, properties, caches, release ownership, and CI design.
Chapter invariant: always name the namespace first: build, project path, module coordinate, or task path. If a diagnostic mixes them, fix the mental model before the build script.
2. Baseline and vocabulary
The mandatory path uses Gradle 9.7.1 through a committed Wrapper, JDK 21 to run Gradle, and Java 17 as the JVM target. The lab uses only local source builds and a disposable Gradle User Home.
| Boundary | What it owns | How you address it |
|---|---|---|
| One Gradle build |
One Settings object, one root project, zero or
more subprojects.
|
Project path such as :app or
:core.
|
| Project dependency | A dependency edge between projects in the same build. | implementation(project(":core")). |
| Included build | A complete, independently configured Gradle build with its own settings/project hierarchy. | Build-tree path for tasks; external module coordinates for substituted dependencies. |
| Composite build | The build tree formed by a root build plus one or more included builds. |
includeBuild("path") or temporary
--include-build.
|
| build-logic included build | Reusable convention/plugin code with its own build classpath and lifecycle. |
Included for plugin resolution, commonly from
pluginManagement.
|
| Gradle User Home | Machine/user-scoped distribution and dependency caches, daemon data, and other user state. |
Can be isolated per lab with GRADLE_USER_HOME.
|
Gradle 9 also validates included subproject directories more strictly than old versions: when a project is declared in settings, its directory must exist and be writable. Treat the settings model as deliberate source state, not as a suggestion.
3. Build tree mental model
flowchart TB
S["Main settings.gradle.kts"] --> R["Root project :"]
R --> A["Subproject :app"]
R --> C["Subproject :core"]
S -->|includeBuild| E["Included build external-lib"]
S -->|pluginManagement includeBuild| B["Included build build-logic"]
A -->|project dependency| C
A -->|module coordinates| M["dev.academy:external-lib:1.0.0"]
E -. automatic substitution .-> M
B -->|plugin resolution| A
B -->|plugin resolution| C
The root and its subprojects share one settings-defined project
hierarchy. :app can therefore declare
project(":core"). The independent
external-lib build has its own root project; it is not
addressable as project(":external-lib") from the main
build. Instead the consumer declares the same external coordinates
it would use for a published module. The composite can then
substitute the local project for those coordinates.
build-logic is also a real included build, but its
product is build logic rather than application runtime code. Keeping
that distinction explicit prevents production classes from leaking
into the buildscript classpath.
4. Multi-project build: one settings model, many projects
// settings.gradle.kts
rootProject.name = "workspace"
include("core", "app")
// app/build.gradle.kts
plugins { application }
dependencies {
implementation(project(":core"))
}
include() creates project descriptors inside one build.
A project dependency affects graph selection and build order: Gradle
knows the producer project must create the variant consumed by
:app. This is tighter than a published-module boundary
because source-level project state participates directly in the same
build invocation.
5. Included/composite build: independent identity joined at the build tree
// main settings.gradle.kts
rootProject.name = "workspace"
include("core", "app")
includeBuild("external-lib")
// external-lib/settings.gradle.kts
rootProject.name = "external-lib"
// external-lib/build.gradle.kts
plugins { `java-library` }
group = "dev.academy"
version = "1.0.0"
// app/build.gradle.kts
dependencies {
implementation(project(":core"))
implementation("dev.academy:external-lib:1.0.0")
}
Gradle’s default composite substitution inspects the included
project identity and registers a substitution for
${project.group}:${project.name}. The dependency
declaration remains an external module coordinate, which is
valuable: removing the composite should naturally return the
consumer to repository-based resolution if that module is actually
published.
Important: included builds are not subprojects.
project(":external-lib") is the wrong namespace even if
the directory sits next to app/.
6. Automatic versus explicit dependency substitution
Automatic substitution works best when the included build’s project
group and name match its normal published coordinates.
If publication coordinates differ, define a narrow substitution in
the including build:
includeBuild("external-lib") {
dependencySubstitution {
substitute(module("dev.academy:external-lib")).using(project(":"))
}
}
Substitution rules are scoped to the including build; they do not magically become global truth in every build. Gradle also warns that composite substitution can diverge from published metadata when a project’s default configuration does not match the publication. That is why the production boundary still needs repository publication tests when publication metadata is customized.
7. buildSrc versus explicit build-logic
| Choice | Useful when | Operational consequence |
|---|---|---|
buildSrc |
Fast prototyping or smaller builds where automatic availability is convenient. | Special build automatically compiled before the main build; changes can invalidate a broad portion of build configuration. |
Explicit build-logic included build |
Growing multi-project builds and reusable convention plugins. | Independent build/classpath, explicit inclusion, clearer dependency model, potentially narrower invalidation. |
Root subprojects {}/allprojects {}
blocks
|
Rare small cases only. | Creates hidden cross-project configuration and coupling; current Gradle guidance prefers convention plugins. |
Chapter 18 introduced convention plugins. Chapter 21 adds the architectural reason: reusable build policy is a separate software component. It can be built and tested independently instead of making the root script an implicit mutable parent for every subproject.
8. Included builds are configured in isolation
Included builds do not inherit the root build’s project hierarchy,
dependency repositories, plugin management, version catalogs, or
ordinary project properties merely because they participate in the
same invocation. Each has its own
settings.gradle(.kts) and build files. A root
gradle.properties user-defined property is not
automatically visible inside the included build; command-line/system
inputs have different propagation rules.
This isolation is an ownership feature. A library build should be independently executable with its own dependency repositories and plugin sources. A composite that only works because the parent silently injects settings is not genuinely independent.
9. Files, caches, and trust boundaries
| State | Commit/review? | Boundary |
|---|---|---|
Root and included settings.gradle.kts |
Yes | Defines separate build identities and inclusion/substitution relationships. |
Subproject/included-build build.gradle.kts
|
Yes | Project logic and dependency/publication identity. |
build-logic/ source |
Yes | Executable build policy; review with the same rigor as code because it runs during the build. |
Project .gradle/ and build/ |
No | Generated project-local execution/build state. |
GRADLE_USER_HOME caches |
No | Machine/user cache and runtime state; may be shared by builds in an invocation but is not source truth. |
| CI logs/artifacts | Evidence | Record which build/project/task namespace actually ran and what was substituted. |
10. Read-only inspection before mutation
./gradlew --version
./gradlew projects
./gradlew tasks --all
./gradlew :app:dependencies --configuration runtimeClasspath
./gradlew :app:dependencyInsight --dependency external-lib --configuration runtimeClasspath
./gradlew :app:build --dry-run
projects proves the main build project hierarchy; it
should list :app and :core, not pretend
the included build is a subproject. The dependency report/insight
proves whether dev.academy:external-lib was
substituted. --dry-run previews selected main-build
tasks, while included-build tasks required to produce dependency
artifacts remain a separate build namespace.
11. DevOps operating model
Repository topology and build topology are related but not identical. One repository can contain several independently executable builds; multiple repositories can be composed locally for co-development. Use a multi-project build when atomic changes and one lifecycle are valuable. Use an included/composite boundary when independent release/ownership is real and the combined workspace is an integration convenience, not a lie about ownership.
CI should record the exact root build, included builds, Wrapper/JDK identity, requested task paths, and whether local substitution was active. A green composite build is not proof that the published external module can be consumed unless a separate lane tests repository metadata without substitution.
Knowledge check
Can :app use
project(":external-lib") when
external-lib is an included build?
No. project() addresses projects in the same build
hierarchy. Consume an included build by external module
coordinates or interact with its tasks through included-build
APIs.
What normally drives automatic included-build substitution?
The included project’s group and project name are
matched to external module coordinates.
Does an included build inherit the root build’s repositories and version catalog?
No. Included builds are configured independently; shared policy must be modeled explicitly.
Why can build-logic be safer than giant
subprojects {} blocks?
It makes reusable policy an explicit, testable build component instead of hidden cross-project configuration.
What does ./gradlew projects primarily prove in
this chapter?
The project hierarchy of the invoked build, which helps distinguish subprojects from included builds.
When is a composite not sufficient proof of publication interoperability?
When substitution uses local project/default variants that may differ from the metadata/artifacts actually published to a repository.
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.