Declaring Dependencies: implementation, api, compileOnly, runtimeOnly & testImplementation
Pick the right configuration so your build stays fast and your API stays clean.
Every line inside a Gradle `dependencies {}` block attaches a library to a named *configuration*. A configuration is just a bucket of dependencies with rules about *when* they are visible: at compile time, at runtime, only during tests, and crucially whether they *leak* to the projects that depend on yours. The Kotlin and Java plugins ship a standard set of these buckets, and choosing the right one is the single biggest lever you have over build speed and clean module boundaries. The wrong choice still compiles, so mistakes hide easily until a refactor or a downstream consumer surfaces them.
`implementation` is your default. The dependency is on the compile and runtime classpath of *this* module, but it is an internal detail: it does not appear on the *compile* classpath of consumers. That means if module `:app` depends on `:lib`, and `:lib` uses Gson via `implementation`, then `:app` cannot accidentally `import com.google.gson.*`. The payoff is twofold: a cleaner public surface, and far faster incremental builds, because changing an `implementation` dependency only recompiles `:lib`, not everything downstream. Reach for `implementation` unless you have a concrete reason not to.
`api` is the deliberate exception. It puts the dependency on the consumer's compile classpath too, so it *leaks* on purpose. Use it only when a type from that library appears in your module's own public API, for example a public function that returns an `OkHttpClient` or takes a Guava `ImmutableList` as a parameter. If a consumer must be able to name the type to call your code, the dependency belongs to your contract and should be `api`. Overusing `api` is a classic mistake: it rebuilds the whole graph on every change and quietly couples consumers to libraries they never asked for. Note the `api` configuration requires the `java-library` plugin (or Kotlin's JVM plugin, which provides it).
`compileOnly` and `runtimeOnly` are the two halves of the classpath split. `compileOnly` provides a library at compile time but never bundles it for runtime, used for annotation-only artifacts, provided servlet APIs, or libraries you expect the host environment to supply. `runtimeOnly` is the mirror image: absent at compile time, present at runtime, ideal for things you only reference by configuration or reflection such as a JDBC driver, a SLF4J logging backend like Logback, or a runtime-only Jackson module. Neither leaks to consumers. For annotation processors specifically, prefer the dedicated `kapt`, `ksp`, or `annotationProcessor` configurations rather than `compileOnly`.
Test configurations form a parallel hierarchy. `testImplementation` adds a library only to the test compile and runtime classpaths, never to production code or to consumers, which is exactly where JUnit, MockK, Kotest, or Truth belong. Its siblings `testCompileOnly` and `testRuntimeOnly` (for example the JUnit Platform launcher) follow the same rules as their main counterparts. Because test dependencies live in their own buckets, they add zero weight to your shipped artifact and never appear in another module's classpath.
Two practical habits keep all of this maintainable. First, centralize versions in a Gradle *version catalog* (`gradle/libs.versions.toml`) so every module references the same coordinates through type-safe `libs.*` accessors instead of hardcoded strings. Second, when in doubt default to `implementation` and only widen to `api` when the compiler or a consumer genuinely forces your hand. The mental test is simple: ask "does a project that depends on me need to *see* this type to use my code?" If yes, it is `api`; if no, keep it `implementation` and enjoy the faster, more isolated build.
// gradle/libs.versions.toml โ centralized version catalog[versions]kotlin = "2.1.0"okhttp = "4.12.0"junit = "5.11.3"[libraries]okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }logback = { module = "ch.qos.logback:logback-classic", version = "1.5.12" }postgres = { module = "org.postgresql:postgresql", version = "42.7.4" }junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "1.11.3" }[plugins]kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
Define versions and coordinates once; modules reference them via type-safe libs.* accessors.
// build.gradle.kts โ a realistic dependencies block for a library moduleplugins {`java-library` // enables the leaking `api` configurationalias(libs.plugins.kotlin.jvm)}dependencies {// Leaks to consumers: OkHttp types appear in this module's public APIapi(libs.okhttp)// Internal detail: present here, hidden from consumers, faster rebuildsimplementation("com.google.code.gson:gson:2.11.0")// Compile-only: annotations not needed at runtimecompileOnly("org.jetbrains:annotations:24.1.0")// Runtime-only: never imported, loaded by reflection/config at run timeruntimeOnly(libs.postgres)runtimeOnly(libs.logback)// Test-only: invisible to production code and to consumerstestImplementation(libs.junit.jupiter)testRuntimeOnly(libs.junit.launcher)}tasks.test { useJUnitPlatform() }
api leaks on purpose; implementation/compileOnly/runtimeOnly/test* stay private to this module.
๐ง Check your understanding
0/1 ยท 0/1 answered1. Module :lib has a public function `fun client(): OkHttpClient`, and module :app depends on :lib. Which configuration must :lib use for OkHttp so :app can name the OkHttpClient return type?