skip to content

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%

answer

  1. api = leaks to consumers; implementation = hidden
  2. api needs java-library plugin
  3. use api only for public signatures
  4. apiElements vs runtimeElements
  5. implementation = runtime-transitive, not compile-transitive

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.

solid answer

~40 s

Both put `:lib` on the consumer's compile and runtime classpath. The difference is **transitivity / leakage**: - `implementation(project(":lib"))` — `:lib` is an **internal** dependency. It's on the consumer's own classpath but is **not** exposed to projects that depend on the consumer. They cannot compile against `:lib`'s types. - `api(project(":lib"))` — `:lib` is part of the consumer's **public API**. It's placed on the consumer's `apiElements` configuration, so downstream consumers also get `:lib` on their compile classpath. `api` requires the **`java-library`** plugin (the plain `java` plugin has no `api` configuration). The rule of thumb: use `api` only when `:lib`'s types appear in your **public** signatures (return types, public method parameters, public superclasses); otherwise use `implementation` to minimise the ABI surface, reduce recompilation, and keep module boundaries clean.

code

kotlin · 8 lines
kotlin
plugins { `java-library` }

dependencies {
    // :model types appear in :service's public method signatures
    api(project(":model"))
    // :util used only internally — hide it from consumers
    implementation(project(":util"))
}

go deeper

for a junior

Know that api exposes the dependency to consumers and implementation hides it; default to implementation.

for a middle

Explain the compile-vs-runtime distinction (apiElements vs runtimeElements) and the public-signature rule for choosing api.

for a senior

Discuss recompilation blast radius / ABI propagation and how implementation shrinks the compile graph for faster incremental builds.

for a principal

Treat api/implementation as an architectural governance tool across many modules; consider linting/conventions plugins that flag accidental api leakage.

## The two configurations The `java-library` plugin splits compile dependencies into two buckets: - **`api`** — dependencies that are part of *your module's public API*. They leak to consumers. - **`implementation`** — internal dependencies. They're on *your* classpath only and are hidden from consumers. This applies identically to **project dependencies**: ```kotlin // service/build.gradle.kts plugins { `java-library` } dependencies { api(project(":model")) // :service exposes :model's types implementation(project(":util")) // :util is hidden from :service's consumers } ``` ## What "public API" means here Use `api` when a type from the dependency appears in a **public or protected** signature of your module — a return type, a parameter, a public field, a thrown checked exception, or a superclass/interface. A consumer that calls those methods needs that type on its own compile classpath, so it must be exposed. Use `implementation` when the dependency is purely internal — used only inside method bodies or private members. Consumers never reference it, so it stays hidden. ## Why it matters beyond compilation 1. **Smaller compile classpaths** — `implementation` dependencies are kept off downstream compile classpaths, so fewer types are visible and accidental coupling is prevented. 2. **Faster builds / less recompilation** — changing the *implementation-only* internals of `:lib` (when it's behind `implementation`) does not force recompilation of the consumer's consumers, because the ABI they see is unchanged. With `api`, ABI changes propagate further. 3. **Cleaner architecture** — `implementation` enforces encapsulation between modules. ## The mechanics: configurations Under the hood the `java-library` plugin creates outgoing configurations: - `apiElements` — what consumers get at **compile** time (includes `api` deps). - `runtimeElements` — what consumers get at **runtime** (includes both `api` and `implementation` deps — implementation deps are still needed to *run*, just not to *compile against*). So `implementation` deps are runtime-transitive but **not** compile-transitive. ## Common mistake Using the plain `java` plugin and trying `api(...)` — it fails because `java` only provides `implementation`. Apply `java-library` to get the `api` configuration.

  • If :app depends on :service via implementation, and :service depends on :util via implementation, can :app compile against :util's types?
    No. Both hops are implementation, so :util is hidden at compile time from :app. :util is still on :app's runtime classpath (runtime is transitive), but :app cannot reference :util's types in its source.
  • Why does over-using api hurt build performance?
    api dependencies leak onto consumers' compile classpaths, so an ABI change in a deep dependency triggers recompilation of more downstream modules. implementation cuts that chain, shrinking the recompilation blast radius.
  • Which plugin do you need for api(...) and why?
    java-library. The plain java plugin only defines implementation/compileOnly/runtimeOnly; java-library adds the api configuration plus the apiElements outgoing variant that carries api deps to consumers.

saying these in an interview costs you the question

  • Claiming implementation dependencies are not available at runtime to consumers — they are (runtime is transitive); they're only hidden at compile time.
  • Saying api works with the plain java plugin.
  • Defaulting everything to api 'to be safe' — it defeats encapsulation and slows incremental builds.

context