Wave-shaped refactors: how to modernize a hot persistence path without freezing development
Big-bang PRs get skimmed. Multi-week branches drift. Neither works for a hot persistence path. The shape that does: 5 independently-shippable waves, shadow-mirror writers, and a kill-switch wave that turns modernization into actual debt reduction.
AppX team ·
There comes a point in every codebase where a hot persistence path has accreted enough archaeology that you can't add the next feature without paying a debt tax. The agent-turn table. The user-state machine. The billing ledger. Some core write path that other teams have built three layers of feature work on top of, and that you now need to rearchitect without freezing development.
You face a dilemma. Big-bang PRs invite reviewers to skim and merge what they can't fully audit — nobody reads a 47-file diff carefully, and your hot path doesn't deserve a skim. Multi-week feature branches drift from main, accumulate merge conflicts in proportion to how busy the surface is, and arrive at integration time with three weeks of subtle conflict debt. Both options trade safety for completeness.
The shape that works is neither. It's a sequence of independently-shippable waves, each one a single commit, each one deployed to prod and observable in metrics before the next one starts. The system stays on the old shape until you cut over. Old code keeps working in production through the entire refactor. Each wave can be paused, rolled back, or extended without leaving anything half-done.
The dilemma
The reason hot paths resist normal refactor patterns is that they're hot. By the time you decide the schema needs to change, four other teams have shipped features on top of the current shape. The current shape has callers in the controller layer, the websocket layer, two cron jobs, and a webhook handler. A "stop-the-world rewrite" branch ages out before you can land it. A drop-in replacement PR is too large to review well.
What you want is a shape where:
- Every step compiles and deploys on its own.
- No step breaks the next.
- No step depends on a later step being ready.
- At every point, you can stop and the system is in a defensible state.
That is the wave shape.
The wave shape
Wave 0 Wave 1 Wave 2 Wave 3 Wave 4 Wave 5
read schema repo service FE kill
only → add → class → rewire → hydrate → switch
(no (dead (dead (writes (reads (drop
code) cols) class) both shapes) both) JSON)
↑
soak window
Note the timeline is strictly sequential — none of these waves run in parallel. Each one consumes what the previous one built. The shape is a queue, not a fan-out.
Wave 0 — Inventory
Read-only. No code changes. You walk every reference to the current shape: callers, readers, writers, tests, fixtures, downstream consumers. You write down what you find. This wave produces a document, not a diff. It is the cheapest wave by an order of magnitude and the one most teams skip — which is why their later waves drift.
Cost: half a day. Risk: zero. Skippable: only if the surface is genuinely small.
Wave 1 — Schema
Add new typed columns alongside the existing JSON blob. NULLable. Indexed. Auto-applied via your migration tool (Drizzle, Prisma, Kysely, plain SQL — pick your poison). No writers populate the new columns yet. Zero behavior change in production. The migration ships, the schema widens, nothing else moves.
ALTER TABLE chat_turns
ADD COLUMN status VARCHAR(32) NULL,
ADD COLUMN duration_ms INT NULL,
ADD COLUMN tool_calls INT NULL,
ADD INDEX idx_status (status);
The JSON blob (metadata) still has the source-of-truth keys. The typed columns are a parallel address space.
Cost: small. Risk: small (a NULL column on a hot table is almost free if indexed thoughtfully). Shippable independently: yes.
Wave 2 — Repository
Write the new typed-builder repository class. Methods named for the queries the next wave will need: findActiveByUser, markComplete, recordToolCall. Full unit-spec coverage. The class is dead code in production until Wave 3 wires it in. Reviewers can audit it in isolation because it has no callers — there is nothing to break.
This is where most of the design work lives. Get the method signatures right; Wave 3 will inherit them and you don't want to rename across the rewire.
Cost: medium (design-heavy). Risk: low (dead code). Shippable independently: yes.
Wave 3 — Service rewire
The expensive wave. Existing service methods become thin facades that delegate to the new typed repository. Service contracts stay unchanged so callers don't move. Writers populate both the new typed columns and the legacy JSON blob — this is the shadow-mirror pattern, discussed below. Readers prefer typed columns when present and fall back to JSON when absent.
Every existing caller has to be re-thought. You will discover writers you forgot about. You will discover that one cron job at 04:00 UTC writes a field nobody else knows about. This is the wave where Wave 0 pays off.
Cost: large. Risk: medium (the dual-write doubles the write path; instrument it). Shippable independently: yes.
Wave 4 — Frontend hydrate
The backend response DTO now surfaces typed columns alongside the legacy JSON. The frontend reader prefers typed; legacy walks remain as fallback for any rows written before Wave 3 shipped. A medium pass — touching every component that reads from the affected shape — but mechanical.
Cost: medium. Risk: low (fallbacks cover gaps). Shippable independently: yes.
Wave 5 — Kill switch
Drop the JSON shadow writes. Mark the JSON keys deprecated in the schema. Remove the frontend legacy fallbacks. The system now writes the new shape exclusively and reads the new shape exclusively. This is the wave that turns "modernization" into actual debt reduction.
Cost: small mechanically. Risk: political (people object: "but what if we need the fallback?"). Shippable independently: yes — but only this wave reduces complexity instead of adding it.
Shadow-mirror — the pattern that enables wave independence
The shadow-mirror pattern is what makes Wave 3 and Wave 4 independent. Writers populate both shapes during a soak window. Readers prefer the new shape and fall back to the old. Neither side blocks the other.
A shadow-mirror writer looks like this:
async function recordTurnComplete(turn: ChatTurn) {
// SOAK WINDOW: write both typed columns AND legacy JSON.
// Remove the JSON write in Wave 5 after typed-write coverage
// hits 100% on the dashboard. Until then both shapes are
// authoritative; readers prefer typed.
await db.update(chatTurns)
.set({
status: 'complete',
durationMs: turn.workedForMs,
toolCalls: turn.toolCounts.total,
metadata: {
...turn.metadata,
status: 'complete',
workedForMs: turn.workedForMs,
toolCounts: turn.toolCounts,
},
})
.where(eq(chatTurns.id, turn.id));
}
And the reader:
function readTurnStatus(row: ChatTurnRow): TurnStatus {
// Prefer typed column; fall back to JSON for pre-Wave-3 rows.
if (row.status != null) return row.status as TurnStatus;
return row.metadata?.status ?? 'unknown';
}
The reader is uglier than you want it to be, on purpose. It documents that there are two shapes in flight. Wave 5 deletes the fallback branch and the ugliness goes away.
The wave teams skip (kill switch)
Wave 5 is the wave teams skip. The new shape is shipped, the metrics look good, the team is exhausted, and the next quarter's planning has started. Wave 5 gets pushed to "later." Later does not come.
What you get when you skip Wave 5:
- The JSON keys still get written on every turn. Forever.
- The frontend still has the fallback walks. Forever.
- Code review for every new feature includes "do we need to update both shapes?" Forever.
- New engineers ask "why are there two ways to read this?" and someone has to explain. Forever.
Schedule Wave 5 explicitly. Put it on the calendar before you start Wave 3. The political objection is always the same — what if we need the fallback? — and the answer is always the same: you have backups, you have the migration script in reverse, and the typed-write coverage metric has been at 100% for two weeks. The fallback is not earning its keep.
When wave shape is overkill
Not every refactor needs this. The wave shape pays off when:
- The surface is hot (other people are shipping daily on top of it).
- The persistence path has external observers — clients, webhooks, dashboards — that would break under a stop-the-world cutover.
- The blast radius of a bad migration is large enough that you want each step reversible.
If the surface is cold, or the table is touched by one team, or the migration is a column rename — just ship the PR. The wave shape has overhead. It is appropriate for the cases where the overhead is cheaper than the alternative.
Takeaways
- Independently-shippable waves beat big-bang PRs on hot persistence paths. Each wave is a commit, a deploy, and an observable metric.
- Wave 0 (inventory) is the cheapest and most-skipped. Don't skip it.
- Wave 3 (service rewire) is the expensive one. Budget accordingly.
- The shadow-mirror pattern — writers populate both shapes, readers prefer new — is what makes the waves independent. Hold it until the typed-write metric hits 100% on real traffic, then schedule the kill switch.
- Wave 5 is non-optional. Without it you have not refactored; you have merely added a parallel address space. Put the kill switch on the calendar before Wave 3 starts.
- Instrument every wave. A dashboard tile per wave is the difference between "we think this shipped" and "we know this shipped."