skip to content

A consumer of your library suddenly can no longer compile after you changed a dependency from `api` to `implementation`. What happened, and how do you reason about and fix it correctly?

level: seniorimportance: should knowfreq 44%

answer

  1. api leaked to consumer compile classpath
  2. implementation surfaced an implicit transitive dep
  3. fix: consumer declares its own direct dep
  4. keep api only if type is in YOUR public API
  5. dependency-analysis plugin flags it

basics

~20 s

The consumer was relying on a transitively leaked compile dependency. Moving it from api to implementation removed it from the consumer's compile classpath. The right fix is for the consumer to declare its own direct dependency, since it actually uses those types.

solid answer

~50 s

When the dependency was `api`, it leaked onto every consumer's compile classpath, so the consumer's code could `import` and compile against it **without declaring it**. Switching to `implementation` hides it from consumers' compile classpath — so any consumer code that imported those types now fails to compile. This is actually *surfacing a latent bug*: the consumer was using a type it never declared, an implicit transitive dependency. The correct fix is **not** to revert to `api` (that re-leaks and re-couples). Instead, the consumer should add its own **direct** `implementation` (or `api`) dependency on that library — because it genuinely uses those types, it should own that declaration. If the type truly is part of *your* library's public API (it appears in your method signatures), then `api` was correct all along and you should keep it. The decision hinges on whether the type is in *your* exported surface or only the *consumer's* code.

code

kotlin · 9 lines
kotlin
// CORRECT fix: the consumer owns what it actually imports
// my-lib
dependencies { implementation("org.apache.commons:commons-lang3:3.14.0") }

// consumer (previously relied on leakage)
dependencies {
    implementation(project(":my-lib"))
    implementation("org.apache.commons:commons-lang3:3.14.0")
}

go deeper

for a junior

Recognize that implementation hides the dep from consumers and that's why compilation broke.

for a middle

Explain leakage and that the consumer should declare its own dependency; distinguish from the case where api was actually correct.

for a senior

Walk the full reasoning (is the type in my public API?), discuss decoupling tradeoffs and tooling to detect undeclared use.

for a principal

Treat as an API-governance migration across many modules: minimizing api surface, automated detection, staged rollout, and breaking-change communication.

## What 'leakage' means Under the legacy `compile` scope (and Gradle `api`), a dependency of a library is **transitively exposed** to consumers' compile classpaths. That lets consumers compile against types they never declared — convenient, but it creates *implicit, undeclared* dependencies. The whole point of `implementation` is to stop this leakage so module boundaries are explicit and compile classpaths stay small. ## The failure sequence 1. `my-lib` declares `api("org.apache.commons:commons-lang3")`. 2. `consumer` depends on `my-lib`, and somewhere writes `StringUtils.capitalize(...)` — it compiles, because commons-lang3 leaked onto its compile classpath. 3. You change `my-lib` to `implementation("...commons-lang3")`. 4. commons-lang3 vanishes from `consumer`'s compile classpath. `consumer` fails: `package org.apache.commons.lang3 does not exist`. ## How to reason about the fix Ask **two questions**: **(a) Does the leaked type appear in *my-lib*'s public API** (return types, parameters, supertypes of exported classes)? - If **yes** → it genuinely belongs in your exported contract; `api` is correct. Keep it `api`. The 'fix' was a mistake. - If **no** (the type only appears inside *consumer*'s code) → `implementation` is correct, and the consumer was abusing leakage. **(b) If `implementation` is correct, who should own the dependency?** - The **consumer**, because it directly uses the type. It should add its own `implementation("...commons-lang3")`. This makes the dependency explicit and decoupled — now the consumer controls its own version and won't break if you swap your internals. ## Why reverting to `api` is usually the wrong instinct Reverting hides the design smell and re-creates the coupling: you can never remove or replace commons-lang3 without breaking consumers, and every consumer's compile classpath grows. The `api`/`implementation` split exists precisely to make such hidden coupling visible. ```kotlin // my-lib/build.gradle.kts — keep internal deps hidden dependencies { implementation("org.apache.commons:commons-lang3:3.14.0") } // consumer/build.gradle.kts — declare what you actually use dependencies { implementation(project(":my-lib")) implementation("org.apache.commons:commons-lang3:3.14.0") // now explicit } ``` ## Tooling that catches this Plugins like `com.autonomousapps.dependency-analysis` flag 'used-but-undeclared' (implicit transitive use) and 'declared-but-unused' dependencies — exactly the leakage class. Running it across a multi-module build lets you migrate to minimal `api` surfaces safely.

  • When is reverting to `api` actually the right call?
    When the type really is part of your library's public API — e.g. it appears in a public method's return type or parameter. Then consumers legitimately need it at compile time and `api` expresses that contract.
  • How can you detect undeclared transitive usage before it breaks a consumer?
    Run a dependency-analysis tool (e.g. the `dependency-analysis` Gradle plugin) that reports 'used but undeclared' and 'declared but unused' dependencies across modules.
  • Why not just keep everything `api` to avoid these breakages?
    It defeats encapsulation: consumers couple to your internals, you can't change deps without breaking them, and compile classpaths and incremental-build invalidation grow, slowing the whole build.

saying these in an interview costs you the question

  • Reflexively reverting to `api` instead of asking whether the type is in your public API.
  • Blaming Gradle rather than recognizing an implicit undeclared dependency was being relied upon.
  • Telling the consumer to depend on your internal transitives instead of declaring its own direct dependency.

context