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

Four independent compatibility dimensions; conflating them is the #1 design-doc failure:

  1. Source: old code compiles against the new version. Broken by: removing members, narrowing visibility, adding abstract methods, generics changes.
  2. 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).
  3. 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.
  4. 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:

  1. Ship the new API alongside the old, in the same artifact, marked @Evolving for ≥1 minor cycle so early adopters bear the iteration risk knowingly.
  2. 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).
  3. Deprecate old methods only when their replacement is GA, with @deprecated Javadoc that names the replacement and the earliest removal version, plus a runtime warn-once-per-JVM log for the highest-traffic methods.
  4. 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.
  • @Since everywhere: 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:

  1. Eligibility: deprecated ≥ 2 minor releases AND measured usage below threshold AND executable migration exists.
  2. Dev-list [DISCUSS] with the usage data attached; lazy consensus is not sufficient for high-traffic methods — explicit vote.
  3. Removal lands only in a major release, with the migration recipe linked from the release notes' breaking-changes table.
  4. For the unmaintained-binaries long tail: consider a separately-versioned compat jar 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 @Public surface change — cheap to run as a PR label + required review.
  • Design docs (the Phase 12 capstone format) required for any new @Public type: 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

  1. A return type needs to change from List<T> to Stream<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.
  2. 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.
  3. 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).
  4. 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