skip to content

Cross-Project Dependencies

Wiring one subproject onto another: project dependencies, the task graph they induce, and targeting a specific producer configuration. Interviewers ask because this is where multi-project builds get their ordering for free — or lock up in a cycle.

on this pageshow

questions

20

In a Gradle multi-project build, how do you make one subproject depend on another subproject so it can use its classes and outputs?

level: juniorimportance: must knowfreq 80%

answer

  1. project(":lib") notation
  2. no publishing — consumes build outputs
  3. auto task ordering
  4. configuration controls classpath
  5. include(...) in settings file

basics

~10 s

Add a project dependency in the consumer's build file, e.g. implementation(project(":lib")). Gradle builds :lib first and puts its compiled output and dependencies on the consumer's classpath.

solid answer

~30 s

Inside a multi-project build (subprojects listed in `settings.gradle.kts` via `include(":lib", ":app")`), one subproject depends on another by declaring a **project dependency** rather than an external coordinate: ```kotlin // app/build.gradle.kts dependencies { implementation(project(":lib")) } ``` The `project(":lib")` notation resolves to the sibling project. Gradle automatically wires task ordering so `:lib` is assembled before `:app` compiles, and `:lib`'s main output (its JAR / classes) plus its declared dependencies are placed on `:app`'s classpath according to the configuration used (`implementation`, `api`, `testImplementation`, etc.). You don't publish `:lib` to a repository — Gradle consumes its outputs directly from the build.

code

kotlin · 7 lines
kotlin
// settings.gradle.kts
include(":lib", ":app")

// app/build.gradle.kts
dependencies {
    implementation(project(":lib"))
}

go deeper

for a junior

Recall the project(":lib") syntax and that it puts the other module's code on your classpath without publishing.

for a middle

Explain task-ordering is automatic and that the configuration (implementation/api) governs classpath and transitivity.

for a senior

Mention variant selection (apiElements/runtimeElements) and how attribute matching picks the right outgoing configuration of the java component.

for a principal

Frame project dependencies as the substrate for module decomposition; discuss when to keep modules in one build vs split into separate published artifacts / composite builds.

## What a project dependency is A Gradle build can contain many **subprojects** (modules). They are declared in the settings file: ```kotlin // settings.gradle.kts rootProject.name = "my-build" include(":lib", ":app") ``` When one subproject needs another's code, you declare a **project dependency** using the `project(path)` notation instead of an external coordinate like `com.example:lib:1.0`. ```kotlin // app/build.gradle.kts dependencies { implementation(project(":lib")) } ``` ## How it differs from an external dependency - An **external dependency** (`implementation("group:name:version")`) is resolved from a repository (Maven Central, etc.). - A **project dependency** is resolved from the **build itself** — Gradle uses `:lib`'s produced artifacts (by default the main JAR / classes directory) without publishing anything. ## What Gradle does for you 1. **Task ordering / dependency graph** — `:app`'s `compileJava`/`compileKotlin` automatically depends on `:lib` being built (its `jar` or `classes`), so you never have to wire `dependsOn` manually. 2. **Classpath assembly** — `:lib`'s output and its *exported* dependencies are added to `:app`'s classpath, governed by the configuration (`implementation` vs `api`). 3. **Variant selection** — Gradle picks the right output **variant** of `:lib` (e.g. the `apiElements` / `runtimeElements` configuration of the `java` component) using attribute matching, so the consumer gets the correct compile vs runtime artifacts. ## The configuration matters The configuration you put the project dependency in controls visibility and transitivity: - `implementation(project(":lib"))` — `:lib` is on `:app`'s compile and runtime classpath, but is **not** leaked to anyone who depends on `:app`. - `api(project(":lib"))` — requires the `java-library` plugin; `:lib` is also exposed to `:app`'s consumers. - `testImplementation(project(":lib"))` — only for `:app`'s tests. ## Path notation Paths are colon-separated and rooted at the build: `:lib`, `:platform:core`, etc. The leading `:` means "from the root". A typo in the path fails fast at configuration time with "project ... not found".

  • Do you have to publish :lib to a Maven repository for :app to use it?
    No. A project dependency consumes :lib's outputs directly from the build; nothing is published. Gradle builds :lib first and uses its produced artifacts and exported dependencies on :app's classpath.
  • Does :app automatically build :lib first?
    Yes. The project dependency creates an edge in the task graph, so :app's compile/jar tasks depend on :lib's relevant tasks; Gradle orders them correctly without any manual dependsOn.

