Overview
Code is a liability, not an asset. Every line of code has ongoing maintenance costs: bug fixes, dependency updates, security patches, and helping new engineers understand it. Deprecation is the discipline of removing code that is no longer worth maintaining, and migration is the process of safely moving users from an old system to a new one.
Most engineering organizations are good at building things, but rarely good at removing them. This skill exists to fill that gap.
When to use
- Replacing an old system, API, or library with a new one
- Retiring features that are no longer needed
- Consolidating duplicate implementations
- Removing dead code that no one owns but everyone depends on
- Planning the lifecycle of a new system (deprecation planning starts at the design stage)
- Deciding whether to continue maintaining a legacy system or invest in migration
Core principles
Code is a liability
Every line of code has ongoing costs: it needs tests, documentation, security patches, dependency updates, and it imposes a comprehension cost on anyone working nearby. The value of code lies in the functionality it provides, not in the code itself. When the same functionality can be provided with less code, lower complexity, or better abstractions, the old code should go.
Hyrum's Law makes removal difficult
With enough users, every observable behavior will be depended upon, including bugs, timing quirks, and undocumented side effects. This is why deprecation requires active migration, not just an announcement. When users depend on behavior that the replacement does not reproduce, they cannot simply "switch over".
Deprecation planning starts at the design stage
When building something new, ask: "How will we remove this in three years?" Systems with clear interfaces, feature flags, and minimal surface area are easier to deprecate than systems that leak implementation details everywhere.
Deprecation decisions
Before deprecating anything, answer these questions:
1. Does this system still provide unique value?
→ If yes, maintain it. If no, proceed.
2. How many users/consumers depend on it?
→ Quantify the migration scope.
3. Does a replacement exist?
→ If no, build the replacement first. Don't deprecate without an alternative.
4. What's the migration cost for each consumer?
→ If trivially automated, do it. If manual and high-effort, weigh against maintenance cost.
5. What's the ongoing maintenance cost of NOT deprecating?
→ Security risk, engineer time, opportunity cost of complexity.
Advisory deprecation vs. forced deprecation
| Type | When to use | Mechanism |
|---|---|---|
| Advisory | Migration is optional, the old system is stable | Warnings, documentation, prompts. Users migrate at their own pace. |
| Compulsory | The old system has security issues, blocks progress, or has unsustainable maintenance costs | A clear deadline. The old system will be removed on date X. Provide migration tooling. |
Use advisory deprecation by default. Only use forced deprecation when maintenance costs or risk justify forced migration. Forced deprecation requires providing migration tooling, documentation, and support; it cannot just be announcing a deadline.
Migration process
Step 1: Build the replacement
Do not deprecate without a usable replacement. The replacement must:
- Cover all key use cases of the old system
- Have documentation and a migration guide
- Be proven viable in production, not just "theoretically better"
Step 2: Announce and document
## Deprecation Notice: OldService
**Status:** Deprecated as of 2025-03-01
**Replacement:** NewService (see migration guide below)
**Removal date:** Advisory — no hard deadline yet
**Reason:** OldService requires manual scaling and lacks observability.
NewService handles both automatically.
### Migration Guide
1. Replace `import { client } from 'old-service'` with `import { client } from 'new-service'`
2. Update configuration (see examples below)
3. Run the migration verification script: `npx migrate-check`
Step 3: Incremental migration
Migrate one consumer at a time, do not migrate all of them at once. For each consumer:
1. Identify all touchpoints with the deprecated system
2. Update to use the replacement
3. Verify behavior matches (tests, integration checks)
4. Remove references to the old system
5. Confirm no regressions
The Churn Rule: If you own the infrastructure being deprecated, you are responsible for migrating your users, or providing a backward-compatible update that does not require migration. Do not announce a deprecation and leave users to figure it out on their own.
Step 4: Remove the old system
Only after all consumers have completed migration:
1. Verify zero active usage (metrics, logs, dependency analysis)
2. Remove the code
3. Remove associated tests, documentation, and configuration
4. Remove the deprecation notices
5. Celebrate — removing code is an achievement
Migration Patterns
Strangler Pattern
Run the old and new systems in parallel. Gradually shift traffic from the old system to the new system. When the old system carries 0% of traffic, remove it.
Phase 1: New system handles 0%, old handles 100%
Phase 2: New system handles 10% (canary)
Phase 3: New system handles 50%
Phase 4: New system handles 100%, old system idle
Phase 5: Remove old system
Adapter Pattern
Create an adapter that translates old interface calls to the new implementation. While migrating the backend, consumers still use the old interface.
// Adapter: old interface, new implementation
class LegacyTaskService implements OldTaskAPI {
constructor(private newService: NewTaskService) {}
// Old method signature, delegates to new implementation
getTask(id: number): OldTask {
const task = this.newService.findById(String(id));
return this.toOldFormat(task);
}
}
Feature Flag Migration
Use feature flags to switch consumers from the old system to the new system one by one:
function getTaskService(userId: string): TaskService {
if (featureFlags.isEnabled('new-task-service', { userId })) {
return new NewTaskService();
}
return new LegacyTaskService();
}
Database Schema Migration (Expand/Contract)
Schema changes are the highest-risk migrations, because data is the only thing that cannot be recovered by rolling back a deployment. Its failure mode is coupling schema changes with code changes: if you rename a column in the same release in which you start using the new column name, then during the rollout window (when old and new code run simultaneously), one side will inevitably be querying a column that does not exist. The solution isnever modify columns in place. Migrate in additive phases, so that old and new code are both valid against the schema at every step.
EXPAND ──────────────→ MIGRATE ──────────────→ CONTRACT
add the new column, backfill existing rows, once no code reads the
nullable, alongside dual-write old+new from old column, drop it in
the old one the app a later, separate deploy
Complete example—rename name to full_name:
- Expand。 add the
full_namecolumn as nullable. Deploy. (Old code ignores it, nothing breaks.) - Dual-write. The application writes both
nameandfull_nameon every insert/update. Deploy. - Backfill. Copy the
name → full_nameof existing rows over in batches, avoiding table locks. - Switch reads. Have the application read
full_nameinstead, while keeping dual-write. Deploy and observe for a while. - Contract. Stop writing
name, then—in aseparate, laterdeployment—drop the column.
Every step can be deployed and rolled back independently: if step 4 goes wrong, just roll back the code, andfull_name is still being populated. Treat each phase as a thin vertical slice—see incremental-implementation skill。
Rules:
- Do additive first, destructive last and separately. Additions (new nullable column, new table, new index) are safe in any deployment; drops and renames should be inafter no code references the old structure, with their own separate deployment.
- Every migration must have a tested down path (rollback path). A migration that cannot be reversed is a deployment that cannot be rolled back. Write and run it before merging
down。 - Backfill in batches, avoiding hot paths. Running a single
UPDATEagainst millions of rows will lock the table; chunk it and rate-limit it. - Do not block writes when building large indexes(for example, Postgres's
CREATE INDEX CONCURRENTLY)。 - When cutover risk is high, use feature flags to decouple from the code, exactly consistent with the approach of the Feature Flag Migration pattern above.
Zombie Code
Zombie code refers to code that no one owns but everyone depends on. It is not actively maintained, has no clear owner, and accumulates security vulnerabilities and compatibility issues. Signs include:
- No commits for over 6 months, but there are still active consumers
- No designated maintainer or team
- Has failing tests but no one fixes them
- Dependencies have known vulnerabilities but no one updates them
- Documentation references systems that no longer exist
How to handle it: Either designate an owner and maintain it properly, or deprecate it with a concrete migration plan. Zombie code cannot remain in limbo forever; it either gets investment or gets removed.
Common Rationalizations
| Rationalization | Reality |
|---|---|
| "It still works, why remove it?" | Usable code that no one maintains accumulates security debt and complexity. Maintenance costs grow quietly. |
| "Someone might need it later" | If it is needed later, it can be rebuilt. Keeping unused code around "just in case" usually costs more than rebuilding. |
| "Migration is too expensive" | Compare the migration cost with the ongoing maintenance cost over the next 2-3 years. In the long run, migration is usually cheaper. |
| "Deprecate it after the new system is done" | Deprecation planning starts at the design stage. By the time the new system is done, you will have new priorities. Plan now. |
| "Users will migrate themselves" | They won't. Provide tools, documentation, and incentives, or do the migration yourself (The Churn Rule). |
| "We can maintain two systems indefinitely" | Two systems doing the same thing means maintenance, testing, documentation, and onboarding costs are all doubled. |
| "It's just renaming a column, just one line of code" | During rollout, old and new code run at the same time, so one side is bound to query a column that no longer exists. Expand/contract, never rename in place. |
| "I'll add the new column and drop the old one in the same migration" | This couples a safe add with a destructive drop. The drop needs its own separate deployment, and it should happen after no code references the old structure. |
| "I'll write the rollback when I need it" | A migration without a down path is an irreversible deployment. Write and run it before merging down。 |
Red Flags
- A deprecated system has no usable replacement
- A deprecation announcement has no migration tools or documentation
- A "soft" deprecation remains only a suggestion for years with no progress
- Zombie code has no owner but still has active consumers
- Adding new features to a deprecated system (investment should go to the replacement)
- Deprecating without measuring current usage
- Removing code without verifying there are no active consumers
- Schema changes are released in the same deployment as the code that depends on them
- Columns are renamed or dropped in place instead of using expand/contract
- Migration was merged without a tested down path, or a backfill locked the table
Verify
After completing a deprecation:
- The replacement has been proven viable in production and covers all critical use cases
- A migration guide exists and contains concrete steps and examples
- All active consumers have been migrated (verified via metrics/logs)
- Old code, tests, documentation, and configuration have been fully removed
- The deprecated system is no longer referenced in the codebase
- Deprecation notices have been removed (they have served their purpose)
After completing a database schema migration:
- Changes are released in additive phases (expand → backfill → contract), not as an in-place modification
- At every deployment step, both old and new code are valid against the schema
- Every migration has a tested down path; backfills run in rate-limited batches
- Destructive steps (drop/rename) have their own separate deployment after no code references the old structure