skip to content

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%

answer

  1. java-library: apiElements vs runtimeElements
  2. api leaks to consumer compileClasspath
  3. implementation = runtime-only to consumers
  4. variant selection by Usage attribute
  5. implementation shrinks recompile fan-out

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.

solid answer

~40 s

The `java-library` plugin splits :lib's outgoing artifacts into two consumable configurations: `apiElements` (compile-facing) and `runtimeElements` (runtime-facing). A dependency :lib declares with `api` is exposed via `apiElements`, so it lands on a consumer's `compileClasspath`; one declared with `implementation` goes only to `runtimeElements`, so it reaches the consumer's `runtimeClasspath` but **not** its compile classpath. When :app does `implementation(project(':lib'))`, :app sees :lib's *api* transitives at compile time and the full runtime closure at runtime. This affects both visibility *and* the task graph: :app:compileJava resolves apiElements; :app's run/test resolves runtimeElements. Using `implementation` aggressively in :lib shrinks the compile classpath, reducing recompilation fan-out when an internal dep of :lib changes — better incrementality across the graph.

code

kotlin · 10 lines
kotlin
// lib/build.gradle.kts
plugins { `java-library` }
dependencies {
    api("com.google.guava:guava:33.0.0-jre")           // visible to :app at compile time
    implementation("org.apache.commons:commons-lang3:3.14.0") // hidden from :app compile
}

// app/build.gradle.kts
dependencies { implementation(project(":lib")) }
// :app compile cp -> :lib + guava ;  :app runtime cp -> :lib + guava + commons-lang3

go deeper

for a junior

Know that api leaks a :lib dependency to :app's compile classpath while implementation keeps it internal/runtime-only.

for a middle

Explain apiElements vs runtimeElements, attribute-based variant selection, and the compile/runtime classpath split for the consumer.

for a senior

Tie api/implementation to encapsulation and incremental-build fan-out; reason about when promotion to api is justified by public signatures.

for a principal

Set org conventions favouring implementation, treat api surface as a governed contract, and quantify recompilation cost across a large module graph.

## Two outgoing variants from a library Apply the `java-library` plugin to :lib and it publishes two **consumable** configurations: - `apiElements` — the **compile** view consumers get. - `runtimeElements` — the **runtime** view consumers get. (The plain `java` plugin has no `api` configuration — only `implementation` — so everything is runtime-only to consumers.) ## api vs implementation inside :lib ```kotlin // lib/build.gradle.kts plugins { `java-library` } dependencies { api("com.google.guava:guava:33.0.0-jre") // leaks to consumers' compile cp implementation("org.apache.commons:commons-lang3:3.14.0") // internal only } ``` - `api` deps are added to `apiElements` → a consumer that depends on :lib gets guava on its **compileClasspath** (and runtime). - `implementation` deps go only to `runtimeElements` → commons-lang3 is on the consumer's **runtimeClasspath** but invisible at compile time. ## The consumer side ```kotlin // app/build.gradle.kts dependencies { implementation(project(":lib")) } ``` - `:app:compileJava` resolves `compileClasspath`, selecting :lib's `apiElements` variant via attribute matching → sees :lib + guava, **not** commons-lang3. - `:app`'s runtime (run/test) resolves `runtimeClasspath` → :lib's `runtimeElements` → full closure including commons-lang3. Variant selection is driven by **attributes** (e.g. `org.gradle.usage = java-api` vs `java-runtime`). Gradle picks the variant whose attributes match what the consuming configuration requests. ## Why it matters for the graph and incrementality A smaller compile classpath means fewer modules participate in compile-time recompilation. If :lib's *internal* (`implementation`) dependency changes, consumers don't recompile against it — only :lib does. Leaking everything via `api` enlarges every consumer's compile classpath and recompilation fan-out. So `api`/`implementation` is both an **encapsulation** decision and a **build-performance** lever across the project graph. ## Common mistake Using `api` by default "to be safe" — it couples consumers to :lib's internals and slows incremental builds. Prefer `implementation`; promote to `api` only when a type genuinely appears in :lib's public API signatures.

  • If you change an implementation dependency inside :lib, do consumers recompile?
    No, consumers don't recompile against it — it's not on their compile classpath. Only :lib recompiles (and consumers may relink at runtime). That's the incremental-build win of implementation over api.
  • What plugin do you need for api to mean anything?
    java-library. The plain java plugin has no api configuration, so all deps are runtime-only to consumers and you lose the compile/runtime split.
  • How does Gradle pick apiElements vs runtimeElements?
    Via attribute matching: the consumer's compileClasspath requests Usage=java-api, runtimeClasspath requests Usage=java-runtime, and Gradle selects the producer variant whose attributes match.

saying these in an interview costs you the question

  • Saying api and implementation behave identically across projects.
  • Defaulting everything to api 'to be safe' — it bloats consumer compile classpaths and recompilation.
  • Forgetting the java-library plugin is required for api to exist.

context