Skip to content

Adopt binary-compatibility-validator to guard the public / Java-interop API surface #76

Description

@endrju19

Summary

Adopt the Kotlin binary-compatibility-validator (BCV) as an automated gate over okapi's public/Java-facing API surface. We currently have no guard against a silently-dropped @Jvm* annotation (or any other public-API drift), which is exactly how #74 slipped in.

Motivation

In #74 the @JvmOverloads on OutboxProcessor's constructor was dropped, silently breaking new OutboxProcessor(store, entryProcessor) for Java callers. Nothing caught it — okapi has no Java-interop tests and no ABI validation; the only gates are ktlint + Kotest. #75 fixes that one class with a hand-written Java compile-test, but that guards a single type. We want a net over the whole @Jvm* surface (@JvmOverloads, @JvmName un-mangling value-class methods, @JvmStatic, @JvmInline value class OutboxId) across all published modules.

Research

We surveyed how mature Kotlin JVM libraries actually guard interop, cross-checked the tooling against primary docs, and verified the key claim on okapi's own code.

BCV is the de-facto ecosystem norm — 10/10 libraries surveyed use it (committed .api dumps + apiCheck in CI): okio, okhttp, kotlinx.coroutines, kotlinx.serialization, kotlinx-datetime, arrow, ktor, mockk, Exposed, koin. It catches a dropped @JvmName/@JvmStatic/@JvmOverloads because those change the recorded JVM signatures (method name, static-vs-instance, the set of overloads).

Hand-written Java interop tests are a minority, supplementary pattern (3/10: coroutines, okhttp, arrow) — sparse and targeted at interop-fragile spots (checked exceptions via @Throws, companion @JvmStatic, generics), not whole-surface mirroring. Not an anti-pattern, but not the primary net; several respected libraries (serialization, ktor, mockk, Exposed, koin) ship none and rely on BCV alone.

@IntroducedAt is the wrong tool here — it preserves binary compatibility for Kotlin callers when adding optional parameters, and is still experimental. okapi's concern is Java-callability, which is what @JvmOverloads is for. Keep @JvmOverloads.

Empirical proof on okapi's own code

Wired standalone BCV into a throwaway branch and ran apiDumpOutboxProcessor dumps three public constructors (the @JvmOverloads-generated ones):

public fun <init> (OutboxStore, OutboxEntryProcessor)V
public fun <init> (OutboxStore, OutboxEntryProcessor, OutboxProcessorListener)V
public fun <init> (OutboxStore, OutboxEntryProcessor, OutboxProcessorListener, Clock)V

Removing @JvmOverloads and running apiCheck fails the build with exactly the regression from #74:

API check failed for project okapi-core.
-	public fun <init> (OutboxStore, OutboxEntryProcessor)V
-	public fun <init> (OutboxStore, OutboxEntryProcessor, OutboxProcessorListener)V
 	public fun <init> (OutboxStore, OutboxEntryProcessor, OutboxProcessorListener, Clock)V

Setup was ~one-time: apply once at the root, apiDump (~12s), commit one .api per published module. apiCheck auto-wires into check, so existing CI (./gradlew check) gets it for free.

Proposal

  • Primary: adopt the standalone org.jetbrains.kotlinx.binary-compatibility-validator plugin (the same one every surveyed library uses), applied at the root, with apiValidation { ignoredProjects.addAll(listOf("okapi-integration-tests", "okapi-benchmarks", "okapi-bom")) }. Commit the .api dumps; intentional API changes then show up as a reviewable .api diff.
  • Not the built-in KGP abiValidation yet — it's still experimental (Kotlin 2.2.0+, requires @OptIn, DSL may change). Revisit once it stabilizes.
  • Keep the Java compile-test from fix: add @JvmOverloads to OutboxProcessor constructor #75 as belt-and-suspenders (same pattern as coroutines/okhttp/arrow) for the interop-fragile spots and as executable Java-usage documentation.

Honest limitations

BCV catches changes to the recorded API; it will bake a forgotten @JvmName on a brand-new method into the golden dump as "correct" (it only trips on later drift). The Java test has the symmetric gap (only covers what you write a call for). Neither is complete alone; together they cover the demonstrated failure mode well. Main ongoing cost: regenerating and committing the .api dump on every intentional public-API change — friction that doubles as a review signal for a library that promises interop stability.

Sources

Related: #74, #75.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions