docs(deprecation): add database schema migration patterns (expand/contract)
Adds a Database Schema Migrations section to deprecation-and-migration covering expand/contract, dual-write + batched backfill, additive-first/destructive-last, tested down paths, and non-blocking index builds. References incremental-implementation for slicing; reuses the skill's existing Feature Flag Migration pattern.
This commit is contained in:
@@ -161,6 +161,34 @@ function getTaskService(userId: string): TaskService {
|
||||
}
|
||||
```
|
||||
|
||||
### Database Schema Migrations (Expand/Contract)
|
||||
|
||||
A schema change is the riskiest migration because the data is the one thing you cannot roll back by reverting a deploy. The failure mode is coupling the schema change to the code change: rename a column in the same release that starts using the new name, and during the rollout window — when old and new code run at once — one of them is querying a column that doesn't exist. The fix is to **never change a column in place**. Migrate in additive phases so old and new code are both valid 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
|
||||
```
|
||||
|
||||
**Worked example — renaming `name` to `full_name`:**
|
||||
|
||||
1. **Expand.** Add `full_name` as nullable. Deploy. (Old code ignores it; nothing breaks.)
|
||||
2. **Dual-write.** App writes both `name` and `full_name` on every insert/update. Deploy.
|
||||
3. **Backfill.** Copy `name → full_name` for existing rows, in batches, so you don't lock the table.
|
||||
4. **Switch reads.** Point the app at `full_name`, keep writing both. Deploy and bake.
|
||||
5. **Contract.** Stop writing `name`, then — in a *separate, later* deploy — drop the column.
|
||||
|
||||
Each step is independently deployable and reversible: if step 4 misbehaves, roll the code back and `full_name` is still being populated. Treat each phase as a thin vertical slice — see the `incremental-implementation` skill.
|
||||
|
||||
**Rules:**
|
||||
- **Additive first, destructive last and alone.** Adds (new nullable column, new table, new index) are safe in any deploy; drops and renames get their own deploy *after* no code references the old shape.
|
||||
- **Every migration has a tested down path.** A migration you can't reverse is a deploy you can't roll back. Write and run the `down` before merging.
|
||||
- **Backfill in batches, off the hot path.** A single `UPDATE` over millions of rows locks the table; chunk it and throttle.
|
||||
- **Build large indexes without blocking writes** (e.g. Postgres `CREATE INDEX CONCURRENTLY`).
|
||||
- **Decouple from code by feature flag** when the cutover is risky, exactly as in the Feature Flag Migration pattern above.
|
||||
|
||||
## Zombie Code
|
||||
|
||||
Zombie code is code that nobody owns but everybody depends on. It's not actively maintained, has no clear owner, and accumulates security vulnerabilities and compatibility issues. Signs:
|
||||
@@ -183,6 +211,9 @@ Zombie code is code that nobody owns but everybody depends on. It's not actively
|
||||
| "We'll deprecate it after we finish the new system" | Deprecation planning starts at design time. By the time the new system is done, you'll have new priorities. Plan now. |
|
||||
| "Users will migrate on their own" | They won't. Provide tooling, documentation, and incentives — or do the migration yourself (the Churn Rule). |
|
||||
| "We can maintain both systems indefinitely" | Two systems doing the same thing is double the maintenance, testing, documentation, and onboarding cost. |
|
||||
| "Just rename the column, it's one line" | During the rollout, old and new code run together — one will query a column that no longer exists. Expand/contract, never rename in place. |
|
||||
| "I'll add the column and drop the old one in the same migration" | That couples a safe add to a destructive drop. Drops get their own deploy, after no code references the old shape. |
|
||||
| "We'll write the rollback if we need it" | A migration with no down path is a deploy you can't reverse. Write and run the `down` before merging. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
@@ -193,6 +224,9 @@ Zombie code is code that nobody owns but everybody depends on. It's not actively
|
||||
- New features added to a deprecated system (invest in the replacement instead)
|
||||
- Deprecation without measuring current usage
|
||||
- Removing code without verifying zero active consumers
|
||||
- A schema change and the code that depends on it shipped in the same deploy
|
||||
- A column renamed or dropped in place rather than via expand/contract
|
||||
- A migration merged with no tested down path, or a backfill that locks the table
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -204,3 +238,10 @@ After completing a deprecation:
|
||||
- [ ] Old code, tests, documentation, and configuration are fully removed
|
||||
- [ ] No references to the deprecated system remain in the codebase
|
||||
- [ ] Deprecation notices are removed (they served their purpose)
|
||||
|
||||
After a database schema migration:
|
||||
|
||||
- [ ] The change ships in additive phases (expand → backfill → contract), not a single in-place edit
|
||||
- [ ] Old and new code are both valid against the schema at every deploy step
|
||||
- [ ] Each migration has a tested down path; backfills run in throttled batches
|
||||
- [ ] Destructive steps (drop/rename) ship in their own deploy after no code references the old shape
|
||||
|
||||
Reference in New Issue
Block a user