It's like importing code from a folder in your own repo instead of pulling a published library off the internet — same import syntax for you, but Gradle compiles the dependency locally first.

saying these in an interview costs you the question

  • Saying you must publish the subproject to a repository first.
  • Manually wiring dependsOn between compile tasks instead of using a project dependency.
  • Using a file path like project("../lib") instead of the colon path project(":lib").

context

open as a page

When :app declares implementation(project(':lib')), how does Gradle decide the order in which :lib and :app tasks run?

level: juniorimportance: must knowfreq 70%

basics

~10 s

Because :app's compile classpath needs :lib's jar, Gradle automatically builds :lib first. The project dependency creates implicit task dependencies, so :lib:jar runs before :app:compileJava — you don't wire that ordering by hand.

open as a page

How do you share test helper code (fixtures) between two Gradle modules so that one project's tests can reuse fixtures defined in another?

level: juniorimportance: must knowfreq 55%

basics

~10 s

Apply the java-test-fixtures plugin to the producing module, put shared helpers in src/testFixtures/java, then in the consumer declare testImplementation(testFixtures(project(":lib"))).

open as a page

How do you depend on a *specific* named configuration of another project in the same build, rather than its default API/runtime, and what does the syntax look like?

level: middleimportance: must knowfreq 45%

basics

~10 s

Use the two-arg project notation: project(path: ':lib', configuration: 'someConfig'). The configuration name selects which producer configuration to consume instead of the default one.

open as a page

When wiring one subproject onto another with project(...), what is the difference between declaring it with api(project(':lib')) versus implementation(project(':lib'))?

level: middleimportance: must knowfreq 75%

basics

~10 s

implementation(project(":lib")) keeps :lib private to the consumer — downstream projects can't see it. api(project(":lib")) exposes :lib transitively to anyone depending on the consumer. api needs the java-library plugin.

open as a page

With a project dependency project(':lib'), how do api vs implementation affect what flows into :app's compile classpath and the induced graph?

level: middleimportance: must knowfreq 60%

basics

~20 s

In :lib, deps declared with api leak onto :app's compile classpath transitively; implementation deps stay internal to :lib and only appear on :app's runtime classpath. This (from the java-library plugin) shapes what :app can see at compile time.

open as a page

If you write project(path: ':lib', configuration: 'reports') and resolution fails, what is likely wrong on the producer side and how do you make a configuration targetable this way?

level: middleimportance: should knowfreq 30%

basics

~10 s

The named configuration on :lib probably isn't consumable. Mark it isCanBeConsumed = true (and usually isCanBeResolved = false) and attach artifacts to it, then it can be targeted.

open as a page

How do you inspect the project dependency graph and confirm a cross-project edge like :app -> :lib?

level: middleimportance: should knowfreq 55%

basics

~10 s

Run ./gradlew :app:dependencies. It prints each configuration's resolved tree; under compileClasspath you'll see a project :lib node, confirming the cross-project edge.

open as a page

Inside a testFixtures source set, when should a dependency go in testFixturesApi versus testFixturesImplementation, and what's the visibility difference for consumers?

level: middleimportance: should knowfreq 28%

basics

~10 s

Use testFixturesApi when a fixture's public signature exposes a type from that dependency (it then leaks onto consumers' compile classpath). Use testFixturesImplementation for internal-only deps that consumers shouldn't compile against.

open as a page

A teammate added testImplementation(testFixtures(project(':lib'))) but the consumer's tests can't find the fixture classes at compile time. How do you diagnose and fix it?

level: middleimportance: should knowfreq 24%

basics

~10 s

