skip to content

Dependency Substitution Across Builds

How an included build automatically substitutes its projects for the published modules a consumer depends on, and how to declare that mapping explicitly. Interviewers ask because the automatic case only works when the coordinates line up.

on this pageshow

questions

5

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

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

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 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