Design Walkthrough 03 — Evolving a Public API Used by Thousands of Projects
Problem statement: You own a widely-used public API surface (think Spark's
DataFrameReader, Hadoop's FileSystem, or Kafka's client API). A redesign is needed —
the current API leaks implementation details, blocks a major performance improvement, and
has accumulated 40 deprecated methods. Downstream: thousands of projects, some unmaintained,
with binaries compiled against versions up to 8 years old. Design the evolution.
Table of Contents
- Step 1: What "compatible" means, precisely
- Step 2: Inventory before design
- Step 3: The evolution strategy
- Step 4: Mechanics that make it survivable
- Step 5: The removal endgame
- Step 6: Governance
- Interviewer follow-ups
- References
Step 1: What "compatible" means, precisely
Four independent compatibility dimensions; conflating them is the #1 design-doc failure:
- Source: old code compiles against the new version. Broken by: removing members, narrowing visibility, adding abstract methods, generics changes.
- Binary: old compiled jars link at runtime. Broken by: signature changes (including return-type changes that are source-compatible!), moving methods to superclasses in some cases, interface→abstract-class. Checked mechanically — japicmp/MiMa/Revapi (you built a simplified checker in Phase 10 Lab 02).
- Behavioral: same calls, same observable semantics — defaults, ordering, error types, thread-safety. No tool checks this; only tests and discipline do. Most production breakage is behavioral.
- Wire/data: serialized forms, file formats, RPC schemas outlive code by years and need their own versioning (cf. Phase 11's format-migration lab).
The interface-vs-abstract-class question lives here: adding a method to an interface broke
all implementors pre-Java-8; default methods fix source/binary but not behavioral
compatibility (the default may be wrong for an implementor). Hadoop chose abstract class
for FileSystem for exactly this reason.
Step 2: Inventory before design
You cannot evolve what you cannot see being used:
- Annotate the whole surface first (
@Public/@LimitedPrivate/@Private×@Stable/@Evolving) — undeclared surface is implicitly public forever (Hyrum's Law). - Measure usage: corpus scans (Maven Central reverse deps, GitHub code search, internal telemetry where it exists) per deprecated method. The Phase 11 migration analyzer's urgency model — deprecated-since × usage × replacement-availability — comes from here.
- Classify the 40 deprecated methods into: trivially-replaceable (delegate exists), semantically-changed (replacement behaves differently — the dangerous bucket), and no-replacement (you must build one before removal is even discussable).
Step 3: The evolution strategy
Rejected: big-bang v2 namespace (org.foo.v2.*). Clean on paper; in practice the
ecosystem bifurcates (libraries must support both, diamond dependencies force both on one
classpath) and v1 never dies. Python 2→3 is the cautionary tale at language scale.
Rejected: break-in-major-and-apologize. With unmaintained downstream binaries, a hard break orphans them permanently and burns trust that took a decade to build.
Chosen: strangler evolution within the existing surface:
- Ship the new API alongside the old, in the same artifact, marked
@Evolvingfor ≥1 minor cycle so early adopters bear the iteration risk knowingly. - Reimplement the old API as a thin adapter over the new one — one implementation, two surfaces; behavioral compat is preserved by construction and verified by running the old API's full test suite against the adapter (the tests are the contract).
- Deprecate old methods only when their replacement is GA, with
@deprecatedJavadoc that names the replacement and the earliest removal version, plus a runtime warn-once-per-JVM log for the highest-traffic methods. - Performance improvement lands inside the new core; old-API users get most of it for free through the adapter — this is the carrot that makes migration voluntary.
Step 4: Mechanics that make it survivable
- CI compatibility gates: japicmp against the last release on every PR; the build fails on undeclared binary breakage; declared breakage requires an explicit exclusion-file entry whose PR needs PMC sign-off (process encoded in tooling).
- Behavioral pinning tests: golden tests for error messages/types, ordering, and defaults that people are known to depend on — they fail when someone "fixes" behavior.
- Migration tooling, not migration documents: an OpenRewrite/Scalafix recipe per deprecated method published with the release; a migration is real when it's executable.
@Sinceeverywhere: lets users and tools reason about availability windows.- Canary downstreams: build the top-20 dependent projects against snapshots weekly (Spark does this with key libraries); ecosystem breakage is detected pre-release, not post.
Step 5: The removal endgame
Removal is a project decision, not a code change:
- Eligibility: deprecated ≥ 2 minor releases AND measured usage below threshold AND executable migration exists.
- Dev-list
[DISCUSS]with the usage data attached; lazy consensus is not sufficient for high-traffic methods — explicit vote. - Removal lands only in a major release, with the migration recipe linked from the release notes' breaking-changes table.
- For the unmaintained-binaries long tail: consider a separately-versioned
compatjar carrying the adapters for one extra major cycle — opt-in life support that keeps the core clean while not orphaning anyone overnight.
Step 6: Governance
API evolution at this scale is mostly a people process wearing a technical costume:
- A standing API review group (3–4 maintainers) over every
@Publicsurface change — cheap to run as a PR label + required review. - Design docs (the Phase 12 capstone format) required for any new
@Publictype: problem, alternatives, compat analysis, and the explicit cost of keeping it forever — every public API is a perpetual liability accepted on purpose. - Compatibility promises published as policy (semver mapping, support windows) so downstream can plan: predictability is itself a feature, often worth more than any individual improvement.
Interviewer follow-ups
- A return type needs to change from
List<T>toStream<T>. Options? — you can't, in place (binary-incompatible). Add a new method (stream()), deprecate the old, adapter both ways; name the cost of each. - How do you deprecate a config default rather than a method? — staged: warn on implicit reliance on the old default in N, flip in N+1 with an escape-hatch config, remove the hatch in N+2. Behavioral compat needs its own ladder.
- What does Hyrum's Law change about your plan? — observable behavior is the real API; hence behavioral pinning tests and canary downstreams, and humility about "internal" changes (someone parses your exception messages).
- When is a hard break the right call anyway? — security-broken designs, correctness bugs that compat would perpetuate, or surface that demonstrably has ~zero usage. Bring data and a vote, not vibes.
References
- Hadoop Compatibility Specification
- Spark Versioning Policy and MiMa
- japicmp / Revapi
- Kafka KIP process — API change governance done in public
- Hyrum's Law
- Software Engineering at Google — ch. "Deprecation"