Skip to content

API Stability

Canonical stability annotations

The api stability rules are defined in tenant/api (including generated code), service/api and service/wiring.

A declaration may be annotated with exactly one of the following stability annotations:

  • @StableApi
  • @ExperimentalApi
  • @InternalApi
  • @VisibleForTest

Having more than one stability annotation on the same declaration is invalid and enforced by a Detekt rule.

Terminology: in this document, “declaration” includes classes, functions, properties, constructors, nested types, and type aliases.

One stability annotation per declaration

To keep semantics unambiguous, Viaduct enforces:

  • Each declaration may have at most one of @StableApi, @ExperimentalApi, @InternalApi, or @VisibleForTest.
  • Multiple stability annotations on the same declaration are invalid and are enforced by tooling.

@Deprecated and lifecycle transitions

@Deprecated is treated as a lifecycle state; when an API becomes deprecated, the previous stability annotation is removed (not combined). Represents a lifecycle state (migration signal) for an API that was previously stable and is being retired.

Project policy for public-facing APIs:

  • Deprecated APIs do not carry another stability annotation in combination.
  • When an API becomes deprecated, the previous stability annotation is removed.

Practical rule of thumb

  • If it is deprecated, annotate it with @Deprecated(...) and do not keep @StableApi / @ExperimentalApi on the same declaration.
  • If you still want to communicate “this was stable”, do that in the deprecation message and/or KDoc.

Quick decision tree

  1. Is this intended to be used by consumers (tenant services / external adopters)?
  2. Yes → @StableApi or @ExperimentalApi.
  3. No → go to #2.

  4. Is this only present to support Viaduct’s own tests/fixtures/diagnostics, but must ship in a production artifact?

  5. Yes → @VisibleForTest.
  6. No → @InternalApi.

  7. Is the API being retired?

  8. Yes → @Deprecated(...) (and remove the stability annotation).

Valid combinations (and where they apply)

Per-declaration rules

What you want Valid? How
Mark something stable Apply @StableApi on the declaration
Mark something experimental Apply @ExperimentalApi on the declaration
Mark something internal-only Apply @InternalApi on the declaration
Mark something test-only Apply @VisibleForTest on the declaration
Multiple stability annotations on one declaration Detekt failure
Deprecate a previously stable API and keep @StableApi Use only @Deprecated(...)

Enclosing scope behavior (coverage + BCV)

Enclosing stability counts as coverage for Detekt:

  • A member is considered “covered” if it has a stability annotation itself or any enclosing declaration has one.

Enclosing non-public markers affect BCV .api output:

  • A declaration appears in .api dumps only if it is public/protected and neither it nor any enclosing declaration has a nonPublicMarker (such as @InternalApi or @VisibleForTest).

Overrides and private declarations

The Detekt rule ignores:

  • private declarations,
  • overrides (stability is defined by the base declaration),
  • local declarations inside functions.

Practical examples

Example A: Stable public class with an internal helper

@StableApi
class PublicController {

    fun publicEndpoint() {} // OK: covered by @StableApi on the class

    @InternalApi
    fun internalHelper() {}  // OK: member has its own stability
}

The Detekt rule will not complain about the stable member coverage pattern shown above.

What happens:

  • PublicController and publicEndpoint() are part of the tracked public surface (BCV will dump them).
  • internalHelper() is treated as non-public for BCV (because @InternalApi is a non-public marker).

Example B: Internal class filters out all its members from .api

@InternalApi
class InternalOnlyService {
    fun methodA() {}
    fun methodB() {}
}

BCV behavior:

  • the class and its members do not appear in .api dumps because the enclosing class is a nonPublicMarker.

Example C: Experimental API (opt-in warning for consumers)

@ExperimentalApi
fun newCapability(): String = "v2"

Call-site behavior without opt-in:

  • @ExperimentalApi uses @RequiresOptIn(level = WARNING), so consumers see a compiler warning unless they opt in.

Consumer-like usage:

