skip to content

Composite Builds

Composing separate Gradle builds with includeBuild: how substitution swaps a published module for a local project, and how tasks are addressed across build boundaries. Interviewers ask because it answers 'how do you develop a library and its consumer together?'

on this pageshow

questions

20

In a Gradle composite build, what happens to a dependency on a published module when you include the producing build with includeBuild?

level: juniorimportance: must knowfreq 60%

answer

  1. includeBuild = automatic substitution
  2. match by group:name, version ignored
  3. external module -> local project
  4. no publish needed
  5. silent fallback if coords mismatch

basics

~10 s

Gradle automatically substitutes the published module coordinates with the local included build's project. Instead of downloading the artifact from a repository, it builds and uses the local source.

solid answer

~40 s

When you `includeBuild("../lib")`, Gradle inspects the included build's projects and the `group:name` coordinates they publish. For any dependency in the consuming build that matches those coordinates, Gradle performs **automatic dependency substitution**: it replaces the external module with the local project, so the artifact is built from source rather than fetched from a repository. This lets you develop a library and its consumer together without publishing intermediate snapshots. The version you declared is ignored for matching — substitution is by `group` and `name` only. If the coordinates don't match (e.g. a typo in the included build's `group`), no substitution happens and Gradle silently falls back to the repository, which is a common source of confusion.

code

kotlin · 9 lines
kotlin
// settings.gradle.kts of the consuming build
rootProject.name = "app"
includeBuild("../widgets-lib") // local build that publishes com.acme:widgets

// build.gradle.kts of :app
dependencies {
    // resolves to the local widgets project, version is ignored for matching
    implementation("com.acme:widgets:1.4.0")
}

go deeper

for a junior

State that includeBuild makes Gradle build the dependency from local source instead of downloading it.

for a middle

Explain matching is by group:name with version ignored, and that it's automatic for included builds.

for a senior

Discuss the silent-fallback failure mode and how to verify substitution with the dependencies report.

for a principal

Frame composites + substitution as a workflow for cross-repo development without snapshot publishing, and its CI implications.

## What a composite build is A **composite build** (a.k.a. *included build*) is one Gradle build that pulls in one or more other, fully independent Gradle builds via `includeBuild`. Each included build keeps its own `settings.gradle(.kts)`, its own root, and its own version — it is not a subproject. The mechanism that makes composites useful is **dependency substitution**. ## Automatic substitution When you add `includeBuild("../my-lib")` to the consuming build's `settings.gradle.kts`, Gradle: 1. Configures the included build and enumerates every project it contains. 2. Determines the **publishing coordinates** (`group:name`) of each of those projects. 3. Builds a substitution map: `group:name` → local project. 4. While resolving the consumer's dependencies, any external module dependency whose `group:name` matches an entry is **replaced** by the corresponding local project's outputs. The key point: matching is done on **group and name only — the version is ignored**. So `implementation("com.acme:widgets:1.4.0")` resolves to the local `widgets` project even though the local version is `2.0-SNAPSHOT`. ## Why it matters This means you can clone a library next to your app, point at it with `includeBuild`, and immediately build against your local edits — no `publishToMavenLocal`, no version bumps, no stale snapshot caches. Change library source, rebuild the app, see the change. ## The silent-fallback trap If the included build's `group` (or a subproject's `group`) does not exactly match the coordinates the consumer declares, **no substitution occurs and there is no error** — Gradle just resolves from the repository. This is the most common reason a composite "doesn't work". Run `./gradlew :app:dependencies` and look for `-> project :lib` annotations to confirm substitution actually happened. ```kotlin // settings.gradle.kts (consumer) includeBuild("../widgets-lib") ```

  • How can you confirm substitution actually happened rather than a repository resolve?
    Run `./gradlew :app:dependencies` (or `dependencyInsight`) and look for `-> project :widgets` next to the module; if it still shows the version coordinate, substitution did not occur.
  • Does the declared version of the dependency matter for automatic substitution?
    No. Automatic matching uses only `group:name`; the requested version is ignored once the included build publishes those coordinates.

Like a symlink for dependencies: the coordinate name still points where it always did, but Gradle quietly redirects it to your working copy on disk instead of the warehouse.

saying these in an interview costs you the question

  • Claiming you must publish to mavenLocal first — that defeats the whole point of a composite build.
  • Believing the version must match between consumer and included build.

context

open as a page

What is a composite build in Gradle, and how do you wire one using includeBuild?

level: juniorimportance: must knowfreq 55%

basics

~10 s

A composite build combines otherwise independent Gradle builds into one. You wire it by adding includeBuild('../other-build') to settings.gradle.kts; Gradle then treats the included build as part of the current build.

open as a page

What must the in-development plugin build declare so that a consumer including it can apply the plugin by its id?

level: juniorimportance: must knowfreq 40%

basics

~10 s

The plugin build must apply the java-gradle-plugin plugin and declare the plugin id and implementationClass in a gradlePlugin { plugins { register(...) } } block. That generates the marker the consumer's id resolves against.

open as a page

How do you run a task that lives in an included build from the command line in a Gradle composite build?

level: juniorimportance: must knowfreq 55%

basics

~10 s

Prefix the task path with the included build's name: ./gradlew :includedBuildName:some:task. The leading segment is the build name (its directory name unless overridden), not a project in the root build.

open as a page

When automatic substitution in a composite build isn't enough, how do you declare an explicit substitution, and what is the syntax?

level: middleimportance: must knowfreq 50%

basics

~10 s

Use a dependencySubstitution block on the includeBuild and call substitute(module("group:name")).using(project(":path")) to map a published module to a specific project in the included build.

open as a page

When would you choose a composite build (includeBuild) over a single multi-project build with include(...)?

level: middleimportance: must knowfreq 50%

basics

~20 s

Use a single multi-project build when all modules live in one repo and release together. Use a composite build when you want to develop separate, independently-released builds (often different repos) together without publishing between changes.

open as a page

How can you develop a Gradle plugin and a project that consumes it in the same workspace, applying the plugin by its id without publishing first?

level: middleimportance: must knowfreq 55%

basics

~10 s

Put the plugin in its own build, then add includeBuild("build-logic") to the consumer's settings.gradle. The consumer can then apply the plugin by its id with no publishing.

open as a page

How do you make a task in your root build depend on a task in an included build programmatically?

level: middleimportance: must knowfreq 50%

basics

~10 s

Use the gradle.includedBuild('name') API to get a handle, then resolve the task: dependsOn(gradle.includedBuild("lib").task(":publishToMavenLocal")). The task path is relative to that included build's root.

open as a page

How can you compose a build temporarily without editing settings.gradle.kts, and when is that useful?

level: middleimportance: should knowfreq 30%

basics

~10 s

Pass --include-build on the command line: gradle --include-build ../my-library run. It composes that build for the single invocation only, without changing settings.gradle.kts.

open as a page

In a composite build where the consumer applies an in-development plugin, what happens when you change the plugin source and re-run a consumer task?

level: middleimportance: should knowfreq 30%

basics

~10 s

Gradle rebuilds the included plugin build first (with normal up-to-date checks), then runs the consumer task against the freshly compiled plugin. No publishing or version bump is needed between edits.

open as a page

How does Gradle disambiguate task addressing when the root build and an included build both have a project or task with the same name?

level: middleimportance: should knowfreq 35%

basics

~20 s

The first path segment is the build name. :lib:app:run targets the included build lib; :app:run targets the root build's app subproject. The build-name prefix removes ambiguity, so equal subproject names in different builds don't clash.

open as a page

A teammate added includeBuild for a library but the consumer still downloads the old published jar from the repo. How do you diagnose and fix it?

level: seniorimportance: should knowfreq 40%

basics

~10 s

Substitution silently fell back to the repo because the included build's group:name doesn't match the requested coordinates. Verify with the dependencies report, then align coordinates or add an explicit substitute(module).using(project) rule.

open as a page

How does composite-build dependency substitution via includeBuild differ from substitution declared with resolutionStrategy.dependencySubstitution?

level: seniorimportance: should knowfreq 30%

basics

~10 s

Composite substitution lives on the includeBuild call in settings and maps a module to a project in another, included build. resolutionStrategy.dependencySubstitution is configuration-level and substitutes within the same build (module-to-module or module-to-local-project).

open as a page

When you includeBuild a library, how does Gradle decide to use the local source instead of the published artifact?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Gradle matches the included build's published coordinates (group and module name) against external dependencies in the consumer. Where group:name matches, it substitutes the included build's output for the external artifact — the version is ignored for matching.

open as a page

When developing a plugin together with its consumer, when would you prefer an included build (includeBuild) over putting the plugin in buildSrc?

level: seniorimportance: should knowfreq 35%

basics

~10 s

Use buildSrc for plugin logic private to one build. Use an explicit included build when the plugin is reusable, applied by a public id, or shared across multiple consumer builds.

open as a page

How would you iterate over all included builds and wire a common task across each of them, for example an aggregate `cleanAll`?

level: seniorimportance: should knowfreq 25%

basics

~10 s

Iterate gradle.includedBuilds, and for each handle call .task(":clean"), adding it to an aggregate task: tasks.register("cleanAll") { dependsOn(gradle.includedBuilds.map { it.task(":clean") }) }.

open as a page

What constraints and gotchas should you keep in mind when structuring composite builds with includeBuild?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Each included build is standalone with its own settings; composition can nest but you can't create cycles where builds include each other. Included builds configure independently, adding overhead, and only the root build's settings declares the composition that the CLI runs against.

open as a page

Your in-development plugin is a Settings plugin (applied in settings.gradle), not a project plugin. Can you co-develop it with a consumer via includeBuild, and what changes?

level: seniorimportance: nice to knowfreq 15%

basics

~20 s

A Settings plugin applies in the consumer's settings.gradle, but includeBuild's plugin substitution is processed during settings evaluation. To apply an included Settings plugin by id you use pluginManagement { includeBuild(...) } so the plugin build is available early enough.

open as a page

Why can you address tasks in an included build but not in `buildSrc`, and what does that imply about how composites expose tasks?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

buildSrc is an implicit, automatically-built helper that contributes classes/plugins — its tasks aren't part of the addressable composite graph. An included build is a first-class member of the composite, so its tasks are reachable via :buildName:task and gradle.includedBuild(...).

open as a page

When substituting a published module to a local included build, how do you target a specific variant or capability (e.g. test fixtures or a platform), and what can go wrong?

level: principalimportance: nice to knowfreq 18%

basics

~10 s

Use the variant-aware form: substitute(module(...)) plus a capability/attribute selector, and using(project(...)) with withClassifier or requireCapability/requireFeature. Mismatched variants (e.g. test fixtures, platforms) otherwise resolve incorrectly or fail.

open as a page