Adding a parameter with a default broke the ABI
Source compatibility and binary compatibility are different guarantees, and a default argument preserves only the first.
TL;DR
Adding a parameter with a default value is source-compatible: every existing call site still compiles. It is not binary-compatible, because the compiled method signature changes — and anything already compiled against the old signature fails to link.
We shipped it as a patch release. Consumers who recompiled were fine. Consumers who had our library
as a transitive dependency and did not recompile got NoSuchMethodError at runtime.
Source compatibility is about recompiling. Binary compatibility is about not recompiling. If you publish a library, you owe the second one, and a tool can check it for you in CI.
The case this post is about
A published multiplatform library, adding a timeout option:
// 2.3.0
class SyncClient(private val api: Api) {
suspend fun sync(userId: String): SyncResult { … }
}
// 2.3.1 — "a patch: purely additive, every call site still compiles"
class SyncClient(private val api: Api) {
suspend fun sync(userId: String, timeout: Duration = 30.seconds): SyncResult { … }
}
Every existing sync(userId) call compiles against 2.3.1. We verified that, shipped it as a patch,
and the release notes said "additive".
The symptom
Issues from two consumers within a day:
java.lang.NoSuchMethodError: 'java.lang.Object com.example.SyncClient.sync(
java.lang.String, kotlin.coroutines.Continuation)'
at com.consumer.SyncWorker.doWork(SyncWorker.kt:34)
The method they were calling no longer existed in the compiled artifact.
Why it reached them without a recompile:
consumer-app
└── third-party-sdk:1.4.0 (compiled against sync-client:2.3.0)
└── sync-client:2.3.0 → resolved to 2.3.1 by another dependency's constraint
The SDK's compiled bytecode referenced sync(String, Continuation). Gradle resolved
sync-client to 2.3.1 — newest-wins — and 2.3.1 does not have that method. The SDK vendor would
have had to recompile, which is not something the consumer can do.
And on iOS it failed differently:
Undefined symbols for architecture arm64:
"_kfun:com.example.SyncClient#sync(kotlin.String;kotlin.coroutines.Continuation){}"
A link-time failure rather than a runtime one — better, and still a broken build for anyone consuming our framework as a binary.
The blast radius:
consumers affected (from issue reports + download stats)
recompiled against 2.3.1: unaffected
consuming us transitively: 2 SDKs, ~40 downstream apps
2.3.1 yanked after: 19 hours
Why the obvious fix failed
"It compiles, so it is compatible." The entire mistake, in one sentence. Our CI built every consumer sample from source, so every check passed. Source compatibility was verified; binary compatibility was never tested because nothing in the pipeline linked old bytecode against the new artifact.
"Bump the minor version instead." We did, re-releasing as 2.4.0, and semver's minor means "backwards-compatible additions". A binary-incompatible change in a minor release still breaks a transitive consumer who cannot recompile — the version number communicates intent and does not prevent the link error.
"Add @JvmOverloads." This generates the overloads on the JVM only, so Android and desktop
consumers are fine and the Kotlin/Native symbol is still gone. On a multiplatform library it fixes
one target of four.
"Tell consumers to recompile." A consumer cannot recompile a third-party SDK. The person who needs to act is not the person experiencing the failure, which is what makes this class so expensive.
The actual mechanism
Two different compatibility guarantees, about two different operations:
Source compatibility: existing source code still compiles against the new version. Adding a default parameter preserves this, because the compiler fills in the default at the new call site.
Binary compatibility: existing compiled artifacts still link against the new version. Adding a parameter changes the method's descriptor, so the old symbol disappears.
2.3.0 compiles to: sync(String, Continuation)
2.3.1 compiles to: sync(String, Duration, Continuation)
+ sync$default(String, Duration, int, Continuation) (the defaults shim)
old bytecode calls: sync(String, Continuation) → not present in 2.3.1
The default value is applied at the call site at compile time, not inside the callee. That is the whole mechanism: a default argument is a compiler convenience, so it only helps code that is being compiled.
Across targets it surfaces differently, which is worth knowing:
JVM/Android NoSuchMethodError at runtime, on first call ← worst: late and partial
Kotlin/Native undefined symbol at link time ← better: build fails
JS/wasm depends on the module system; often a runtime error
What a check for this actually looks like, because "run the validator" hides the one idea worth carrying: the comparison is over symbols, and a symbol that disappeared is breaking no matter how source-compatible the change reads.
/** One declaration line from a `.api` dump — the descriptor, not the Kotlin signature. */
data class Symbol(val owner: String, val descriptor: String)
enum class Verdict { SAFE, BREAKING }
fun classify(old: Set<Symbol>, new: Set<Symbol>): Map<Symbol, Verdict> {
val verdicts = mutableMapOf<Symbol, Verdict>()
for (symbol in old) {
if (symbol in new) continue // unchanged — nothing to say
// A `$default` shim with the same owner is the tell that a parameter was ADDED:
// the source still compiles, and this exact descriptor is gone for anyone who
// compiled against it. That is the whole defect, and it is why "we only added a
// default argument" is not a defence.
verdicts[symbol] = Verdict.BREAKING
}
// Added symbols are safe on their own. They are listed, never judged — a new overload
// breaks nobody, and treating additions as findings is how a gate gets switched off.
for (symbol in new - old) verdicts[symbol] = Verdict.SAFE
return verdicts
}
fun gate(old: Set<Symbol>, new: Set<Symbol>, isPatchRelease: Boolean) {
val broken = classify(old, new).filterValues { it == Verdict.BREAKING }.keys
when {
broken.isEmpty() -> println("abi: ${new.size} symbols, no removals ✓")
isPatchRelease -> error(
"abi: ${broken.size} symbol(s) removed in a PATCH release:\n" +
broken.joinToString("\n") { " ${it.owner}.${it.descriptor}" },
)
else -> println("abi: ${broken.size} removal(s) — permitted, bump the MAJOR version")
}
}
On the named case — the 2.3.0 → 2.3.1 dumps that shipped:
$ ./gradlew apiCheck
abi: 1 symbol(s) removed in a PATCH release:
com/acme/sync/Syncer.sync (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
The branch is the part that matters. The same removal is a release-blocking defect at 2.3.1 and an expected cost at 3.0.0, so the check reads the intended version rather than treating every removal as an error — a gate that fires on legitimate major releases is one somebody disables before the patch release where it would have helped.

A default argument is resolved at the call site, so it preserves source compatibility and nothing else. If someone can consume you without recompiling — and with a transitive dependency they always can — you owe them binary compatibility.
The fix
Keep the old signature, add an overload, and make the ABI machine-checked.
// 2.4.0 — binary compatible
class SyncClient(private val api: Api) {
// the ORIGINAL signature, preserved. Old bytecode links against this.
suspend fun sync(userId: String): SyncResult = sync(userId, 30.seconds)
// the new capability as a separate overload
suspend fun sync(userId: String, timeout: Duration): SyncResult { … }
}
Two methods instead of one with a default. Slightly more verbose, and both symbols exist.
Then the tool that makes this checkable — binary-compatibility-validator:
// build.gradle.kts
plugins { alias(libs.plugins.binaryCompatibilityValidator) }
apiValidation {
// the KLib ABI dump covers the non-JVM targets, which @JvmOverloads does not
klib { enabled = true }
}
$ ./gradlew apiDump # once, to record the current ABI
# commits api/sync-client.api and api/sync-client.klib.api
$ ./gradlew apiCheck # in CI, on every PR
And with the original 2.3.1 change applied, that check fails before merge:
$ ./gradlew apiCheck
API check failed for project sync-client.
--- api/sync-client.api
+++ build/api/sync-client.api
- public final suspend fun sync (Ljava/lang/String;Lkotlin/coroutines/Continuation;)…
+ public final suspend fun sync (Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/coroutines/…
A removed method shown as a diff, in review. That is the whole control.
Four decisions worth naming:
The ABI dump is committed. It is a reviewable artifact: a pull request that changes the public ABI shows exactly which symbols changed, and a reviewer can see "removed" versus "added".
klib { enabled = true } so Kotlin/Native and wasm ABIs are covered. Without it the validator
checks the JVM only, which is the subset @JvmOverloads already handles.
internal and explicit API mode keep the surface small:
kotlin { explicitApi() } // every public declaration must be explicitly public
Which forces a decision per declaration, rather than public by default — the same lesson as the
exported-iOS-framework surface.
And a documented policy, because the tool reports changes and does not decide them:
## Compatibility policy
- PATCH: no public ABI change. apiCheck must pass with no diff.
- MINOR: additive ABI only (new declarations). Removals and signature changes are forbidden.
- MAJOR: ABI breaks allowed, documented in the migration guide.
Adding a parameter to an existing function is a MAJOR change, or an overload in a MINOR.
Defaults do NOT make it compatible.
How we proved it
That the validator catches the original change:
re-apply the 2.3.1 default-parameter change on a branch
$ ./gradlew apiCheck
FAILED — 1 removed, 2 added (sync-client.api)
FAILED — 1 removed, 2 added (sync-client.klib.api)
exit 1
Both dumps, both targets, before merge. Nineteen hours of a broken release for one CI task.
That the overload version is genuinely binary compatible, verified by linking rather than by compiling — which is the check that was missing:
# compile a consumer against 2.3.0, then run it against the new artifact WITHOUT recompiling
$ javac -cp sync-client-2.3.0.jar Consumer.java
$ java -cp sync-client-2.4.0.jar:. Consumer
sync completed ← links and runs
# the same, against the yanked 2.3.1
$ java -cp sync-client-2.3.1.jar:. Consumer
Exception: NoSuchMethodError: SyncClient.sync(String, Continuation)
That pair of commands is the test. We added it to CI as a real job, because the validator checks the declared ABI and this checks the actual linkage:
- name: Link old consumer bytecode against the new artifact
run: ./scripts/abi-link-check.sh --old 2.3.0 --new ${{ github.ref_name }}
And the Native half, which fails differently:
$ ./gradlew :consumer:linkDebugExecutableMacosArm64 # consumer klib built against 2.3.0
2.3.1: Undefined symbols … kfun:SyncClient#sync(kotlin.String;…)
2.4.0: BUILD SUCCESSFUL
The ABI surface, after enabling explicit API mode:
public declarations in the dump
before: 412
after: 118 (294 were public by default and should not have been)
Two hundred and ninety-four accidentally-public declarations, which is the same public-by-omission problem as the iOS framework export — and every one of them was an ABI promise we did not know we had made.
One limit: binary-compatibility-validator's KLib support is newer than its JVM support, and the
dump format has changed across versions — we had to regenerate the klib.api file on a Kotlin
upgrade, which looked like an ABI break and was a format change. Reading the diff carefully, rather
than trusting the exit code, is still required.
What this generalizes to
"It compiles" and "it links" are different claims. Source compatibility is about recompiling; binary compatibility is about not recompiling. Every published library owes the second, and testing only the first is the default because building from source is what CI naturally does.
A convenience resolved at the call site helps only code being compiled. Default arguments, inline functions, macros, constant folding. Anything the compiler expands at the usage site cannot retroactively help an artifact already built.
Commit the derived surface so changes appear in review. The ABI dump, like the exported header or the resolved dependency list, turns an invisible change into a diff a reviewer can object to.
The person who must act is often not the person who breaks. A transitive consumer cannot recompile the SDK between them and you. That asymmetry is why a library's compatibility guarantees have to be mechanical rather than advisory.
Related
- Publishing a KMP library commits you to every target you listed — the other half of a publication contract
- The iOS framework you export is an API, and nobody reviews it — public-by-omission, again
- A version catalog is a contract, and a transitive bump breaks it quietly — how 2.3.1 reached them
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".