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/@ExperimentalApion the same declaration. - If you still want to communicate “this was stable”, do that in the deprecation
messageand/or KDoc.
Quick decision tree¶
- Is this intended to be used by consumers (tenant services / external adopters)?
- Yes →
@StableApior@ExperimentalApi. -
No → go to #2.
-
Is this only present to support Viaduct’s own tests/fixtures/diagnostics, but must ship in a production artifact?
- Yes →
@VisibleForTest. -
No →
@InternalApi. -
Is the API being retired?
- 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
.apidumps only if it is public/protected and neither it nor any enclosing declaration has anonPublicMarker(such as@InternalApior@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:
PublicControllerandpublicEndpoint()are part of the tracked public surface (BCV will dump them).internalHelper()is treated as non-public for BCV (because@InternalApiis a non-public marker).
Example B: Internal class filters out all its members from .api¶
BCV behavior:
- the class and its members do not appear in
.apidumps because the enclosing class is anonPublicMarker.
Example C: Experimental API (opt-in warning for consumers)¶
Call-site behavior without opt-in:
@ExperimentalApiuses@RequiresOptIn(level = WARNING), so consumers see a compiler warning unless they opt in.
Consumer-like usage:
Example D: @VisibleForTest is test-only¶
@VisibleForTest uses @RequiresOptIn(level = WARNING) to express “tests only”.
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¶
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, andVisibleForTestto avoid scattering@OptInin the implementation. - Consumer-like code (including demo apps and
testFixtures) must use@OptInexplicitly 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:
@StableApicode — fully tracked by BCV. Renames, removals, or signature changes here are breaking and will fail BCV.@ExperimentalApicode — 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.@InternalApicode — excluded from BCV dumps. Changes are never considered breaking, regardless of package name..internalpackages (e.g.runtime.internal) — package placement alone does not grant "internal" status for BCV purposes. Only the@InternalApiannotation (or an enclosing declaration that carries it) removes a declaration from the tracked surface. A rename inside a package named.internalis 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 inruntimepackages.
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:
- Add
id("conventions.bcv-api")to the module'sbuild.gradle.kts. - Run
./gradlew runApiDumpto generate the initial.apidump file and commit it. - Create a
MANDATORY_REVIEWERSfile in the same directory as the.apifile, listing the API owners. This file gates PR approval on those owners whenever the.apidump 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):
Note:
MANDATORY_REVIEWERSfiles 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.