In a Gradle multi-module Kotlin or Java build, what's the difference between declaring a dependency with the api configuration versus the implementation configuration, and how does that difference help enforce API and implementation separation?
answer
- api = transitive compile exposure
- implementation = hidden from consumers
- java-library plugin
- faster incremental builds
- requires transitive is the JPMS analog of api
basics
~20 sapi means 'this dependency is part of what I expose,' so it shows up on their build too. implementation means 'I use this internally,' so it stays hidden - consumers can't see or accidentally depend on it.
solid answer
~40 sGradle's api configuration puts a dependency on the compile classpath of every module that depends on you, exposing it transitively, while implementation keeps it off downstream compile classpaths entirely. So if module B exposes a type from library X in its own public method signatures, X must be declared api in B's build file, or consumers fail to compile with a missing-class error. If B only uses X internally and never leaks its types through its public surface, declaring X as implementation hides that dependency completely, shrinks consumers' compile classpath, and lets Gradle skip recompiling consumers when an implementation-only dependency changes, which also speeds up incremental builds.
go deeper
Should know the two keywords exist and roughly that one is exposed to consumers and one isn't, without necessarily explaining the build-performance mechanism.
Should explain the compile-classpath propagation difference concretely and know that a leaking type forces a dependency to be declared api.
Should connect the distinction to incremental build performance at scale and to the JPMS requires/requires-transitive analog, and recognize it is not a runtime boundary.
Should discuss using this as an org-wide enforced convention (e.g. a custom Gradle plugin restricting which modules may declare api dependencies at all) to keep the encapsulation goal from eroding across many teams.
## The two configurations Gradle's `java-library` plugin introduces two dependency configurations with different visibility semantics. | Configuration | What it does to a consumer | |---|---| | `api` | A dependency declared api is exposed transitively: it appears on the compile classpath of every module that depends on the module declaring it. | | `implementation` | A dependency declared implementation is only added to that module's own compile and runtime classpath - it is not exposed to consumers at compile time, though it still ends up on the full runtime classpath transitively, since the application still needs it present to actually run. | The distinction is purely about what consumers need in order to compile against you, not about whether the dependency exists in the final assembled application. ## Why the distinction exists This exists primarily for **build performance** and **communication of intent** at scale. - Gradle's incremental compiler can use the api/implementation distinction to decide what needs recompiling: if you change the public signature of an implementation-only dependency, Gradle knows no downstream consumer compiled against that dependency in the first place, so none of them need to be recompiled - only the module that directly declared it does. On a large multi-module build with hundreds of modules, this can be the difference between a change triggering a handful of recompilations versus cascading through the entire dependency graph. - Beyond performance, the two keywords communicate developer intent directly in the build file: `api` says 'this is genuinely part of what I promise,' `implementation` says 'this is an internal detail, ignore it.' ## A build-time rule, not a runtime boundary The enforcement mechanism here is different in kind from JPMS: this is a build-time, compile-classpath visibility rule enforced by the build tool, not a JVM-level runtime boundary. Nothing stops code at runtime from reflectively reaching an implementation-only dependency's classes, because they are still physically present on the runtime classpath - the split is a compile-time convenience and performance mechanism, not a hard security or access-control boundary. This matters when comparing it to JPMS's `exports/opens`, which do enforce visibility at class-loading and reflection time. ## The trade-off The main trade-off is that this only works with discipline: 1. **Declaring everything api by default**, out of laziness or to avoid dealing with compile errors, defeats the purpose entirely - consumer builds slow down and unnecessary coupling creeps in, since every api dependency becomes part of what every consumer's classpath drags in. 2. **Going the other way, marking a dependency implementation** when one of its types actually appears in a public method signature produces a compile failure for every downstream consumer the moment they try to use that method, because the needed type simply isn't on their classpath - Gradle fails loudly rather than silently doing the wrong thing, which is a deliberately good failure mode, though it can confuse developers seeing an error about a class they never explicitly imported. ## A concrete real-world case A concrete real-world case: a module `reporting` depends on a PDF-generation library only to render report output internally, with no PDF-related type ever appearing in reporting's own public method signatures. Declaring that dependency implementation means a consumer module that only calls `reporting.generateSummary()`: - never sees the PDF library on its own compile classpath, - never needs to know it exists. And reporting's team can later swap PDF libraries entirely without touching any consumer. This mirrors, at the Gradle dependency-declaration level within a single module, the same goal that a dedicated api/impl module split achieves at the artifact level - and the two techniques compose naturally: a dedicated `foo-api` module can itself use api and implementation internally for its own dependencies, and a dedicated `foo-impl` module typically declares its dependency on foo-api itself as api, since foo-impl's own public classes implement foo-api's interfaces and therefore need to expose those types onward too. ## The JPMS analog It's also worth noting the direct JPMS analog, since both mechanisms solve a similar problem at different layers. | Declaration | Effect | |---|---| | `requires transitive` in a `module-info.java` file | Behaves like Gradle's api, exposing a dependency's exported packages onward to your own consumers so they don't need to separately require it themselves | | a plain `requires` | Behaves like implementation, pulling the dependency in for your own use without exposing it further | The key difference is that Gradle's api/implementation split is purely a build-tool convention checked at compile time within a Gradle-aware toolchain, while JPMS's `exports` and `requires transitive` are enforced by the JVM itself at module-resolution and class-loading time, independent of which build tool produced the jar. A team migrating from a plain classpath-based build to JPMS modules often finds that api dependencies map naturally onto requires transitive declarations, since both describe the same underlying question: what does this module promise to hand onward to whoever depends on it.
- What happens if you declare a dependency as implementation but one of its types actually appears in a public method signature of your module?Consumers get a compile error the moment they try to use that method, because the type needed to reference its return or parameter type isn't on their compile classpath even though the method itself is visible. The fix is reclassifying that dependency as api in the module that declared it, not working around it downstream.
- Does using implementation instead of api prevent downstream code from reflectively loading the hidden class at runtime?No - implementation-only dependencies are still normally present on the full runtime classpath because the application needs them to actually run, so reflection can still reach those classes. The split is a compile-time convenience and build-performance mechanism, not a runtime access-control boundary; a hard runtime boundary requires something like JPMS strong encapsulation instead.
Think of a company's public partner list versus its subcontractors: api dependencies are like officially listed partners that everyone dealing with the company also sees and deals with, while implementation dependencies are internal subcontractors the company uses to get work done - clients never need to know their names.
saying these in an interview costs you the question
- Claims api/implementation is a runtime or security boundary
- Cannot explain the incremental-build motivation for the distinction
- Doesn't know that a type leaking into a public signature forces reclassifying a dependency as api
- Conflates this Gradle-level mechanism with JPMS module exports