8 min read

Deprecating an API Without Breaking Five Teams

Deprecation fails in two directions: breaking everyone at once, or a warning that survives three years. The Kotlin tooling that makes migration mechanical, the escalation schedule that makes a deadline real, and why counting call sites is the whole job.

API DesignKotlinAndroidLibrariesEngineering Culture
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — A deprecation is a migration you're asking other teams to do, so the only lever that matters is making it near-zero cost. Ship the replacement first and use it yourself; deprecate with ReplaceWith (the argument most people skip — it turns migration into Alt+Enter) and a real date in the message; count remaining call sites in CI because you can't manage what you can't count; send the migration PR yourself; then escalate WARNING → announce → ERROR → remove. Never deprecate without a replacement, and never silently change behaviour instead — a compile error is found by someone who can fix it, a behaviour change is found in production by someone who can't.

Two failure modes, and the quiet one is worse

The loud failure: you delete the function, five teams' builds go red, and it's the morning of someone's release. You'll hear about that one, and it teaches the wrong lesson — the org concludes that deprecation is dangerous rather than that yours was badly run.

The quiet failure: @Deprecated goes on in March 2023 and is still there in 2026, with 340 call sites and a warning everyone's IDE has been trained to ignore. Nobody logs this as a failure. But now you maintain two APIs, every new engineer picks the old one because it's the one that appears in the codebase most, and the warning noise has taught the team that warnings are decorative.

A deprecation without a deletion date is not a deprecation. It's a comment with a yellow squiggle.

Start from the honest framing

You are asking several teams to spend time on something they did not plan, for a benefit that mostly accrues to you. That's fine — it's how shared code stays healthy — but it means your job is to drive the cost per team as close to zero as you can. Everything below is a way of doing that.

Step 1: ship the replacement, and use it yourself first

Before a single @Deprecated annotation:

  • The new API exists, is released, and is documented.
  • You have migrated at least one real call site in your own code. Not a sample. A real one.

That last requirement catches the thing that otherwise gets discovered five times by five teams: the replacement doesn't quite cover the case where the URL is null, or it needs a CoroutineScope the old one didn't, or the parameter order makes every call site read badly. Find it yourself. It's cheap to fix before you've asked anyone to adopt it and expensive after.

Step 2: make the migration mechanical

This is where Kotlin gives you real tooling, and where most deprecations under-use it.

@Deprecated(
    message = "Use load(Request) — supports cancellation and " +
        "per-request timeouts. Removed 2026-12-01. " +
        "Migration: docs/migrate-load.md",
    replaceWith = ReplaceWith(
        expression = "load(Request(url))",
        imports = ["com.example.net.Request"],
    ),
    level = DeprecationLevel.WARNING,
)
fun load(url: String): Response = load(Request(url))

Three things doing work here:

  • ReplaceWith — the IDE now offers Replace with on every call site, and "Run inspection by name → Deprecated symbol → Apply fix in whole project" migrates an entire repository in one action. This is the difference between a five-minute task and an afternoon, and it's the argument people leave out.
  • The date, written as a date. "A future release" is not a commitment and won't be treated as one.
  • The old function delegates to the new one. Both work, identically, for the whole transition. If they can't be made to behave identically, see the note on semantic changes below.

If you can't express the replacement as a mechanical transform, stop. You don't have a deprecation, you have a redesign, and it needs a different plan — a joint one, with the affected teams, and a schedule they agreed to rather than received.

Step 3: count the call sites

The most-skipped step and the one that makes this manageable.

# In CI, weekly, posted to the channel
git grep -c "\.load(" -- '*.kt' | awk -F: '{s+=$2} END {print s}'

For a multi-repo setup, a scheduled job that clones each consumer and counts is an hour of work and gives you the only progress signal that exists. Publish it:

load(String) migration — 2026-12-01 deadline
  :feature:player   12 → 0   ✅
  :feature:tv        8 → 3
  :feature:search   31 → 31  ⚠️ not started
  total             51 → 34

Three effects, all of them good. You know whether the deadline is realistic while you can still move it. The team at 31 knows they're the blocker without anyone having to say so in a meeting. And you can see whether a team is stuck rather than slow, which are different problems with different fixes.

Step 4: send the PR yourself

For a mechanical migration this is often twenty minutes per consumer, and it is worth more than every reminder you will ever post. You know the replacement better than they do, the diff is boring, and a PR sitting in their queue converts at a rate no Slack message approaches.

It also surfaces the real blockers immediately. If a team can't merge your trivially-correct PR, the reason is interesting and you want to know it now — usually a frozen release branch or a consumer you didn't know existed.

Reserve your own time for the biggest two consumers if you can't do all of them. That's where the count is.

Step 5: escalate on a schedule

The escalation is what makes the date real.

When Level What you do
T-0 WARNING + ReplaceWith Announce with the date. Publish the call-site count.
T+4 weeks WARNING Send migration PRs. Report the count.
T+8 weeks ERROR Announce two weeks before flipping it. Nobody discovers this from a red build.
T+12 weeks Removed / HIDDEN Delete, or hide for binary compatibility.
// The step people forget to announce.
level = DeprecationLevel.ERROR

The rule for the ERROR flip is that it must be boring. Everyone knew, the count was near zero, and the remaining teams had a note in their sprint. If flipping it to ERROR surprises anyone, the communication failed, not the schedule.

And check the calendar before you flip. Ask the two biggest consumers when their code freeze is and avoid it. Costs you a Slack message and buys you a great deal of goodwill for the next deprecation, which there will be.

Published libraries: binary compatibility

If your artifact is consumed as a binary — a published SDK, or a module another team builds against without recompiling — source-level deprecation isn't enough. Removing a function is a breaking change requiring a major version.

// Invisible to source, still present in the
// binary. Old compiled callers keep working.
@Deprecated("", level = DeprecationLevel.HIDDEN)
fun load(url: String): Response = load(Request(url))

And enforce it mechanically, so nobody has to remember:

// binary-compatibility-validator
// api/library.api is checked into git;
// CI fails on an unapproved diff.
plugins { id("org.jetbrains.kotlinx.binary-compatibility-validator") }

A checked-in API dump turns "did we break anyone?" from a judgement call into a diff in code review, which is exactly where that question belongs.

Two things not to do

Don't deprecate without a replacement. "Don't use this" with nowhere to go is a complaint, and it gets treated as one — the warning is suppressed and the call sites keep multiplying, now with an @Suppress("DEPRECATION") on top so you can't even count them.

Don't silently change the behaviour instead. This is the tempting shortcut: keep the signature, change what it does, avoid the whole social process.

// Same signature. Now throws on 404 instead
// of returning an empty Response.
// Nobody's build breaks. Somebody's users do.
fun load(url: String): Response

A compile error is a gift: it's found at build time, by the person who can fix it, before anything ships. A semantic change is found in production, by someone with no idea your library changed, during an incident. If the semantics change, the name changes. That's the whole reason the mechanical-transform rule exists.

The rule

Deprecation is a migration you're asking other people to do — so your job is to make it cost them nothing. Replacement first, ReplaceWith so the IDE does the typing, a real date, a count you publish, a PR you send, and an escalation nobody is surprised by. Do that and the removal is a non-event, which is exactly what a good deprecation looks like: nothing happens.

Keep reading