skip to content

Do inferred provider dependencies work across projects in a multi-module build, and how should they be exposed between modules?

level: seniorimportance: nice to knowfreq 25%

answer

  1. provider carries producer across projects
  2. direct task reach-in breaks isolation
  3. consumable vs resolvable configurations
  4. outgoing artifact carries built-by
  5. resolve config → infers producer task

basics

~20 s

Yes — a provider from another project's task carries that task as producer, so consuming it infers a cross-project dependency. The clean way to expose outputs across modules is via variant-aware configurations (consumable/resolvable) rather than reaching into another project's tasks.

solid answer

~40 s

Inference works across projects: if project B consumes a `Provider` produced by a task in project A (e.g. `project(":a").tasks.named<Gen>("gen").flatMap { it.out }`), the provider still carries A's task as producer, so Gradle infers the cross-project edge. However, reaching into another project's tasks couples B to A's internals and fights project isolation / parallel configuration. The idiomatic alternative is **variant-aware dependency management**: project A declares a *consumable* configuration with `outgoing.artifact(genTask.flatMap { it.out })` (the artifact carries its producer), and B declares a *resolvable* configuration that `extendsFrom`/depends on `project(":a")` and resolves it. The artifact's built-by metadata flows through, so resolving B's configuration infers running A's task — inference, but decoupled and isolation-friendly. Prefer configurations over cross-project task references for anything beyond trivial builds.

code

kotlin · 11 lines
kotlin
// producer project :a
val gen = tasks.register<Gen>("gen") { out.set(layout.buildDirectory.file("a.txt")) }
val sharedOut = configurations.consumable("sharedOut")
artifacts { add(sharedOut.name, gen.flatMap { it.out }) }

// consumer project :b
val sharedIn = configurations.resolvable("sharedIn")
dependencies { add(sharedIn.name, project(":a")) }
tasks.register("useShared") {
    inputs.files(sharedIn)   // resolving infers :a:gen — no dependsOn
}

go deeper

for a junior

Know that consuming another project's output provider can make Gradle run that project's task too.

for a middle

Explain that the provider carries its producer across projects, but reaching into another project's tasks is discouraged.

for a senior

Describe consumable vs resolvable configurations and how an outgoing artifact's built-by metadata lets resolution infer the producer task cleanly across modules.

for a principal

Set module-boundary conventions: expose outputs via variant-aware configurations, ban cross-project task reach-in, to preserve isolation, parallel configuration, and refactor safety at scale.

## Short answer: yes, but be careful A `Provider` doesn't care which project its producing task lives in. If project `:b` wires an input from a task in `:a`, the provider carries `:a`'s task as producer and Gradle infers the cross-project edge. So this *works*: ```kotlin // in :b (tightly coupled — avoid in real builds) val genA = project(":a").tasks.named<Gen>("gen") tasks.register("useA") { inputs.file(genA.flatMap { it.out }) } ``` ## Why the direct form is discouraged Reaching into `:a`'s task container: - **Breaks project isolation** — `:b` now depends on `:a`'s task names and types; renaming a task in `:a` breaks `:b`. - **Hurts parallelism** — forces `:a` to be configured when `:b` is configured. - **Doesn't scale** — couples module internals across the graph. ## The idiomatic alternative: variant-aware configurations Gradle models artifacts as **configurations** with two roles: - **consumable** (`canBeConsumed = true, canBeResolved = false`) — what a project *exposes*. - **resolvable** (`canBeResolved = true, canBeConsumed = false`) — what a project *requests*. Producer `:a` exposes its output: ```kotlin val shared = configurations.consumable("sharedOut") artifacts { add(shared.name, genTask.flatMap { it.out }) } // artifact carries built-by ``` Consumer `:b` resolves it: ```kotlin val incoming = configurations.resolvable("sharedIn") dependencies { add(incoming.name, project(":a")) } tasks.register("useShared") { inputs.files(incoming) } // resolving infers running :a's task ``` Because the published artifact carries its producer's built-by metadata, resolving `:b`'s configuration **infers** that `:a`'s task must run — the same inference principle, now across a clean module boundary with attribute matching. ## Decision guidance - Trivial/private internal builds: a direct cross-project provider may be fine. - Anything shared, reused, or isolation-sensitive: use consumable/resolvable configurations (or `project(path, configuration)` dependencies), so the dependency is declarative, attribute-matched, and cache/isolation friendly. ## Tie-back to the topic In all cases the *mechanism* is identical: a provider (or a configuration's artifact) carries its producing task, and consuming it infers the dependency without an explicit `dependsOn`. The design choice is *how* you let that provider cross the project boundary.

  • What's the difference between a consumable and a resolvable configuration?
    Consumable (canBeConsumed=true, canBeResolved=false) is what a project exposes to others; resolvable (canBeResolved=true, canBeConsumed=false) is what a project requests/resolves. A configuration should be one or the other, not both (avoid legacy bucket configurations).
  • Why prefer configurations over project(":a").tasks.named(...)?
    Configurations keep modules decoupled and isolation-friendly: B depends on A's published artifact and attributes, not A's task names/types. It scales, parallelizes, and survives refactors in A.
  • Does resolving a configuration still rely on the same inference mechanism?
    Yes — the artifact added to the consumable configuration carries its producing task's built-by metadata, so resolving the consumer's configuration infers running that task, exactly like provider wiring.

saying these in an interview costs you the question

  • Claiming inference can't cross project boundaries — it can.
  • Defaulting to project(":a").tasks.named(...) in a real multi-module build, breaking isolation.
  • Declaring a configuration as both consumable and resolvable.

context