Gradle configurations confuse most Java developers for months. The terminology is dense, the concepts overlap, and the docs dive into details before explaining the idea. This article is the explanation I wish I’d had earlier.

What a configuration is

A configuration is a named bucket of dependencies with a purpose. Each bucket answers “when do these dependencies apply?”

  • implementation — compile my code, include at runtime, hide from dependents
  • api — compile my code, include at runtime, expose to dependents
  • compileOnly — compile my code, NOT at runtime, NOT exposed
  • runtimeOnly — NOT for compile, but include at runtime
  • testImplementation — compile tests, runtime for tests, not in production
  • annotationProcessor — available to annotation processors only

Each serves a specific scenario. Pick by matching scenario to configuration.

The difference that matters most

implementation vs api:

  • implementation("x")x is on my compile classpath; it is NOT on my consumers’ compile classpath
  • api("x")x is on my compile classpath AND on my consumers’ compile classpath

Why this matters: if you declare implementation("slf4j-api"), and a downstream module uses your code, they can’t directly import org.slf4j.Logger unless they also declare slf4j-api themselves. That’s a feature — it keeps compile classpaths lean and builds fast.

Use api only when a type is in your public interface. If your method signature is public Logger createLogger(), consumers need to see Logger — declare api("slf4j-api").

compileOnly — the one people miss

compileOnly puts a dependency on the compile classpath but not the runtime. When would that help?

Provided dependencies. Lombok is compileOnly:

compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")

Lombok generates code at compile time; at runtime, it’s not needed. compileOnly keeps the jar out of the deployed artifact.

Interface-only. You want to compile against a type but the runtime environment provides the implementation. Example: Servlet API in war files — the servlet container provides it.

Gradle plugin APIs. When writing Gradle plugins, tool classes are compileOnly because Gradle provides them at runtime.

runtimeOnly — the other half

Opposite direction: needed at runtime but not at compile.

JDBC drivers. Your code uses java.sql.Connection (JDK-provided). The PostgreSQL driver is accessed via reflection/service loader. runtimeOnly("org.postgresql:postgresql") ensures it’s packaged but your compile classpath doesn’t depend on it.

Logging implementations. Code uses slf4j-api; the actual logger (logback-classic) is wired in at runtime.

Keeps compile classpath minimal — only interfaces.

Annotation processors

Special bucket. annotationProcessor dependencies are on the processor’s classpath during compilation — they generate code but aren’t part of your runtime.

annotationProcessor("org.mapstruct:mapstruct-processor")
implementation("org.mapstruct:mapstruct")

Two declarations: processor for compile, mapstruct for the runtime types.

Test configurations

Parallel to main, prefixed with test:

  • testImplementation — for test code, not in main artifact
  • testRuntimeOnly — runtime test classpath only
  • testCompileOnly — compile tests only

For integration tests in separate source sets, Gradle creates matching configurations (integrationTestImplementation, etc.).

Custom configurations

The advanced feature that solves real problems.

Example: native dependencies per platform

val linuxNative by configurations.creating
val macosNative by configurations.creating

dependencies {
    linuxNative("some:native-lib:1.0:linux-x86_64")
    macosNative("some:native-lib:1.0:darwin-aarch64")
}

tasks.register<Copy>("copyNatives") {
    from(linuxNative)
    into(layout.buildDirectory.dir("linux"))
}

Custom configuration holds dependencies that aren’t on the standard classpath but are consumed by specific tasks.

Example: tooling dependencies

val checkstyle by configurations.creating

dependencies {
    checkstyle("com.puppycrawl.tools:checkstyle:10.12.0")
}

Isolates tool classpath from application classpath.

Configuration resolution

Gradle turns declared dependencies into an actual classpath by resolving the configuration — downloading artifacts, computing transitive dependencies, applying conflict resolution.

Version conflicts: newest wins by default. Can be overridden with strict constraints or resolution strategies:

configurations.all {
    resolutionStrategy {
        eachDependency {
            if (requested.group == "com.google.guava") {
                useVersion("33.0.0-jre")
            }
        }
    }
}

Force a specific version across all transitive uses. Use sparingly — dependency overrides can mask real conflicts.

Platform and BOM

implementation(platform("org.springframework.boot:spring-boot-dependencies:3.4.0"))
implementation("org.springframework.boot:spring-boot-starter-web")  // version inherited

platform(...) declares a BOM (bill of materials) — a centralized version set. Avoids version mismatches between related libraries. Spring Boot’s BOM ensures all Spring libraries align to the same release.

Exclusions

Transitive dependency you don’t want:

implementation("some:lib") {
    exclude(group = "old.stuff", module = "outdated-transitive")
}

Or globally:

configurations.all {
    exclude(group = "commons-logging", module = "commons-logging")
}

Common reasons: removing SLF4J bridges that conflict, excluding old crypto libraries, removing deprecated transitives.

Common misuses

Everything is implementation. Even things returned from public methods. Consumers now can’t use those types.

Everything is api. Compile classpath bloated. Slow builds. Tight coupling.

Using compile (deprecated). Old-style Gradle. Migrate to implementation or api explicitly.

Putting version in each module. Duplication, drift. Use version catalogs or BOMs.

Annotation processor missing. Code compiles but Lombok/MapStruct/etc. doesn’t run. Always pair implementation with annotationProcessor for such tools.

Debugging

Show the resolved dependencies:

./gradlew :my-module:dependencies --configuration runtimeClasspath

Prints the full tree of what’s on the runtime classpath, including transitives and why each one is there. Essential when debugging version conflicts or mysterious classloading.

./gradlew :my-module:dependencyInsight --dependency some.library shows why a specific library is included.

Closing note

Gradle configurations are conceptually clean once you see them as “buckets with a purpose.” Most daily work uses only implementation, api, testImplementation, compileOnly, annotationProcessor, and occasionally runtimeOnly. The rest — custom configurations, resolution strategies, platform imports — solve specific problems as they arise. Start with the basics, reach for the advanced features only when you hit their motivating problems, and the build stays maintainable as the project grows.