How does api vs compile affect how Gradle resolves dependencies?

In the notes for upgrading from Gradle 6.x, there’s a section that says this:

Since its inception, Gradle provided the compile and runtime configurations to declare dependencies…
The implementation configuration should [now] be used to declare dependencies which are implementation details of a library…
The api configuration, available only if you apply the java-library plugin, should be used to declare dependencies which are part of the API of a library, that need to be exposed to consumers at compilation time.
In Gradle 7, both the compile and runtime configurations are removed.

https://docs.gradle.org/current/userguide/upgrading_version_6.html#sec:configuration_removal

This got me wondering what impact making those changes actually has when authoring a Java library.

Gradle and dependencies

When Gradle is trying to build your application, it downloads dependencies from an artifact repository. More specifically for Java dependencies, it downloads a POM file and a JAR file. The POM file is XML and the JAR file is the build output from the Java compiler.

If you have a Java library with a few compile dependencies, the JAR file built by Gradle when you do ./gradlew build DOES NOT, by default, contain the JAR files for those dependencies. This wasn’t immediately obvious to me.

In order for Gradle to know about the compile dependencies the POM file for the library must include references to the dependencies and that is how Gradle knows to download them as well and put them on the classpath too. This wasn’t immediately obvious to me either.

api vs implementation

So what’s the difference between the build output of a library when you use api vs implementation in the dependencies?

Nothing.

There’s only a difference if you generate a POM file to be stored in the artifact repository alongside your JAR, in which case you’ll see different XML output in the POM file. This wasn’t immediately obvious to me for the third time.

Here’s the Gradle dependencies config:


dependencies {
    // This dependency is exported to consumers, that is to say found on their compile classpath.
    api 'org.apache.commons:commons-math3:3.6.1'

    // This dependency is used internally, and not exposed to consumers on their own compile classpath.
    implementation 'com.google.guava:guava:30.1.1-jre'
}

And here’s the resulting POM output when generated by the maven-publish plugin.

<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.jdbevan.gradle</groupId>
  <artifactId>lib</artifactId>
  <version>0.0.1</version>
  <packaging>pom</packaging>
  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-math3</artifactId>
      <version>3.6.1</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
      <version>30.1.1-jre</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

As you can see, api dependencies get listed as compile scope in the POM, and implementation dependencies get listed as runtime dependencies in the POM. Gradle uses this information to establish which classpath to make the dependencies available in: the compile classpath or the runtime one.

I guess the old dependency configurations used to just match the POM scopes exactly, and now they have different names, but the behaviour is still the same: the dependency configuration is used to create a POM file that defines which JAR files for which dependencies will be put on the classpath during compilation and at runtime.