2021 is getting closer and it’s time to draft your new year’s resolution 🎆. How about becoming a “better API citizen”?
In my free time, I maintain and contribute to several open-source Kotlin libraries and projects on Github.
With great tools comes great responsibility. And as a library maintainer, I have the duty of protecting the library public API. This means making sure that the library evolves sustainably, without pushing breaking changes to our clients. This also means proactively discover breaking changes introduced by external Pull Requests.
In this blog-post, I will walk you through several techniques that will help you to become a better API citizen. Please note that this blogpost will mostly focus on the perspective of a Kotlin developer. However, those suggestions are valid for all the APIs consumed by languages on the JVM.
@Deprecated
When evolving your library, you will necessarily end up in a situation where you want to remove an API. Perhaps you exposed something that was not supposed to. Or you have a broken method and an alternative implementation that you prefer your users to use.
In Kotlin you can use the @Deprecated
annotation to mark APIs that should not be used anymore:
@Deprecated( message = "This method is local-specific, replace with lowercase() that is locale-agnostic", replaceWith = ReplaceWith("lowercase()"), level = DeprecationLevel.ERROR ) fun toLowerCase(with: String)
Kotlin allows you to specify also:
- A message to clarify why you’re deprecating an API.
- An alternative API to use with
ReplaceWith
. IDEs can consume this to offer quick-fixes to your users while typing. - The deprecation level to define if usages of this API should raise a Warning or an Error. You can also use
DeprecationLevel.HIDDEN
to mark the API as hidden. This will make them inaccessible from the code while still keeping the binary compatibility.
As a rule of thumb, consider having a deprecation cycle for your most used APIs. This means communicating upfront when you plan to remove an API (e.g. either in the next major release or in a fixed amount of time). Don’t forget to mention in your release notes if you’re deprecating or are planning to remove some APIs.
@OptIn
As you’re removing old API, you might want to add new ones. If you have some experience with library development, you know that a poorly-designed API feels like a cage. It’s expensive to maintain and hard to evolve.
That’s why Kotlin offers you the possibility to specify Experimental APIs with the @OptIn
and @RequiresOptIn
annotations.
To do so, you first define a custom annotation that you can use to mark the experimental APIs:
@RequiresOptIn(message = "This API is experimental") annotation class MyExperimentalApi
You can then use your annotation to specify the surface of your API that is experimental:
@MyExperimentalApi fun getAnswer() = 42
Once a user tries to use this API, they will get notified with a warning and will have two alternatives.
They can annotate their usages with the same @MyExperimentalApi
annotation. This will cause their usage to propagate the experimental API surface. Callers of the new method will also get warned of the experimental API usages:
@MyExperimentalApi fun main() { println(getAnswer()) }
Or they can annotate their usages with the @OptIn
annotation. This will not propagate the experimental API surface and will work as an acknowledgment of the user on the API contract:
@OptIn(MyExperimentalApi::class) fun main() { println(getAnswer()) }
Using @OptIn
allows you to be intentional when evolving your API. Your users will have to commit to your experimental API so they should expect breakages on those specific parts of the API.
Explicit API Mode
With Kotlin 1.4, library and SDK developers have another tool that can simplify their lives as maintainers: Explicit API Mode, a compiler mode that enforces the API of your Kotlin code to be “explicit”.
You can enable Explicit API Mode using the -Xexplicit-api={strict|warning}
compiler flag or by adding this snippet in your build.gradle
:
kotlin { explicitApi() // OR explicitApiWarning() }
One check performed with Explicit API Mode is the explicit visibility. The compiler will warn you for all the classes/methods/interfaces that don’t have specified visibility. This is useful as the default visibility for Kotlin is public
(rather than Java’s package-private default visibility). The result is Kotlin libraries accidentally leaking implementation classes that should not be exposed.
Explicit API Mode will also force you to use explicit types. This helps to prevent breaking changes caused by inferred return types. For instance in the function from the previous example:
fun getAnswer() = 42.0
the return type is not specified. The exposed API will have Int
as the return type, given that the type is inferred from the literal 42
. If you accidentally change the literal to 42.0
, you will change the return type from Int
to Double
resulting in a breaking change pushed to your clients.
With Explicit API Mode enabled, the compiler forces you to adapt the code to:
public fun getAnswer() : Int = 42
Semantic Versioning
How often do you bump your major version?
Do you feel that your releases are never perfect and you find it hard to release a new major version of your library? Have you ever felt a connection between human emotions and version bumps?
If this is the case, you should consider switching to Semantic Versioning. If you haven’t done it yet, I invite you to take a look at the full specification on https://semver.org/ (you can find also a nice Backus–Naur-form grammar for it).
In short words, Semantic Versioning relies on having a version number that follows the MAJOR.MINOR.PATCH
schema. You’re supposed to increment:
MAJOR
version when you make incompatible API changes,MINOR
version when you add functionality in a backwards-compatible manner, andPATCH
version when you make backwards-compatible bug fixes.
Following this versioning schema allows you to have a clear contract with your users. They will know what to expect from your library just by looking at the version number.
Automatic API verification
Using @Deprecated
, @OptIn
, Explicit API mode, and Semantic versioning would be great steps towards becoming a better API citizen. Yet, we’re humans and we do mistakes, a lot of them.
I invite you to take a look at the Evolving Java-based APIs Part 1 and Part 2 from the Eclipse documentation to get an overview of all the possible scenarios that lead to breaking or non-breaking API changes.
If you feel overwhelmed by it, you’re not alone.
Luckily, there are tools to warn you if you’re accidentally introducing a breaking change in your API.
I prefer to use those two open-source alternatives:
binary-compatibility-validator
, a Gradle plugin to compute a file representation of your public Kotlin/Java API.japicmp
, a Java tool to compute the API diff between two JARs.
binary-compatibility-validator
Kotlin/binary-compatibility-validator is a Gradle plugin that helps you manage your public API.
The setup is straightforward as all the other Gradle plugins:
buildscript { dependencies { classpath("org.jetbrains.kotlinx:binary-compatibility-validator:0.2.4") } } apply plugin: "binary-compatibility-validator"
Once applied, it offers two Gradle tasks that you can run on your project:
apiDump
to compute a dump of the public API and save it in a.api
file.apiCheck
to compute again the dump of the public API and compare it with the one already on disk.
Once you setup this tool, you should run apiDump
once for your projects and commit the .api
files to your repository.
During your normal workflow, you can run the apiCheck
task to verify if your code change is introducing a change in the public API. If so, that task will fail. You are then supposed to rerun apiDump
and add the modified .api
file to your repository. This makes sure all the code changes that are impacting the public API are tracked with a related change to the .api
file.
Moreover, having a .api
file is a convenient way to have an always up-to-date snapshot of your public API.
Beware that the apiCheck
task will warn you for every change to your public API, both the breaking and the non-breaking ones. To have more fine-grained control over the type of changes you introduce, you can use japicmp
.
japicmp
japicmp is a Java tool to compute the diff between the public API of two JARs.
To use it, you can download a fat JAR with all the dependencies from Maven Central and run it from the command line. You need to pass both the old and the new JAR that you’re interested in comparing.
$ java -jar japicmp-jar-with-dependencies.jar \ --old library-1.0.0.jar \ --new library-1.1.0.jar
Japicmp supports several flags that allow you to fail the execution if there is either a binary or a source incompatible change:
$ java -jar japicmp-jar-with-dependencies.jar -h SYNOPSIS java -jar japicmp.jar [-a <accessModifier>] [(-b | --only-incompatible)] [(-e <excludes> | --exclude <excludes>)] [--exclude-exclusively] [(-h | --help)] [--html-file <pathToHtmlOutputFile>] [--html-stylesheet <pathToHtmlStylesheet>] [(-i <includes> | --include <includes>)] [--ignore-missing-classes] [--ignore-missing-classes-by-regex <ignoreMissingClassesByRegEx>...] [--include-exclusively] [--include-synthetic] [(-m | --only-modified)] [(-n <pathToNewVersionJar> | --new <pathToNewVersionJar>)] [--new-classpath <newClassPath>] [--no-annotations] [(-o <pathToOldVersionJar> | --old <pathToOldVersionJar>)] [--old-classpath <oldClassPath>] [--report-only-filename] [(-s | --semantic-versioning)] [(-x <pathToXml> | --xml-file <pathToXml>)] [--error-on-binary-incompatibility] [--error-on-source-incompatibility] [--error-on-modifications] [--no-error-on-exclusion-incompatibility] [--error-on-semantic-incompatibility] [--ignore-missing-old-version] [--ignore-missing-new-version]
You can also use the --semantic-versioning
flag to let the tool compute your correct Semantic Versioning bump.
By default, japicmp will print out the API diff to the console. If you’re looking into a more convenient way to navigate your API diff, you can use the --html-file
flag to get an HTML report that would look like this one:
If you’re building your project with either Maven or Gradle, you can use the respective Maven plugin or Gradle plugin.
Conclusion
Evolving Java/Kotlin APIs is hard.
Using automated tools such as binary-compatibility-validator
or japicmp
can help you catch unintended breaking changes before you release them to your users.
Ultimately, consider using @Deprecated
, @OptIn
, Explicit API mode, and Semantic Versioning to make your API more user-friendly and become a better API citizen.
Author: Nicola Corti
Kotlin GDE – Android Infra @ Spotify
—
Nicola Corti is a Google Developer Expert for Kotlin. He has been working with the language since before version 1.0 and he is the maintainer of several open-source libraries and tools.
He’s currently working as Android Infrastructure Engineer at Spotify in Stockholm, Sweden.