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

User constraints vs developer tooling

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

build.gradle.kts
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 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

  1. Define two versions of java: one for the tooling and one for our users
  2. Use the tooling java version for the toolchain
  3. Use the user java version JDK release for the compilation

We can start with directly using the different versions in our build script

build.gradle.kts
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

gradle/libs.versions.toml
[versions]
jdkTarget = "8"
jvmToolchain = "21"

In the build file, we get type-safe accessors to the values of the versions

build.gradle.kts
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

  1. Define two versions of Kotlin: one for the tooling and one for our users
  2. Use the tooling Kotlin version of the plugin
  3. Use the user Kotlin version as a compilation constraint

Expanding on our version catalog from before

gradle/libs.versions.toml
[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

build.gradle.kts
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

  1. 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
  2. Configuring Kotlin language and stdlib API versions
    These are user constraints, so we keep them at Kotlin 1.6
  3. 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 are kotlin-stdlib and kotlin-test
  4. Syncing Kotlin JVM target with Java plugin JVM target
    This is required, because otherwise Kotlin plugin fallbacks to the java.toolchain which is the modern tooling version, and not the conservative version
  5. 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