Actual footage of different kinds of Gradle Configurations
The first time I heard about Gradle configurations, I thought it’d be about writing build.gradle
files and configuring some DSL
and writing {}
blocks. Then I started writing plugins and realized that they have a configuration phase too that is run before execution.
Well these are all configurations for sure… They also hide another type of configuration, which plays a center role in Gradle dependency management: the Configuration API.
Everytime you add a new dependency to a project, you’re actually using configurations behind the scenes:
The Gradle dependency management documentation is very detailed. It has a very detailed page about terminology and another one on resolvable vs consumable Configurations that I recommend reading. It’s also a lot of information to process.
This article goes the other way and starts from the concrete example above to go up and expose the three different kinds of configurations in real life.
A concrete example
To understand what implementation()
does, let’s start from scratch! Create a new empty Gradle project with a single build.gradle.kts
file containing:
Running ./gradlew dependencies
should fail:
That’s because implementation
is not a regular method from the Gradle Core API, it is a generated accessor, generated automatically by Gradle to make it easier to work with the DSL (Groovy has the same syntax although it's more dynamic and doesn't rely on generated accessors). You don't have to rely on the generated accessors though. Everything is Gradle is doable using the plain Gradle APIs:
Running ./gradlew dependencies
should still fail:
Fair enough. Since we started from an empty build.gradle.kts
file, Gradle doesn't even know what we're trying to do. OkHttp
and implementation
are JVM concepts so it makes sense that Gradle doesn't force it by default. In fact, the Java plugin creates the implementation
configuration (see doc). Let's add it:
Running ./gradlew dependencies
will now show a lot more information. The result is too long to be displayed here, but you should see something like this (test configurations omitted for clarity):
annotationProcessor
Annotation processors and their dependencies for source set 'main'.apiElements
- API elements for main. (n)archives
- Configuration for archive artifacts. (n)compileClasspath
- Compile classpath for source set 'main'.compileOnly
- Compile only dependencies for source set 'main'. (n)default
- Configuration for default artifacts. (n)implementation
- Implementation only dependencies for source set 'main'. (n)runtimeClasspath
- Runtime classpath of source set 'main'.runtimeElements
- Elements of runtime for main. (n)runtimeOnly
- Runtime only dependencies for source set 'main'. (n)
Pheewww, that’s a lot! We won’t be able to cover all of them in this article but we’ll cover the most representative ones. Let’s skip the default
configuration that is now deprecated and put aside the archives
and annotationProcessor
ones for now, that leaves us with apiElements
, compileClasspath
, compileOnly
, implementation
, runtimeClasspath
, runtimeElements
and runtimeOnly
.
Let’s start with the ubiquitous one, implementation
.
“Bucket of dependencies” Configurations
implementation
is a "bucket of dependencies" Configuration. This is where you add dependencies like com.squareup.okhttp3:okhttp:4.9.0
. To get the list of dependencies (but not their files, more on that later), you can do things like:
Run ./gradlew
to trigger the compilation and evaluation of your build.gradle.kts
script:
So far so good! The dependency you just added has been registered. It’s registered as a DefaultExternalModuleDependency
because it gets its file from an external repo (MavenCentral here), that's fair. Ultimately though, you want to get access to the okhttp
jar as well as its transitive dependencies: okio
and kotlin-stdlib
. The way this is usually done is by reading files directly from the Configuration
. Indeed, a Configuration extends from a FileCollection so it has a getFiles()
method. Let's try to display the files in our configuration, this is called resolving the configuration:
That shouldn’t go too well:
💥 Damn, this is where things get fun… Indeed, if you remember the results of the first ./gradlew dependencies
, there was this line:
Getting the list of jar files contained in the implementation
configuration, i.e. resolving it, is not possible. If you dump configurations["implementation"].isCanBeResolved
, you will see it will indeed be false
. This configuration holds dependencies declarations but cannot be resolved itself. For this, you'll need resolvable configurations (see doc).
Resolvable Configurations
If you look at the earlier ./gradlew dependencies
output, you can find two resolvable configurations:
Both these configurations don’t have a (n)
in front of them, meaning you can resolve them, Let's do this:
Huge success! You just resolved your first configuration. In fact this is the same thing that the Java/Kotlin compiler will use to determine what jars to put on the compile classpath (hence the "compileClasspath"
name!). Whenever you need to compile against okhttp
, the compiler also needs okio
and kotlin-stdlib
. It needs okio
because okio
is in the okhttp
API. Function such as ResponseBody.source() expose a okio.BufferedSource so the compiler needs that symbol in the compile classpath (you can read more about api vs implementation here).
What about runtimeClasspath
then? Well in this specific case, it's going to be the same. This is because the exact same dependencies are needed both to compile the project and to run it. This isn't a general rule though. If okhttp
wrapped all the okio
types and did not expose them, okio
wouldn't be needed to compile the project.
In addition to the above resolvable configurations, the java
plugin creates 2 non-resolvable, implementation-like, "bucket of dependencies", configurations:
compileOnly
to add a dependency tocompileClasspath
only. This is typically what's used by Gradle plugins to compile against the Gradle API but not use it at runtime since it's provided by the Gradle instance that runs the plugin.runtimeOnly
to add a dependency toruntimeClasspath
only. This is used less often but is useful in cases where multiple implementations of the same API could be made available at runtime. For an example using ServiceLoader or another mechanism. This happens with logging frameworks like SLF4J. The project is compiled using an abstract logger. The actual implementation is being loaded at runtime but not needed during compilation.
Using Configuration.extendsFrom(), Gradle can make dependencies from these configurations available to the resolvable configurations. When compileClasspath extends from compileOnly, all the files from compileOnly will be available in compileClasspath.
In practice, the java
plugin uses the following (from the doc):
- implementation (non resolvable)
- compileOnly (non resolvable)
- runtimeOnly (non resolvable)
- compileClasspath extends compileOnly, implementation
- runtimeClasspath extends runtimeOnly, implementation
The first three are where you add dependencies. The last two are used by the JavaCompile task and runners.
Note that there is no api
configuration in the list. This is because api
only make sense for library projects that can be consumed by another project. The api
configuration is added by the java-library
plugin (and not the java
one)
If you go back to the original list of configurations, we have covered compileClasspath
, compileOnly
, implementation
, runtimeClasspath
and runtimeOnly
.
So what are runtimeElements
and apiElements
?
Consumable Configurations
runtimeElements
and apiElements
are consumable
configurations. Consumable configurations are meant to be used by other projects consuming this project. I know this is very close to "resolvable". In Gradle terminology:
- Resolvable is to read the files from a configuration inside a project
- Consumable is to expose files to consumers outside the project
It makes more sense for library projects. For some reason, it’s also added for non-library projects. I’m guessing some project could consume the executable jar too. In all cases, you can get the consumable configurations with ./gradlew outgoingVariants
:
The consumable configurations are used during variant-aware selection. If you have seen a message such as below, chances are that some consumable configuration exposes incompatible attributes.
A consumable configuration can have attributes using the Configuration.attributes
API and then expose artifacts using the Project.artifacts
API. There's a lot in there and that'll certainly deserve a separate article.
Conclusion
The Configuration API is a corner stone of the dependency management in Gradle. Despite all of them being Configurations, the “bucket of dependencies”, resolvable and consumable configuration are very different. I hope this simple example helps to understand what are the different types of Configurations. If you ever want to double check, you can always dump the values of isCanBeResolved
and isCanBeConsumed
:
In this article, we’ve seen the three different types of configurations:
- Bucket of dependencies (
implementation
,runtimeOnly
,compileOnly
) are used by the user to declare dependencies. They are neither resolvable nor consumable... ...well, except forcompileOnly
that is both! I didn't expected that when I started writing this article. If anyone has an explanation, I'll take it*. - Resolvable configurations (
runtimeClasspath
andcompileClasspath
) are the resolvable configurations to be used inside the project by tasks like compileJava and compileKotlin to get the actual jar files. - Consumable configurations (
apiElements
andruntimeElements
): are the consumable configurations to be consumed by other projects and used by variant aware selection. You can see them with./gradlew outgoingVariant
.
When in doubt, always refer to the official terminology doc which is super useful!
Happy configuring!
*: I got an explanation from Cedric Champeau! compileOnly
was present before runtimeOnly
and has both flags set to true for backward compatibility reasons. And actually, both flags are set to false by default starting with Gradle 7.