PATTERN Cited by 2 sources
API stability annotations¶
When to use¶
You're shipping a library with a public surface that other teams (inside or outside your organisation) will compile and link against, and you need to communicate which parts of the surface are stable versus experimental versus internal — at a granularity finer than the package or repo as a whole.
This pattern fires especially hard at the OSS 1.0 line: before 1.0, breaking changes are managed by tooling and trust; after 1.0, the "don't break consumers" contract has to be machine-checkable.
The pattern¶
Annotate every element of your library's public surface (classes, methods, fields, types) with a stability tier:
| Annotation | Meaning | Breaking-change policy |
|---|---|---|
@StableApi |
Public, supported, semver-bound | No breaking changes within a major version |
@ExperimentalApi |
Public but explicitly unstable; consumers opt in eyes-wide-open | May break in any release |
@InternalApi |
Visible because the language requires it (e.g. cross-module access), but not for consumer use | May break in any release; consumers shouldn't depend |
Then enforce the contract in CI with a binary-compatibility
checker that knows about the annotations: any change to a
@StableApi-annotated element that breaks the ABI fails the
build.
Canonical wiki instance: Viaduct 1.0¶
The 2026-05-13 Viaduct 1.0 announcement codifies the pattern verbatim: "We have applied @StableApi, @ExperimentalApi, and @InternalApi annotations across all public surfaces, and we run Kotlin's binary compatibility validator in CI to catch breaking changes before they ship." (Source: sources/2026-05-13-airbnb-viaduct-1-0-and-the-future-of-airbnbs-data-mesh)
The full OSS-readiness substrate Viaduct names — the wider discipline this pattern lives inside:
@StableApi/@ExperimentalApi/@InternalApiannotations — declarative API-surface contract.- Kotlin binary compatibility validator in CI — automated enforcement.
- Maven Central publication — automated releases with reproducible build artefacts.
- Dokka- generated API documentation — the consumer-readable surface derived from the same annotations.
Why annotation-driven beats convention-driven¶
Several other conventions exist for marking API stability —
package naming (internal, impl, experimental), file
location (/internal/, /api/), Javadoc comments (@since,
@deprecated). Annotations beat these on three axes:
- Machine-checkable. A binary-compatibility validator can
read annotations and ignore non-
@StableApielements; it can't read package conventions or comments. - Per-element granularity. A single class can have
@StableApimethods +@ExperimentalApimethods. Package- level conventions can't express this. - Survives refactoring. Move a
@StableApimethod to a different package; its stability classification follows it.
Pre-1.0 vs post-1.0 disclosure¶
Verbatim from the Viaduct post: "Until now, Viaduct has evolved rapidly to meet internal needs, often with breaking changes managed through our internal monorepo tooling. Public release required a different approach."
The pattern's value-add is in the post-internal phase when:
- Consumers can't be tracked by an internal monorepo tool.
- Breaking changes can't be co-changed alongside the library.
- The library author can't email every consumer about a deprecation.
In that regime, annotations + CI enforcement become the substitute for the monorepo's "refactor everyone at once" affordance.
Adjacent patterns¶
- JDK's
@jdk.internal.api.Internalandjdk.internal.miscpackages — long-standing cousin in the JDK ecosystem. - Apache Hadoop's
@InterfaceStabilityannotations — explicit@Stable/@Evolving/@Unstableprecedent in the JVM-OSS world. - Rust's
#[unstable(feature = "...")]attributes — same pattern at the language level rather than the library level (Rust's nightly-vs-stable channel divide). - TypeScript's
@deprecated+ ESLint custom rules — the JS/TS approximation. - gRPC's
[deprecated = true]field option in.proto— the RPC-protocol cousin.
The pattern is older than Viaduct's instance, but Viaduct's
explicit naming of the @StableApi / @ExperimentalApi /
@InternalApi triad (vs the more common @Stable / @Beta
/ @Internal triad in other libraries) and its pairing with
Kotlin's
binary compatibility validator + Maven Central
+ Dokka as a coherent OSS-readiness checklist is the
load-bearing wiki contribution.
Hard problems¶
- Migration from
@ExperimentalApito@StableApiis one-way, but the reverse is hard. Once an API is@StableApi, removing the annotation is itself a breaking change to consumers who relied on the stability promise. - Annotation discipline is load-bearing. A single
@StableApion a method that should have been@InternalApiis a permanent forward-compatibility tax. There's no easy fix beyond disciplined code review at the API surface. - Cross-module visibility leaks. Kotlin / Java's
internalkeyword can't always express "visible to other modules in this library, hidden from consumers".@InternalApionpublicdeclarations is the workaround, but it requires consumers to respect the annotation (which by construction they may not). - The validator catches ABI changes, not API-semantics changes. A method whose signature is unchanged but whose semantics have shifted is invisible to a binary-compatibility validator. The pattern needs to be paired with semver discipline + change-log discipline + tests.
Second canonical wiki instance: Netflix Nebula ArchRules¶
The 2026-05-08 Netflix TechBlog post on Nebula ArchRules documents the JVM-bytecode + ArchUnit-enforcement variant of this pattern. The taxonomy is named verbatim:
"@Deprecated from the Java standard library, @Public A custom annotation to use on APIs meant to be used downstream, @Experimental A custom annotation for new APIs which may not yet be stable, All other APIs are assumed to be 'internal'." — sources/2026-05-08-netflix-scaling-archunit-with-nebula-archrules
Netflix's variant has three structural differences from the Airbnb Viaduct instance:
| Aspect | Airbnb Viaduct (Kotlin) | Netflix Nebula (JVM-broad) |
|---|---|---|
| Annotations | @StableApi / @ExperimentalApi / @InternalApi |
@Public / @Experimental / @Deprecated (JDK) / implicit-internal |
| Stability default | Annotation-required for stable | Implicit-internal default — anything not @Public or @Experimental is internal regardless of Java visibility |
| Enforcement substrate | systems/kotlin-binary-compatibility-validator (ABI signature dump in repo) | systems/archunit rules (bytecode-graph callsite detection across consumer fleet) |
| Where enforcement runs | Library's own CI | Every consumer's CI (patterns/centralized-fleet-wide-rule-catalog) |
| What enforcement tells you | This change would break ABI | This change will break specific consumers (with names) |
| Discovery direction | Pre-release | Post-deprecation, pre-removal (patterns/static-analysis-as-cross-repo-impact-discovery) |
| Auto-scope mechanism | n/a (per-library validator) | patterns/bundled-rules-auto-scoped-to-library-consumers |
The two instances are complementary, not competing:
- The Kotlin validator catches accidental ABI breaks at the library author's CI before release.
- ArchRules detects intentional API removals' downstream impact at all consumers' CI to make the deprecation→removal transition data-driven.
A library shipping both patterns has the strongest substrate: no accidental breaks (validator) + confident intentional breaks (ArchRules-discovered consumer count).
The Netflix implicit-internal default¶
The Netflix taxonomy has a property the Viaduct triad doesn't: default-internal. "All other APIs are assumed to be 'internal'." This means:
- A
public class Foonot annotated@Publicis internal, even though Java visibility says public. - The JVM's lack of an
internal-to-libraryaccess modifier is worked around by annotation-as-actual-contract, with ArchRules enforcing that "no class outside the library's package may depend on classes inside the library's package that are not annotated@Public."
This is structurally important because the JVM requires
public for cross-module access, so public can't be the
internal-vs-API signal. The annotation has to be.
The Viaduct triad uses an annotation-required-for-stable model
instead (@StableApi is the explicit positive marker; absence
means experimental/internal). Both work; the choice depends on
which case is more common in your library:
- More API surface than internal → Netflix-style annotation-required-for-public.
- More internal surface than API → Viaduct-style annotation-required-for-stable.
The ArchRules detection rule¶
The Netflix post gives the verbatim rule template for detecting deprecated-API callers from outside the library's package:
ArchRuleDefinition.priority(Priority.MEDIUM)
.noClasses().that(resideOutsideOfPackage(packageName + ".."))
.should()
.dependOnClassesThat(resideInAPackage(packageName + "..").and(are(deprecated())))
.orShould().accessTargetWhere(targetOwner(resideInAPackage(packageName + ".."))
.and(target(is(deprecated())).or(targetOwner(is(deprecated())))))
.allowEmptyShould(true)
.because("Deprecated APIs are subject to removal");
The same shape generalizes to @Public (negative — no class
outside should depend on a non-@Public class inside) and
@Experimental (warning).
Seen in¶
- sources/2026-05-13-airbnb-viaduct-1-0-and-the-future-of-airbnbs-data-mesh
— first canonical wiki naming. Viaduct's 1.0 announcement
names the
@StableApi/@ExperimentalApi/@InternalApitriad as the surface-marking discipline applied across all public surfaces, paired with Kotlin's binary compatibility validator in CI as the automated enforcement. - sources/2026-05-08-netflix-scaling-archunit-with-nebula-archrules
— second canonical wiki naming. Netflix's
@Public/@Experimental/@Deprecated/ implicit-internal taxonomy, enforced by ArchUnit rules running in every consumer's CI via Nebula ArchRules — the JVM-bytecode + fleet-wide-detection variant of the pattern.
Related¶
- systems/viaduct — the Kotlin canonical instance
- systems/kotlin-binary-compatibility-validator — the Kotlin-specific enforcement substrate
- systems/archunit — the JVM-broad enforcement substrate
- systems/nebula-archrules — the Netflix fleet-wide enforcement system
- patterns/static-analysis-as-cross-repo-impact-discovery — the pattern this enables when paired with bundled rules
- patterns/bundled-rules-auto-scoped-to-library-consumers — the substrate for shipping detection rules with the library
- concepts/breaking-change-detection-via-static-analysis — the concept this pattern is the precondition for
- companies/airbnb — first OSS publisher to codify the pattern
- companies/netflix — second OSS publisher with the fleet-detection variant