Maintaining libraries or even a library is no small feat. Libraries can start as mere pet projects, but with time grow into something users rely on. Supporting a library on the side means there is only so much energy you can dedicate to working on it
A natural path for library authors is to find ways to avoid friction in the maintenance process. One way of doing that is keeping up-to-date with modern tools that solve most problems for you. This is particularly relevant when starting from scratch
Rather unintuitively, this naive approach can hinder the ability of the library to get popular. Modern tools, especially in the land of JVM library development, may bring with themselves requirements imposed on the library users. The newer the tools, the stronger the requirements and, as a consequence, the fewer users can actually benefit from the library
In this post, we will explore how to set up a Java and Kotlin library development to get the best of both worlds
Modern by default
Let’s look at a minimal modern build file that defines a Kotlin JVM project using the latest Gradle 8.4
plugins {
`java-library`
kotlin("jvm") version "1.9.20"
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
Kotlin encourages us to use the latest plugin version. Java encourages us to use the latest toolchain. The latest plugin gives us the best integration with the build tool. The latest toolchain gives us the best performance during compilation and test execution. What else to desire?
Well, let’s take a look at the effects this setup has for our JVM library
- Kotlin and Java sources can use JDK 21 APIs
- Kotlin and Java compilation will target Java 21 bytecode
- Java 21 compiler will be used
- Java compilation daemons will run on a Java 21 JVM
- Kotlin 1.9.20 compiler will be used
- Kotlin sources can use Kotlin 1.9 language features
- Kotlin sources can use Kotlin stdlib 1.9 APIs
- Kotlin 1.9.20 standard libraries artifacts will be added to runtime dependencies
- All tests will run on a JVM with Java 21
The versions, which we provided on a higher level, trickle down into many aspects of our library development and its resulting distribution
While trying to be liberal with our project setup or our tooling, we inadvertently set a very high bar for our users. They would effectively have to have the same setup as us to even be able to use our library. Meaning, nothing less than a Java 21 and Kotlin 1.9 for both compilation and runtime
As responsible library authors, we should recognize that this is mostly likely not what we want. We want our library to be accessible to as many consumers as possible. This entails requiring only an absolute minimum from our users
Defaults in tooling favor developers over users
Let’s say our library does not need new shiny language syntax and only uses time-proved APIs. In this example, our minimal requirements in 2023 are very conservative
- Kotlin 1.6 (released in 2021)
- Java 8 (released in 2014)
There should be a way to express them in our build setup
One obvious option we have is to simply change the versions in the snippet above. However, it would be unwise to start using dated toolchains and plugins. Not only do they offer inferior performance, but they can also contain bugs that have only been fixed in later versions
We should keep using the latest tooling but override the defaults that affect our users
Conservative Java library
Using toolchains for Java development is still a best practice. It gives us consistent defaults for various aspects of development and distribution, while being defined in a single place. All we need is to override some of those defaults to cater for our users
In fact, we only need to override one thing — a JDK release
that we want to target.
Here are the steps how we can approach this
- Define two versions of java: one for the tooling and one for our users
- Use the tooling java version for the toolchain
- Use the user java version JDK
release
for the compilation
We can start with directly using the different versions in our build script
plugins {
`java-library`
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.release = 8
}
Now, there is a clear separation between the Java we use for the build and the Java we expect from our users. We can keep bumping the former as often as we want, while being explicit when we raise the requirements for our users
Modern tooling may not like conservative targets
If we do try to build our Java 8 library with a Java 21 toolchain, we’ll see a warning
warning: [options] source value 8 is obsolete and will be removed in a future release
warning: [options] target value 8 is obsolete and will be removed in a future release
warning: [options] To suppress warnings about obsolete options, use -Xlint:-options.
It’s modern Java 21 telling us that if you develop for Java exclusively, it might not be the best idea to still conform to the Java 8 source and target limitations
This is a fair point, but it does not really apply when we develop for Android or if we know our library users still have the Java 8 limitation coming from elsewhere. We can safely ignore this warning, because the toolchain still supports our desired target. Though, this might change when we test drive the next toolchain sometime later
Version catalogs
There is something we can improve in the build script, though. Currently, we mix our versions into our build logic, which requires everyone to read it, in order to understand what versions are used for what purpose. It is much better to use Gradle’s declarative Version Catalogs
We can create a catalog in the gradle/libs.versions.toml
file, which Gradle recognizes automatically
[versions]
jdkTarget = "8"
jvmToolchain = "21"
In the build file, we get type-safe accessors to the values of the versions
plugins {
`java-library`
}
java {
toolchain {
languageVersion = libs.versions.jvmToolchain.map {
JavaLanguageVersion.of(it)
}
}
}
tasks.withType<JavaCompile>().configureEach {
options.release = libs.versions.jdkTarget.map { it.toInt() }
}
Version catalogs have many benefits. One of them is making the declared versions reusable in any build script in projects containing multiple modules
Conservative Kotlin library
With Kotlin, things get a bit more involved. We do not only have an implicit dependency on a JDK, but also a dependency on the Kotlin standard libraries and the language itself
Having said that, using the latest Kotlin plugin is a good start. It provides the best developer experience and ships with the latest Kotlin compiler
So we follow the familiar steps to only tweak the necessary settings of the plugin
- Define two versions of Kotlin: one for the tooling and one for our users
- Use the tooling Kotlin version of the plugin
- Use the user Kotlin version as a compilation constraint
Expanding on our version catalog from before
[versions]
jdkTarget = "8"
jvmToolchain = "21"
kotlinTarget = "1.6.0"
kotlinPlugin = "1.9.20"
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinPlugin" }
In the build file, we then configure Kotlin-specific stuff
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
plugins {
`java-library`
alias(libs.plugins.kotlin.jvm) // <1>
}
// ... jvm/jdk configuration ...
kotlin {
val kotlinTarget = libs.versions.kotlinTarget
val kotlinTargetVersion = kotlinTarget.map {
KotlinVersion.fromVersion(it.toKotlinMinor())
}
compilerOptions { // <2>
languageVersion = kotlinTargetVersion
apiVersion = kotlinTargetVersion
}
coreLibrariesVersion = kotlinTarget.get() // <3>
compilerOptions {
jvmTarget = libs.versions.jdkTarget.map { // <4>
JvmTarget.fromTarget(it.toJdkTarget())
}
freeCompilerArgs.add(libs.versions.jdkTarget.map { // <5>
"-Xjdk-release=${it.toJdkTarget()}"
})
}
}
// 8 => 1.8, 11 => 11
fun String.toJdkTarget() = if (toInt() <= 8) "1.$this" else this
// 1.7.21 => 1.7, 1.9 => 1.9
fun String.toKotlinMinor() = split(".").take(2).joinToString(".")
There is a lot going on here, so let’s break it down
- Using the Kotlin plugin version from the catalog via a type-safe accessor
This is our tooling, so it is using Kotlin 1.9.20 release - Configuring Kotlin language and stdlib API versions
These are user constraints, so we keep them at Kotlin 1.6 - Setting core libraries version to manage compile and runtime dependencies exposed in the published artifact metadata
These will become transitive dependencies for our users, so we also keep them at Kotlin 1.6. Core libraries for JVM arekotlin-stdlib
andkotlin-test
- Syncing Kotlin JVM target with Java plugin JVM target
This is required, because otherwise Kotlin plugin fallbacks to thejava.toolchain
which is the modern tooling version, and not the conservative version - Pinning JDK release
This is to make sure we can only call functions from the conservative JDK APIs in our Kotlin sources
One can tell, that bending the defaults towards the best practice is much harder than it should be. Nonetheless, we achieved our goal!
The best for both worlds
For our library, we use the most liberal and modern tooling that comes with the best performance, IDE support and, hopefully, fewer bugs
- Building with the latest Gradle 8.4
- Gradle Kotlin plugin is the latest 1.9.20
- Java and Kotlin compilation and tests run on the latest JVM 21
While our library users enjoy very conservative requirements
- JDK and JVM 8
- Kotlin 1.6
As responsible library authors, we would change the user requirements only when absolutely necessary. At the same time, we can upgrade our tooling easily and often
Conclusion
In this post, we explored a topic of modern JVM library development. More specifically, a seeming conflict between the convenience of the library developer and a potential library user
The latest plugins and toolchains, by default, introduce dependencies on the most recent language and library versions. This translates into the strong constraints for the users, which is something we want to avoid. Though, the means to make this right and follow the best practices are out there
We learned that for a Java library, it is enough to override the JDK release
that we are targeting.
With Kotlin, overriding the JDK release is slightly more involved, but still possible.
And, of course, we must not forget to configure the most conservative constraints for the Kotlin itself
Following these best practices will benefit the whole ecosystem with each new library!
Special thanks to Yahor Berdnikau for his insightful comments and review