8 minutes
Gradle compileOnly - library developer’s best friend?
Many developers might not pay much attention to the compileOnly
scope, but you’ve probably encountered it at some point. Have you ever wondered about its functionality and where it fits best?
The compileOnly
configuration was introduced in Gradle 2.12 (2016) as one of the most anticipated features. According to the original Gradle blog post:
Compile-only dependencies address a number of use cases, including:
- Dependencies required at compile time but never required at runtime, such as source-only annotations or annotation processors;
- Dependencies required at compile time but required at runtime only when using certain features, a.k.a. optional dependencies;
- Dependencies whose API is required at compile time but whose implementation is to be provided by a consuming library, application or runtime environment.
We’ll delve into these use cases shortly. You can use the following decision tree as an entry point and read the appropriate section to get more details.
flowchart TB areNeededAtRuntime(Is your dependency needed at runtime?)-->|no|useCompileOnly(Use compileOnly) areNeededAtRuntime-->|yes|areOptional(Is your dependency optional?) areOptional-->|no|useApiOrImplementation(Use api or implementation) areOptional-->|yes|isSpringBoot(Is your library a Spring Boot autoconfiguration?) isSpringBoot-->|no|multiModule(Split your library to modules\nand use api or implementation) isSpringBoot-->|yes|haveConditional(Do you need other dependencies to provide\nsupport for your optional dependency?) haveConditional-->|no|useCompileOnly1(Use compileOnly) haveConditional-->|yes|useFeatureVariants(Use Feature Variants)
How it works?
Let’s start by understanding how compileOnly
actually works.
When you publish your library to Maven Central, Gradle generates two metadata files for you:
my-library-1.0.0.pom
my-library-1.0.0.module
The pom
file is for Maven interoperability, while the module
file contains additional Gradle metadata, commonly referred to as Gradle Module Metadata (GMM). If your library is consumed by another Gradle build, the GMM is used to benefit from Gradle exclusive features like Feature Variants, which are not available in Maven.
Consider the following example in your buildscript:
build.gradle.ktsdependencies {
api("org.example:blue-library:1.0.0")
implementation("org.example:red-library:1.0.0")
compileOnly("org.example:green-library:1.0.0")
}
Publishing it to the local Maven repository:
./gradlew publishToMavenLocal
Generates the following files:
my-library-1.0.0.pom<project>
<groupId>org.example</groupId>
<artifactId>my-library</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>blue-library</artifactId>
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.example</groupId>
<artifactId>red-library</artifactId>
<version>1.0.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>
my-library-1.0.0.module{
"component": {
"group": "com.example",
"module": "example-library",
"version": "2.0.0-SNAPSHOT"
},
"variants": [
{
"name": "apiElements",
"dependencies": [
{
"group": "org.example",
"module": "blue-library",
"version": {
"requires": "1.0.0"
}
}
]
},
{
"name": "runtimeElements",
"dependencies": [
{
"group": "org.example",
"module": "red-library",
"version": {
"requires": "1.0.0"
}
}
]
}
]
}
The api
dependency1 is translated to Maven compile
scope4 and to a dependency of Gradle apiElements
variant6.
The implementation
dependency2 is translated to Maven runtime
scope5 and to a dependency of Gradle runtimeElements
variant7.
It can be suprising, but the compileOnly
dependency3 is omitted in both pom
file and the GMM. It means that this dependency is not transitive and won’t be visible in the client classpaths.
Having this basic understanding, let’s explore the use cases mentioned in the original blog post.
Pure compile-time dependencies
Dependencies required at compile time but never required at runtime, such as source-only annotations or annotation processors
I completely agree with that. In situations where the dependency is never needed at runtime, use compileOnly
.
For example:
- you use only annotations or annotation processors from the dependency
- you want to pass a type-safe reference for a class to some annotation, like
@ConditionalOnClass(OkHttpClient.class)
Using compileOnly
here prevents unnecessary transitive dependencies at the client side, simplifying dependency management.
Optional dependencies
Dependencies required at compile time but required at runtime only when using certain features, a.k.a. optional dependencies
I basically agree with this bullet point, although there is more to this topic.
Imagine you develop a library providing some functionality for HTTP clients. You want to support multiple HTTP clients, like RestTemplate
, Feign
, Ktor
, etc. How would you model it?
Option 1: Multiple modules
You can create different modules dedicated to each client, like my-library-rest-template
, my-library-feign
, and my-library-ktor
. Each module will carry its own transitive dependency to the corresponding HTTP client. Users of your library are likely to find this approach intuitive and easy to understand. As a result, this method is often the preferred choice.
Option 2: Single module
In certain cases, it may be more effective to have one module capable of handling multiple optional dependencies. A fitting example is when you’re developing a Spring Boot starter. Typically, you will have an autoconfiguration with some conditional beans, created only when a particular dependency is present.
To achieve this, set your optional dependencies as compileOnly
. The users of your library may choose to provide these dependencies or not. Your job is to ensure that NoClassDefFoundError
is not thrown, even if the optional dependency is missing. If you work with Spring, the @ConditionalOnClass
annotation will be your best friend. Outside of Spring, you can use the good old try..catch
block. For example:
fun configureKtorClient(): io.ktor.HttpClient? =
if (isOnClasspath { io.ktor.HttpClient::class }) {
// build and configure HttpClient...
} else {
null
}
private fun isOnClasspath(clazz: () -> KClass<*>): Boolean =
try {
clazz()
true
} catch (e: NoClassDefFoundError) {
false
}
The above code will simply return null
if the Ktor dependency is missing
Conditional dependencies
Let’s complicate our previous example a little. Assume that our implementation providing the Ktor support needs additional dependency for Jackson. The key thing is that Jackson is needed only when the client wants to use Ktor support. We can say that it’s a conditional dependency.
Library -> build.gradle.ktsdependencies {
compileOnly("io.ktor:ktor-client-core-jvm:2.3.7")
compileOnly("com.fasterxml.jackson.core:jackson-core:2.16.1")
}
Now, let’s say that some client uses our library, and he correctly provides the optional Ktor dependency:
Client -> build.gradle.ktsdependencies {
implementation("dev.panuszewski:http-clients-library:1.0.0")
implementation("io.ktor:ktor-client-core-jvm:2.3.7")
}
This client will most likely get the NoClassDefFoundError
when trying to use our Ktor support. Even though he provided the Ktor dependency, there are still Jackson classes missing.
The conditional dependencies can be tricky. If you have this kind of situation, I highly recommend to split your library into feature modules, as described in option-1-multiple-modules.
However, if having multiple modules is not an option for you, there is another way. Let me introduce a little-known Gradle feature called Feature Variants. It basically allows you to define various optional features which your library provides. For example, the Ktor support could be declared as a feature:
Library -> buildSrc/src/conventionPlugin.gradle.kts// we put this code in the conventionPlugin.gradle.kts
// instead of directly in build.gradle.kts to get the
// typesafe accessors like 'ktorImplementation'
java {
registerFeature("ktor") {
usingSourceSet(sourceSets["main"])
}
}
It gives you the opportunity to declare dependencies which are only resolved when a particular feature is requested by the client. In our case it would be:
Library -> build.gradle.ktsplugins {
conventionPlugin
}
dependencies {
ktorImplementation("io.ktor:ktor-client-core-jvm:2.3.7")
ktorImplementation("com.fasterxml.jackson.core:jackson-core:2.16.1")
}
The client now needs to explicitly require the Ktor feature (in Gradle terms: capability). It can be done like this:
Client -> build.gradle.ktsdependencies {
implementation("dev.panuszewski:library-a:1.0.0") {
capabilities {
requireCapability("dev.panuszewski:library-a-ktor")
}
}
}
What’s nice about this approach is that the client will automatically get all dependencies required for the Ktor support to work properly. Not so nice is the verbose syntax which most of the Gradle users are not very familiar with.
While you may like it or not, Feature Variants are a viable option to solve the problem of conditional dependencies in a single-module library.
Provided dependencies
Dependencies whose API is required at compile time but whose implementation is to be provided by a consuming library, application or runtime environment.
Spoiler: I totally don’t agree.
Developers from the Maven world tend to see compileOnly
as Gradle’s equivalent of the provided
scope, but there are differences. The compileOnly
dependencies aren’t visible in test classpaths, unlike those defined in Maven provided
scope.
If (for some reason) you need the provided
scope equivalent, either:
- put the dependency in both
compileOnly
andtestImplementation
configurations, or - apply the
war
plugin and put the dependency inprovidedCompile
configuration
Apart from the Maven analogy, let’s analyze how provided dependencies fit into the modern software development.
Application development
The provided dependencies only make sense if you deploy your application in a application server (e.g. GlassFish or JBoss). It’s not a common approach in a modern software, though. Typically, your app starts up with an embedded web server, so you have to package your application together with all the runtime dependencies. It makes the compileOnly
unsuitable.
Library development
Not only the application server can provide dependencies. If we develop a library, we can think of the client classpath as our deployment environment, and require it to provide components we depend on.
For example, let’s say we develop some utility for Spring Webflux. It basically doesn’t make sense to use it without the Webflux itself, so we define it as compileOnly
dependency:
compileOnly("org.springframework:spring-webflux:6.1.2")
It makes the library consumers responsible for providing it at runtime. Otherwise, they will most likely get NoClassDefFoundError
. It’s not the most gentle way of informing our clients that they are missing a dependency to Webflux, isn’t it? Simply making Webflux a transitive dependency (by replacing compileOnly
with api
or implementation
) would eliminate those exceptions, making our library work seamlessly.
Thus, it is advisable to refrain from using compileOnly
to specify a library’s provided dependencies. While the intention may be to reduce the number of transitive dependencies, ultimately, users will either need to supply the missing dependency themselves or encounter a runtime error. It is more effective to ensure everything works by default.