@OptIn(ExperimentalApi::class)
fun useIt() {
    newCapability()
}

Example D: @VisibleForTest is test-only

@VisibleForTest uses @RequiresOptIn(level = WARNING) to express “tests only”.

@VisibleForTest
fun internalTestHook() { /* ... */ }

A consumer-like module calling it without opt-in gets a compiler warning.

Example E: Deprecating a stable API

When retiring a stable API, replace its stability annotation with @Deprecated(...).

@Deprecated(
    message = "Use newFoo() instead.",
    replaceWith = ReplaceWith("newFoo()")
)
fun oldFoo() { /* ... */ }

@StableApi
fun newFoo() { /* ... */ }

Invalid examples (and why)

Invalid: Two stability annotations on one declaration

@InternalApi
@ExperimentalApi
fun dangerousExperimentalHelper() = Unit

This is disallowed. Even though Kotlin could combine the opt-ins, Viaduct enforces “pick one stability annotation per declaration.”

How opt-in is configured across Viaduct modules

Kotlin compiler -opt-in=... flags act like module-wide @OptIn.

Typical configuration:

  • Internal Viaduct modules opt in to InternalApi, ExperimentalApi, and VisibleForTest to avoid scattering @OptIn in the implementation.
  • Consumer-like code (including demo apps and testFixtures) must use @OptIn explicitly for internal/experimental usage.

What counts as the public API surface for semver?

The short answer: if the binary compatibility validation (BCV) passes, the public API surface has not changed.

BCV tracks every declaration that appears in a module's .api dump. A declaration only appears there if it is public/protected and neither it nor any enclosing declaration carries a non-public marker (@InternalApi, @VisibleForTest). That means:

  • @StableApi code — fully tracked by BCV. Renames, removals, or signature changes here are breaking and will fail BCV.
  • @ExperimentalApi code — also tracked by BCV, but consumers have opted in to potential churn. Prefer to use deprecation cycles where practical, but a change is not a semver violation in the strong sense.
  • @InternalApi code — excluded from BCV dumps. Changes are never considered breaking, regardless of package name.
  • .internal packages (e.g. runtime.internal) — package placement alone does not grant "internal" status for BCV purposes. Only the @InternalApi annotation (or an enclosing declaration that carries it) removes a declaration from the tracked surface. A rename inside a package named .internal is still safe if the class is annotated @InternalApi; without the annotation, it would still appear in the dump.
  • @VisibleForTest — should never be used in the public API surface. Intended only for internal code, e.g., code in runtime packages.

Practical guidance for new APIs

If you are introducing an API whose final shape is not yet clear, prefer @ExperimentalApi over @StableApi. This signals to consumers that breakage is possible, keeps the design flexible, and still gives the API real-world exposure. Avoid the temptation to use @InternalApi as a holding area for APIs you intend to eventually make public — only use it for code that is genuinely not intended for external consumption.

The litmus test

Is BCV green? → the public API surface is unchanged and the change is safe from a semver perspective.

If BCV fails, you have two options: revert the incompatible change, or follow the deprecation cycle (mark old API @Deprecated, introduce the replacement, and remove the old form in a future major/minor release per the project's deprecation policy).

Adding BCV to a new module

When applying id("conventions.bcv-api") to a new module, three steps are required:

  1. Add id("conventions.bcv-api") to the module's build.gradle.kts.
  2. Run ./gradlew runApiDump to generate the initial .api dump file and commit it.
  3. Create a MANDATORY_REVIEWERS file in the same directory as the .api file, listing the API owners. This file gates PR approval on those owners whenever the .api dump changes — without it, breaking API changes can be merged without review from the people responsible for the public surface.

Example MANDATORY_REVIEWERS (placed next to the .api file):

@raymie-stata @aileen-chen @airbnb/viaduct

Note: MANDATORY_REVIEWERS files are Treehouse-internal and are excluded from OSS sync via .copybara/copy.bara.sky. They do not need to be ported to the public repository.