Two ways to break a caller
As in Java, a change can break callers in two ways:
- Source compatibility: does the caller's code still compile against the new version?
- Binary compatibility: does code that was compiled against the old version still run with the new jar?
The second one matters because applications rarely recompile the other libraries they depend on. Kotlin adds its own traps on top of the Java ones, because several of its features are compiled into code that lives in the caller, not in your library.
In each example, version 1.0 is the release that callers use and 1.1 is the change. The error messages are the real ones.
1. A new parameter with a default value
// 1.0
fun greet(name: String): String
// 1.1: existing calls still compile, so it looks safe
fun greet(name: String, punctuation: String = "!"): String
Recompiled callers: compile. greet("Ana") picks up the default.
Old binaries: fail. Kotlin compiles the function to a single JVM method with two parameters, and the one-parameter method that callers were compiled against no longer exists:
java.lang.NoSuchMethodError: 'java.lang.String lib.LibKt.greet(java.lang.String)'
Instead: keep the old function as an overload that calls the new one, or add @JvmOverloads, which generates the shorter overloads for you. With @JvmOverloads on the 1.1 function, the old binary printed Hello, Ana!.
2. A renamed parameter
// 1.0
fun connect(timeoutMs: Long)
// 1.1: a clearer name
fun connect(timeoutMillis: Long)
Recompiled callers: every call that uses the named argument stops compiling:
error: no parameter with name 'timeoutMs' found.
Old binaries: run. Parameter names are not part of the JVM signature.
In Java, renaming a parameter is invisible to callers. In Kotlin, parameter names are public API, and named arguments are common, so treat a rename like any other signature change.
Instead: keep the name. If it really has to change, add the new function, deprecate the old one and remove it in the next major version.
3. A top-level function moved to another file
// 1.0, in Strings.kt
fun slug(s: String): String
// 1.1, the same function moved to Text.kt
Recompiled callers: compile. The package and the name are unchanged.
Old binaries: fail. A top-level function lives in a class named after its file, here StringsKt, and that class is gone:
java.lang.NoClassDefFoundError: lib/StringsKt
Renaming the file has the same effect. It is the kind of change that shows up in a refactoring commit, not in a review of the API.
Instead: name the class explicitly with @file:JvmName("StringsKt") at the top of the new file. With it, the old binary ran normally. Better still, give every file with public top-level functions a @file:JvmName from the first release.
4. A new enum entry or sealed subclass
// 1.0
enum class Level { LOW, HIGH }
// 1.1
enum class Level { LOW, HIGH, CRITICAL }
Recompiled callers: every when expression that lists all the entries without an else stops compiling:
error: 'when' expression must be exhaustive. Add the 'CRITICAL' branch or an 'else' branch.
Old binaries: run until the new value reaches one of those when expressions, and then throw:
kotlin.NoWhenBranchMatchedException
A new subclass of a sealed class or interface behaves the same way: we added an object Pending to a sealed Result and got the same compile error and the same exception.
Instead: decide up front whether a set of values is closed. If you expect to add cases, say so in the documentation and use a type that callers cannot match exhaustively, such as a regular interface. If the set is closed, adding a case is a major change.
5. A new property in a data class
// 1.0
data class User(val name: String)
// 1.1, with a default so existing code compiles
data class User(val name: String, val email: String = "")
Recompiled callers: compile. User("Ana") picks up the default.
Old binaries: fail. The constructor changed, and so did the generated copy function:
java.lang.NoSuchMethodError: 'void lib.User.<init>(java.lang.String)'
You can add the old constructor back as a secondary constructor, and the error moves to the next line:
java.lang.NoSuchMethodError: 'lib.User lib.User.copy(java.lang.String)'
There is no way to keep the old copy: Kotlin generates it from the primary constructor, and you cannot declare your own.
Instead: don't expose data classes in a library's public API if they might grow. Kotlin's own backward compatibility guidelines recommend the same. Use a regular class with explicit equals, hashCode and toString.
6. A return type that becomes nullable
// 1.0
fun findName(id: Int): String
// 1.1: "not found" is now possible
fun findName(id: Int): String?
Recompiled callers: fail wherever they use the result directly:
error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
Old binaries: run, because String and String? are the same type on the JVM. The caller trusts the old promise, and the null travels until something uses it:
java.lang.NullPointerException: Cannot invoke "String.length()" because "n" is null
That exception can appear far from the call that returned null, which makes it hard to trace back to your release.
Instead: add a new function, such as findNameOrNull, and keep the old one non-null. The opposite change, from String? to String, does not break code that calls the function, only classes that override it.
7. A changed inline function
// 1.0
inline fun retries(block: () -> Unit) { repeat(3) { block() } }
// 1.1
inline fun retries(block: () -> Unit) { repeat(5) { block() } }
Nothing fails. The body of an inline function is copied into the caller when the caller is compiled. A client compiled against 1.0 and run with the 1.1 jar still retried 3 times. It is the Kotlin version of a changed Java constant.
Bug fixes in an inline function don't reach code that was compiled before them, and two parts of one application can run two versions of the same function.
Instead: keep public inline functions small and stable, and move the logic into a regular function that the inline one calls. That function can be marked @PublishedApi internal if it should not be part of the API, but its signature then has to stay compatible, because inlined code calls it.
The seven at a glance
| Change in a minor release | Recompiled callers | Old binaries |
|---|---|---|
| New parameter with a default | Compile | NoSuchMethodError |
| Renamed parameter | Named calls fail | Run |
| Top-level function moved to another file | Compile | NoClassDefFoundError |
| New enum entry or sealed subclass | Exhaustive when fails | NoWhenBranchMatchedException |
| New property in a data class | Mostly compile | NoSuchMethodError |
| Return type becomes nullable | Fail | NullPointerException later |
| Changed inline function | Get the new code | Keep the old code |
None of them fails the library's own build. The library's tests are compiled with the new code, so they always match it.
How to catch them
- binary-compatibility-validator: JetBrains' Gradle plugin. It writes the public binary API to a file that you commit, and fails the build when the API changes without the file being updated. It looks at the binary API, so source-only changes such as a renamed parameter are outside what it checks.
- Kotlin's backward compatibility guidelines: the official list of what to avoid, worth reading before a first release.
- Bumpwise: our IntelliJ IDEA plugin. It compares the code in the editor with the last published release of your library and highlights breaking changes while you type, before the change is committed, and calculates the next version.
Disclosure: Gilware makes Bumpwise. Kotlin support is part of Bumpwise Team, the paid edition, which has a 30-day free trial.
How we checked
For each change we compiled the 1.0 library, compiled a small client against it, compiled the 1.1 library, then ran the old client with the new jar and recompiled the client against 1.1. Kotlin 2.2.20, OpenJDK 25.0.5. The error messages above are copied from those runs. The @JvmOverloads, @file:JvmName and secondary constructor results were checked the same way.
Found a mistake? Write to hello@gilware.com.