Check that :lib actually applies java-test-fixtures, that the classes live in src/testFixtures/java, and that any types the consumer references are declared testFixturesApi (not testFixturesImplementation). Then use dependencyInsight to confirm the right variant resolved.

open as a page

Compare consuming a sibling project via an explicit project(configuration:) versus relying on attribute-driven variant selection. When would you deliberately choose the explicit form, and what are its downsides?

level: seniorimportance: should knowfreq 28%

basics

~10 s

Explicit project(configuration:) names exactly one output and skips attribute matching — direct but brittle. Attribute-driven selection (plain project(':lib')) is the modern, composable default that adapts to context like api vs runtime.

open as a page

When you declare implementation(project(":lib")), what exactly does :app consume from :lib, and how does Gradle choose it?

level: seniorimportance: should knowfreq 40%

basics

~10 s

By default Gradle consumes :lib's main java component — its produced JAR (or classes/resources for compile) plus its exported dependencies — selecting the correct outgoing configuration via attribute matching, not a hardcoded JAR path.

open as a page

Walk through how api vs implementation on project dependencies controls what a downstream consumer can see across a chain of subprojects (:app → :service → :core).

level: seniorimportance: should knowfreq 55%

basics

~20 s

Compile-time visibility flows only through api edges. If :service declares api(project(":core")), then :app (depending on :service) can compile against :core. If it's implementation, :core is hidden from :app at compile time but still present at runtime.

open as a page

Given the inter-project task DAG, how does Gradle exploit parallelism and incrementality, and what limits it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

The DAG lets Gradle run independent tasks in parallel (--parallel) and skip up-to-date ones via input/output checks. Producer→consumer edges serialize what must be ordered; only the affected slice rebuilds. Cycles or coarse dependencies kill parallelism.

open as a page

When you write testImplementation(testFixtures(project(':lib'))), what does Gradle actually resolve under the hood — what variant and capability are involved?

level: seniorimportance: should knowfreq 30%

basics

~10 s

testFixtures() requests a separate capability (group:lib-test-fixtures). Gradle's variant-aware resolution then selects the testFixturesRuntimeElements/testFixturesApiElements consumable variant instead of the module's normal API/runtime variants.

open as a page

In the absence of attribute-aware metadata, what is the 'default' configuration of a project, and how does it relate to project(':lib') versus project(path: ':lib', configuration: 'default')?

level: middleimportance: nice to knowfreq 15%

basics

~10 s

Legacy project dependencies resolve to a configuration literally named 'default'. project(':lib') is shorthand for project(path: ':lib', configuration: 'default') only when no variant-aware metadata drives selection.

open as a page

When you consume project(path: ':lib', configuration: 'foo'), what exactly do you receive — just artifacts, or dependencies too — and how does transitivity behave?

level: seniorimportance: nice to knowfreq 18%

basics

~10 s

You receive both the artifacts attached to 'foo' and the dependencies declared on 'foo', including their transitive closure. The named configuration's whole outgoing surface comes along, not just files.

open as a page

When would you replace a project(':lib') dependency in the same build with a composite build (includeBuild), and how does ordering change?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Use a composite build when :lib lives in its own separate Gradle build/repo. includeBuild substitutes a normal module:lib dependency with the local build, so Gradle still orders producer before consumer — but across builds, not subprojects.

open as a page

Why are test fixtures preferred over the older approach of sharing a 'test-jar' or pointing another module at src/test directly?

level: seniorimportance: nice to knowfreq 18%

basics

~10 s

Test fixtures give a first-class, variant-aware, separately-published surface with its own dependencies, instead of brittle hacks like classifier test-jars or wiring another module's sourceSets.test.output, which leak full test code and lose proper metadata.

open as a page

How would you decide when to model a piece of code as a separate subproject wired in via project(...) dependencies versus keeping it in an existing module?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Split into a separate subproject when the code has a clear, reusable boundary, needs an enforced API surface, or improves parallel/incremental builds. Wire it with implementation(project(...)) by default, exposing only its real public API via api.

open as a page