Chapter 21Lesson 01~200 minutes

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.

Gradle 9.7.1Multi-projectComposite buildsIncluded buildsBuild tree

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 group plus project name in the default mapping.
  • Separate reusable build logic from product projects and compare buildSrc with an explicit build-logic included 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

One build tree, multiple project hierarchies
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?

What normally drives automatic included-build substitution?

Does an included build inherit the root build’s repositories and version catalog?

Why can build-logic be safer than giant subprojects {} blocks?

What does ./gradlew projects primarily prove in this chapter?

When is a composite not sufficient proof of publication interoperability?

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.