Gradle has deprecated the compile dependency configuration in favor of implementation and api a while back. The differences are explained both in Gradle’s documentation and on StackOverflow. However, when migrating legacy projects to current infrastructure, I still find myself confused at times and having to re-read the explanations, so I thought I’ll do a quick experiment to highlight the effects of each configuration in practice.


All the code can be found here

Let’s take a dummy project where I will show how the dependency configurations affect visibility of transitive dependencies.

The root project app will be a java application project and depend on two submodules:

plugins {
    java
    application
}
dependencies {
    implementation(project("submodule-java"))
    implementation(project("submodule-javalib"))
}


Implementation

Let’s start with submodule-java which will use java plugin and declare an implementation level dependency. The dependency itself is arbitrary, I don’t care about its semantics here.

plugins {
    java
}
dependencies {
    implementation("org.msgpack:msgpack-core:0.8.18")
}

To inspect the module’s compile classpath we can do:

$ ./gradlew :submodule-java:dependencies --configuration compileClasspath

> Task :submodule-java:dependencies

------------------------------------------------------------
Project :submodule-java
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- org.msgpack:msgpack-core:0.8.18

All I want is to see how implementation configuration will affect the consumer of this module, which is the root project. I will create a class with two static methods in the module - one that will use the implementation dependency in its implementation and another one that will try to expose a class from the implementation dependency in its API:

public class SubmoduleJava {

    public static void useDependency() {
        var packer = MessagePack.newDefaultBufferPacker();
        System.out.println(packer);
    }

    public static MessageBufferPacker getDependency() {
        return MessagePack.newDefaultBufferPacker();
    }
}

In the root project, I will create a Main class and try to call both methods from the submodule:

public class Main {

    public static void main(String[] args) {
        SubmoduleJava.useDependency();

        var dependencyFromJavaSubmodule = SubmoduleJava.getDependency();
        System.out.println(dependencyFromJavaSubmodule);
    }
}

And when I try to compile this, I get:

        var dependencyFromJavaSubmodule = SubmoduleJava.getDependency();
                                                                     ^
  class file for org.msgpack.core.MessageBufferPacker not found

The transitive dependency is not visible to the root’s project compile classpath!

However, if I only leave SubmoduleJava.useDependency(); in the root module, everything compiles and I can run it:

$ ./gradlew run

> Task :run
org.msgpack.core.MessageBufferPacker@17ed40e0

Which indicates that the transitive dependency is on the runtime classpath. Let’s check it:

$ ./gradlew :dependencies --configuration runtimeClasspath

> Task :dependencies

------------------------------------------------------------
Root project
------------------------------------------------------------

runtimeClasspath - Runtime classpath of source set 'main'.
+--- project :submodule-java
|    \--- org.msgpack:msgpack-core:0.8.18

And double check it:

$ zipinfo -1 build/distributions/app.zip
app/
app/lib/
app/lib/app.jar
app/lib/submodule-java.jar
app/lib/msgpack-core-0.8.18.jar
app/bin/
app/bin/app.bat
app/bin/app

Indeed, the transitive dependency is there. So the implementation configuration will protect the users of a module from the module’s transitive dependencies leaking to compile classpath. If I want to use a library that the module I depend on uses transitively, I have to make a concious decision to include it in my compile classpath, which is very nice.


API

submodule-javalib will use java-library plugin and declare an api level dependency. Note that api dependencies are only available with java-library plugin.

plugins {
    `java-library`
}
dependencies {
    api("com.fasterxml.jackson.core:jackson-databind:2.10.0")
}

Likewise, its compile classpath can be inspected:

$ ./gradlew :submodule-javalib:dependencies --configuration compileClasspath

> Task :submodule-javalib:dependencies

------------------------------------------------------------
Project :submodule-javalib
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- com.fasterxml.jackson.core:jackson-databind:2.10.0
     +--- com.fasterxml.jackson.core:jackson-annotations:2.10.0
     \--- com.fasterxml.jackson.core:jackson-core:2.10.0

Similarly as before, to see how api configuration will affect the consumer of this module - the root project, I will create a class with two static methods - one that will use the dependency and another that will expose it in a public API:

public class SubmoduleJavaLib {

    public static void useDependency() {
        var objectMapper = new ObjectMapper();
        System.out.println(objectMapper);
    }

    public static ObjectMapper getDependency() {
        return new ObjectMapper();
    }
}

Let’s call it from root’s Main:

public class Main {

    public static void main(String[] args) {
        SubmoduleJavaLib.useDependency();

        var apiDependencyFromJavaLibSubmodule = SubmoduleJavaLib.getDependency();
        System.out.println(apiDependencyFromJavaLibSubmodule);
    }
}

This time everything compiles right away and I can run it:

$ ./gradlew run

> Task :run
com.fasterxml.jackson.databind.ObjectMapper@701fc37a
com.fasterxml.jackson.databind.ObjectMapper@4148db48


Again, let’s have a look at root module’s compile classpath:

$ ./gradlew :dependencies --configuration compileClasspath

> Task :dependencies

------------------------------------------------------------
Root project
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
+--- project :submodule-java
\--- project :submodule-javalib
     \--- com.fasterxml.jackson.core:jackson-databind:2.10.0
          +--- com.fasterxml.jackson.core:jackson-annotations:2.10.0
          \--- com.fasterxml.jackson.core:jackson-core:2.10.0

Note that there is no transitive dependency on msgpack from submodule-java while api dependency from submodule-javalib is included.

Yet, if we take a look at the runtimeClasspath, all the dependencies will be there:

$ ./gradlew :dependencies --configuration runtimeClasspath

> Task :dependencies

------------------------------------------------------------
Root project
------------------------------------------------------------

runtimeClasspath - Runtime classpath of source set 'main'.
+--- project :submodule-java
|    \--- org.msgpack:msgpack-core:0.8.18
\--- project :submodule-javalib
     \--- com.fasterxml.jackson.core:jackson-databind:2.10.0
          +--- com.fasterxml.jackson.core:jackson-annotations:2.10.0
          \--- com.fasterxml.jackson.core:jackson-core:2.10.0


My takeway here is that upgrading compile to implementation should not affect the runtime classpath - if some module is using a leaked transitive dependency, then it will fail to compile and I will have to investigate and figure whether that dependency should be an api of the dependency module, or whether it should be explicitly listed as an implementation dependency of the depending module.