Gradle dependency configurations - implementation and api
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.