Deprecation
Introduce the new thing, migrate the consumers, warn on the old, then remove it. Skipping a step does not save time; it moves the cost onto whoever is unlucky.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
How do I get rid of an internal interface that other people's code still calls?
The Order.legacyTotal() method computes prices in a way that has been wrong since the tax change. It must go. Forty-one call sites remain, across three teams, and two of them are in a service nobody has deployed in a year.
Add a @deprecated annotation and a comment saying "use total() instead". People will see the warning and migrate.
Nobody sees it. A deprecation warning in an editor is noticed by whoever opens that file, and the forty-one call sites are in files nobody has opened.
- Nobody sees it. A deprecation warning in an editor is noticed by whoever opens that file, and the forty-one call sites are in files nobody has opened.
- Even when it is seen, it is a request with no deadline and no help, competing with work that has both. It loses every time.
- Two years later the annotation is still there, both implementations are still maintained, and new code has started calling the deprecated one — because it is still there, so it must be fine (What Technical Debt Actually Is).
- Meanwhile the annotation has made things worse in a specific way: it signals that the old path is unsupported while it is still load-bearing, so bugs in it get triaged as "well, that is deprecated" and go unfixed.
- The version of this that skips the other way — delete it and let the build break — works fine for compile-time consumers and silently corrupts data for reflective, dynamic or cross-service ones.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- You cannot edit the other teams' code unilaterally in a way they will accept — you can propose, you cannot merge for them.
- One of the consumers is a scheduled job that runs quarterly, so "nobody has called it in a month" is not evidence of anything.
- Deleting it and seeing what breaks is not available: some of the breakage would be a wrong number rather than an exception.
- At every moment during the migration the system must work. There is no window in which the old path is gone and the new one is not yet adopted (Expand and Contract).
- Old and new must produce the same answer for every case that is not the bug being fixed, or this is two changes wearing one commit (What Refactoring Actually Is).
- A deprecation with no removal date is not a deprecation. It is a second implementation you have promised to maintain forever.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The owner of the interface owns the whole lifecycle, including the removal, and including finding the call sites. "I announced it" is not ownership.
- The owner also owns providing the migration: a codemod, a script, or a worked pull request against the largest consumer. This is the step that converts a request into a change (Knowledge Sharing).
- Consumers own applying it within the window, and own saying early if the window is impossible.
- Somebody owns the calendar. Without a named date and a named person, a deprecation is a wish (Revisit Triggers).
- The boundary that matters is between announcement and enforcement. Everything before enforcement is optional for consumers, and optional work does not happen.
- For an in-repository interface, enforcement is available and cheap: a lint rule that fails the build on new call sites freezes the problem at its current size on day one.
- Across a network or a published package, enforcement is a version boundary and the mechanics belong to API Design — but the lifecycle is the same shape (Versioned Interfaces).
The order is the whole lesson
Every step here exists because skipping it produces a specific, recognisable failure. The order matters more than the individual steps: warning before there is a replacement produces annoyance, and removing before instrumenting produces an incident.
Note where the enforcement is. Step two is the only step that bounds the problem, it costs almost nothing, and it is the one most often left out.
- 1Introduce the replacement
Ship the new interface and make it obviously better, not merely different. Consumers need a reason beyond your convenience.
fails by Warning about the old path before a replacement exists, which asks people to stop without telling them what to do instead.
- 2Freeze new uses
A lint rule or CI check that fails on any *new* call site. The count can now only go down.
fails by Leaving it out, so the migration is a race against people still adding call sites — which is how a forty-one-site migration becomes a sixty-site one.
- 3Migrate the consumers
Do the mechanical edits yourself, in small pull requests, one team at a time. Offer a codemod if there is one.
fails by Filing tickets instead. Optional work assigned to someone else's backlog does not happen (Knowledge Sharing).
- 4Warn, with a date
A runtime or build warning that names the caller and states the removal date. Now it is a schedule, not an opinion.
fails by An editor-only annotation, which is seen by whoever opens the file and by nobody else.
- 5Prove it is unused
Instrument the old path with caller attribution and let it run for one full cycle of your slowest consumer.
fails by Trusting a static search. Reflection, serialised data and quarterly jobs are all invisible to grep.
- 6Remove it
One commit, nothing else in the diff, easy to revert.
fails by Bundling the removal with a refactor, so the revert at three in the morning also reverts something you needed.
The waiting in step five is calendar time, not effort, and it runs in the background. Teams that skip it are usually optimising the wrong quantity — they are impatient about elapsed time and end up spending work instead.
The lifecycle as a state machine, including the states you must not reach
It is worth drawing because two of the transitions people reach for are the ones that cause every bad deprecation story, and naming them as forbidden is more useful than naming the good path.
The state that does not appear here is the one most codebases are actually in: *deprecated indefinitely*. That is not a state in this machine because it is not part of a lifecycle — it is the absence of one.
| From | On | To | Guard | Effect |
|---|---|---|---|---|
| Current | The replacement ships | Superseded | The replacement covers every case the old path handles | Documentation points at the new interface |
| Superseded | The decision to remove is taken | Frozen | A removal date and an owner are named | Lint rule added; existing sites allowlisted and counted |
| Frozen | Migration of known consumers is complete | Warned | The allowlist is empty or down to the consumers you cannot edit | Runtime or build warning naming the caller; call counter enabled |
| Warned | The observation window closes | Unused | Zero recorded calls for at least one cycle of the slowest known consumer | Removal is scheduled |
| Unused | The removal commit lands | Removed | Nothing else is in the diff | — |
| Warned | A new consumer is discovered | Frozen | The discovered consumer cannot migrate within the window | The date moves once, publicly, with a reason |
- Current → Removed — Deleting without a replacement leaves consumers with a requirement and no way to meet it, so they will reimplement it locally — and you will have converted one shared implementation into several private ones (Duplicate Knowledge).
- Superseded → Removed — Removal without the freeze and the warning means the first anyone hears about it is a broken build, or worse, a wrong number from a path that fails silently. This is the transition that produces the three-in-the-morning revert.
- Warned → Removed — Skipping the observation window means removing on the strength of a static search, which cannot see reflection, serialised payloads, dynamic dispatch or the quarterly job. The failure arrives months later, detached from the change that caused it.
- Frozen → Current — Un-deprecating without deleting the replacement leaves two supported implementations and no stated winner, which is the state this entire lifecycle exists to avoid. If the decision was wrong, remove the replacement instead.
The date moving once, publicly, with a reason, is a legitimate transition. The date moving twice teaches consumers that dates are not real, and no deprecation in this codebase will work afterwards.
What "deprecated" should look like in the code
The difference between a deprecation that finishes and one that does not is usually visible in the annotation itself. One is an opinion; the other is a schedule with an owner and a next step.
Everything in the better version is information a consumer needs in order to act: what to use, how to convert, when it goes, and who to argue with.
/** @deprecated use total() instead */
legacyTotal(): number { ... }
// Two years later this is still here.
// New code calls it, because it is still here.
// Bugs in it are triaged as "well, it is deprecated"./**
* @deprecated Removed 2026-11-01. Use total({ currency }).
*
* Migration: legacyTotal() -> total({ currency: order.currency }).money
* Difference: legacyTotal() omits tax for EU orders (the bug).
* Owner: #team-billing
*
* Enforced: lint rule no-legacy-total (new call sites fail CI)
* Remaining: 2 call sites, both in reporting/ — tracked in DEBT.md
*/
legacyTotal(): number { ... }The second version can be acted on by someone who has never met you: it says what to write instead, what will change when they do, when it stops working, and who to talk to. It also states the enforcement, which is what stops the number of call sites growing while the migration is in progress. The cost is honest — it is six lines of comment that must be kept true, and a stale removal date in a comment is worse than none, so this only works if the date is also in a register somebody reads (The Debt Register).
How to build it
Most important first.
- Introduce the replacement first, and make it obviously better — not just different. A migration that offers no improvement is one nobody will prioritise (Designing a Module Interface).
- Freeze the problem immediately with a lint rule or a check that fails on *new* uses. The count of call sites should never go up after the day you decide to remove something.
- Migrate the consumers yourself where you can. Forty-one mechanical edits by the person who understands the change is an afternoon; forty-one requests to three teams is a quarter.
- Only then warn loudly, with a date. A runtime warning that names the caller, or a build warning that appears in CI output, reaches people that an editor annotation never will.
- Instrument the old path so removal is evidence-based: log or count each call with the caller's identity, and let it run for at least one full cycle of your slowest consumer — a quarter, if there is a quarterly job.
- Remove it, in its own commit, with nothing else in the diff. That commit is the one someone will revert at three in the morning, and it should be trivially revertible (Reversible and Irreversible Decisions).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Running the full lifecycle: roughly a day to introduce and instrument, an afternoon of mechanical migration, a quarter of waiting, and an hour to remove. The waiting is calendar time, not work, and it can overlap with everything else.
- Skipping the migration step and only announcing: the same work, done by three teams instead of one, spread over a year, plus indefinite maintenance of both paths in the meantime. Strictly more total effort.
- Skipping removal: both implementations are maintained forever, every future change to pricing has to be made twice, and every new engineer has to learn which one is real. This is the cost that compounds (Change Amplification).
- Once removed, the next change to pricing costs one edit in one place. That is the entire return, and it does not arrive until the last call site is gone — which is why partial deprecations have negative value.
- The overlap window means maintaining two implementations, and a bug found during it must be fixed in both. That is a real, ongoing cost and the honest argument for the aggressive alternative.
- Migrating consumers yourself means touching code you do not own, which needs review from people who are busy and occasionally produces a worse change than they would have made.
- Enforcement by lint rule is friction on everyone, including people whose use is legitimate and temporary, and it will occasionally block someone at a bad moment.
What can go wrong
- The window is announced, the date passes, nobody has migrated, and the date is extended. The second extension teaches everyone that dates are not real, and no future deprecation in this codebase will work.
- The old path is removed on schedule and the quarterly job fails three months later, in a way that produces a wrong number rather than an error.
- The compatibility shim added to smooth the transition is itself never removed, so you now have three implementations: the old one, the new one, and the adapter between them.
- The lint rule is added with an allowlist of existing violations, the allowlist is never reduced, and it becomes documentation of a migration nobody finished (The Debt Register is where it should have lived instead).
- Instrumentation is added, shows zero calls, the code is removed — and the zero was because the metric was misconfigured. The mitigation failing in the direction that looks like success.
- Removal depends on every consumer having migrated, so your ability to delete code is bounded by the least responsive team that calls it (Code Ownership).
- The instrumentation depends on your observability being good enough to attribute a call to a caller. Where it is not, removal is a guess (Debuggability by Design).
- During the window you depend on both implementations staying correct, which is the real cost of the overlap and the reason for the date.
- "Deprecation means it still works." It means it works *and* has a removal date. Without the date, the word is being used to mean "I do not like this", which is how a codebase ends up with deprecated code that is ten years old (What Technical Debt Actually Is).
- "Just delete it and fix the build." Reasonable for a statically typed, single-repository, fully-compiled consumer set — and it is genuinely the fastest path there. It fails badly for reflection, dynamic dispatch, serialised data, scheduled jobs and anything across a network, and most codebases have at least one of those.
- "This is the same as API deprecation." The shape is identical and the enforcement is not: internally you can lint, migrate and delete; externally you can only announce and wait, which is why the external policy is dominated by version boundaries (Backward Compatibility as a Constraint).
- "The annotation is the work." The annotation is the cheapest step and the least effective. The migration and the date are what move consumers.
- duplicate-knowledge
- shotgun-surgery
Testing it, and how it ages
- Pin the old behaviour before you start, so "the new one agrees with the old one except for the bug" is an assertion (Characterization Tests).
- Test the new interface at its own boundary, and keep the old one's tests running until the day it is deleted — a deprecated path that is untested will break during the window and poison the migration (What a Unit Is).
- Add a test that fails if a new call site appears, if your language allows it. The lint rule is the test that matters most, because it is the one that bounds the problem.
- After removal, a test that the replacement covers the cases the old path handled, including the ones nobody documented.
- A team that completes one deprecation cleanly finds the next one much cheaper, because the tooling — the lint rule, the instrumentation, the codemod habit — is reusable, and because dates become credible.
- A team that fails one finds every subsequent deprecation harder, because consumers have learned that ignoring the warning works.
- Long-lived codebases accumulate a stratum of half-removed interfaces. The recoverable version is a register with owners and dates; the unrecoverable version is a folder called
legacy/that no one is allowed to touch (What "Legacy" Actually Means).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- SCALE-SPECIFICAt one team, deprecation is often unnecessary: change it, fix the callers, ship, and the whole lifecycle is a morning. The machinery starts to pay when the call sites belong to people whose priorities you do not set. In a very large organisation the calculus shifts again — migrating consumers yourself becomes impossible at ten thousand call sites, and the answer becomes automated codemods run centrally, which is a capability small teams do not have and should not try to build.
- LANGUAGE-SPECIFICIn a statically typed, single-build language, removal produces a compile error at every call site and the lifecycle can be compressed drastically. In a dynamic language, or where the interface is reached by reflection, serialisation or a network call, removal produces a runtime failure at whatever moment the rare path executes — so instrumentation over at least one full cycle of the slowest consumer stops being cautious and starts being the only responsible option.
- CONTESTEDThe strongest opposing view: deprecation windows are how dead code becomes immortal. Advocates of immediate removal argue that the overlap period is where all the cost lives — two implementations, doubled bug fixes, confused newcomers — and that a hard break with a fast rollback is cheaper and more honest than a year of warnings nobody reads. In a monorepo with strong typing and good tests this is frequently correct, and the teams who work this way ship faster. It stops being correct the moment a consumer cannot be found by the compiler.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the observation window is only as trustworthy as the instrumentation behind it, and a metric that reports zero because it was misconfigured is indistinguishable from a metric that reports zero because nothing calls the code.