Article · May 1, 2026

Phased migrations with per-phase verification gates

Big-bang rewrites of 6+ files break things you cannot predict. Phased migration with explicit gates is slower and catches the regressions early.

Most refactors that go wrong go wrong because somebody decided to do it all at once. Six files of form code converted from useState to React Hook Form in one commit. Twenty edge functions ported from the v1 SDK to v2 in one branch. A backend rewrite from REST to GraphQL shipped on a Tuesday. Each one of those is a real story I have either been pulled into to fix or watched a team try to recover from over a week.

The alternative is phased migration. One file per phase. Each phase shipped, verified, and merged before the next dispatches. It is slower in calendar time and faster in total time, because the regression-bug week never happens.

When to phase and when to big-bang

Three conditions argue for a big-bang refactor:

  1. The change is purely mechanical (rename a function across the codebase, no semantic change).
  2. The test coverage is comprehensive enough that a regression triggers a red CI within minutes.
  3. The whole change fits in one reviewer’s head in one sitting.

Most refactors fail at least one of those. The form-layer migration I described in migrating useState forms to React Hook Form and Zod touches six files; even with strong tests, the per-file edge cases (default values, server-error handling, custom select components) accumulate into surprises. Phased is the safer default.

The cost of phased is real: each phase has overhead (prompt drafting, gate verification, commit, push). For a six-file migration, the overhead might be a day. The benefit is a week of regression fixes that never happen. The architectural ancestor of this pattern is Martin Fowler’s Strangler Fig Application, which describes replacing a legacy system one route at a time, each replacement shipped behind its own gate.

The shape of a phase

Each phase has the same five pieces, in this order:

  1. A target. One file or one well-bounded unit of work (e.g. “convert EditProfileForm.tsx from useState to RHF, including the matching test file”).
  2. A prompt. The full instruction sent to whichever AI tool is doing the implementation (Claude Code, Cursor, Lovable). The prompt names the file, the desired output, and the invariants.
  3. The implementation. The actual code change, however generated.
  4. A gate. The verification step that decides whether the next phase dispatches.
  5. A log entry. A few lines in a verification log capturing what was done, what was verified, and any surprises.

The discipline is that step 5 must update before step 1 of the next phase begins. The log is what keeps the migration auditable across days.

A diagram showing the five pieces of a phase as a vertical chain: target on top, then prompt, then implementation, then gate, then log entry, with an arrow back up to the target of the next phase only if the gate passes (green path) and an arrow to a rollback path if the gate fails (red path).

The gate criteria

The gate is the part that earns its place. The three layers I run on every phase:

Layer 1: automated. Lint, typecheck, build, unit tests. This runs in 30 seconds to 2 minutes. Failing this is a hard stop; do not proceed to layer 2.

Layer 2: functional walkthrough. A 12 to 15 step manual test of the changed surface. For a form migration, the steps would be: open the form, type a valid name and email, save, verify the toast appears; open the form again, leave the name blank, blur, verify the error appears; resubmit with a server-side conflict, verify the error renders under the email field; etc. The walkthrough takes 5 to 10 minutes per phase. It catches the bugs that automated tests miss because nobody wrote a test for the case.

Layer 3: regression check. Open three or four other parts of the app that should not be affected by the change. For the form migration, you would walk through an unrelated dashboard, an unrelated workflow, and a settings page, looking for any surprise that the change rippled through. This is the “did we break anything we weren’t trying to touch” check.

If all three layers pass, the phase is done. Commit, push, log, dispatch the next phase. If any layer fails, fix and re-run. Do not move on.

Drafting all prompts upfront in parallel

The temptation is to write each phase’s prompt right before dispatch. The problem with that is the gate often takes longer than the implementation, so the dispatcher (you) sits idle waiting for the AI tool to finish, then has to write the next prompt from a cold start.

The pattern that works:

  1. Spend the first hour of the migration drafting all the per-phase prompts in one sitting. Each one is a self-contained brief.
  2. Save the prompts to a folder like migration-prompts/phase-1-name.md, migration-prompts/phase-2-name.md, etc.
  3. Dispatch phase 1, run the gate, log, dispatch phase 2 immediately.
  4. No prompt-writing in the loop. The dispatch loop is “wait for AI, run gate, log, dispatch next.”

The hour of upfront prompt-drafting pays for itself by the third phase. By phase 6, you are not even thinking about the prompts; you are running the gate loop.

For a recent six-form migration, the upfront drafting took 90 minutes. Each phase took 25 to 40 minutes (15 minutes of AI implementation, 10 minutes of gate verification, 5 to 15 minutes of small fixes). The whole migration took 4 days. The big-bang version would have taken 1 day to write and a week to debug.

The verification log pattern

A single markdown file at the root of the migration captures the state across days. Mine usually looks like this:

# Migration verification log: useState → React Hook Form

## Phase 1: Form A
- Prompt: migration-prompts/phase-1-form-a.md
- Implementation: Claude Code, 12 min
- Gate:
  - Lint/typecheck/build: pass
  - Functional (12 steps): pass
  - Regression (3 surfaces): pass
- Surprises: none
- Commit: abc1234

## Phase 2: Form B
- Prompt: migration-prompts/phase-2-form-b.md
- Implementation: Lovable, 8 min
- Gate:
  - Lint/typecheck/build: pass
  - Functional (15 steps): FAIL on step 9 (server error not displaying under field)
  - Fix: setError('email', { message }) was setting on 'emailAddress' (typo)
  - Re-run: pass
- Surprises: Lovable misread the field name; verified the corrected version
- Commit: def5678

Two values. The log is the audit trail when someone six weeks later asks “why did we do it this way.” And the log is the cross-session resume point if the migration spans multiple days (or multiple developers).

When a phase fails the gate

Two responses, one is right.

Wrong response: ship anyway, fix forward. The gate failed; this is the system telling you something is off. Shipping past the gate compresses one bug into a slower-to-fix bug later, and breaks the integrity of the phased approach. Once you have shipped one bad phase, the gate is no longer trustworthy.

Right response: roll back and fix. If the gate fails on layer 1 (automated), the fix is usually small (a typo, a missing import). If the gate fails on layer 2 (functional), the fix might be a small re-prompt or a manual code patch. If the gate fails on layer 3 (regression), something rippled where you did not expect, and the right move is to roll back the phase entirely, re-scope the prompt, and re-dispatch.

The discipline is: the gate gets the final word. If you find yourself talking yourself into shipping a failed gate, your timeline is wrong, not the gate.

Tooling that helps

The shape works without specific tools. The tools that make it faster:

  • Claude Code or Cursor for the per-phase implementation. The advantage over a generic AI is that they have direct file-system access and can apply diffs in place. Lovable works too if your app is web-based.
  • A shared verification-log file at the root of the migration (the verification-log.md pattern above). Treat it as the single source of truth for “what state is this migration in.”
  • A prompts/ folder with one markdown file per phase. Mirrors the verification log. The two together are the project state.
  • A simple script that runs the gate’s layer 1 (lint + typecheck + build + unit tests) with one command. If your CI is fast enough, run the actual CI on each phase; otherwise approximate locally.

I covered the broader pattern of treating AI-assisted work as a structured pipeline in the four-part system for AI-built projects, which the phased-migration template is a subset of.

A worked example from a recent refactor

The clearest example I can share without naming anything specific: a six-page admin dashboard migrating its form layer from useState to RHF + Zod + shadcn <Form>. The pages were Tasks, Clients, Users, Projects, TimeTracking, and Settings. Each form had its own validation rules and its own server-error handling.

The phase plan:

  • Phase 0: install the shadcn Form component (no behavioral change, just dependency)
  • Phase 1: Auth/Login (smallest form, validates the pattern end to end)
  • Phase 2: Tasks (medium complexity, two fields plus a select)
  • Phase 3: Clients (four fields, server-error on duplicate name)
  • Phase 4: Users (five fields, role select, server-error on duplicate email)
  • Phase 5: Projects (six fields, date picker, conditional fields)
  • Phase 6: TimeTracking (seven fields, the form that drove the migration)
  • Phase 7: Settings (mostly password change, server-error mapping)

Eight phases. Each one took 25 to 40 minutes once the prompts were drafted. The whole migration took four working days. Zero regressions in production.

The big-bang version would have been “do all six forms in one weekend.” I have watched two other teams try that exact migration. Both took the same calendar time (four days) but with the order reversed: one day of writing followed by three days of fixing regressions in production.

When AI tools make this more important, not less

The temptation with Claude Code, Cursor, or Lovable is to skip the gates because the implementation is fast. The AI produced 6 files of converted code in 20 minutes; the gate would take another 90 minutes; the math looks like “skip the gates.”

Skip-the-gates is the bug. AI tools generate plausible-looking code quickly. Plausible is not the same as correct. The gates are how you verify the code is actually correct, and the speed of the AI implementation makes the gate cost proportionally higher (which feels worse), but the cost of a missed bug is unchanged.

Phased migration with explicit gates is the discipline that prevents AI-generated code from compounding into a regression week. The faster the implementation, the more important the verification.

If you are running a refactor over five or more files and you want a second opinion on the phase plan or the gate criteria, let’s talk. The phase-planning session is about two hours and produces a written plan you can dispatch immediately. The discipline is the kind of structural work I do on every AI-assisted project before any code gets written.

Frequently asked questions

When should I do a big-bang refactor instead of a phased migration?

Three conditions argue for big-bang: the change is purely mechanical (rename a function, no semantic change), the test coverage is high enough to catch regressions automatically, and the scope is small enough that the whole change reviews in one sitting. Most real-world refactors fail at least one of those. If the change touches six or more files, alters runtime behavior in any way, or lives in a codebase without strong tests, phased is the safer default.

What goes in a verification gate?

Three layers. An automated layer that always runs (lint, typecheck, build, unit tests). A functional layer that runs once per phase (the 12 to 15 step manual walkthrough that exercises the changed surface end to end). A regression layer that runs against the pre-existing surface (does the rest of the app still work). The gate fails if any layer fails. The next phase does not dispatch until the gate is green.

How do I write the per-phase prompts without leaking context?

Treat each prompt as if the AI has never seen the project. Include the specific file path, the desired before and after shape, the invariants that must hold (no new dependencies, reuse the shared schema, match the existing API contract), and a short "do not change" list. The prompt should be self-contained; the AI agent reads only the prompt plus the file it is editing. If you find yourself referencing "the pattern we established in phase 2," fold that into the phase-3 prompt explicitly.

Should each phase be its own commit?

Yes. One commit per phase keeps the history readable, makes the verification log mirror the commit log, and lets you roll back exactly one phase if the gate fails after merge. Squash-merging defeats the purpose; keep each phase as a discrete commit on the feature branch. If your team policy is "squash everything," consider one PR per phase instead of one PR with multiple commits.

Can phased migrations work without AI tools?

Yes. The discipline predates AI. The "Strangler Fig" pattern (described by Martin Fowler in 2004) is the architectural ancestor: replace a legacy system one route at a time, each route shipped behind its own gate. AI tools shorten the per-phase implementation time but do not change the discipline. If anything, AI tools make phased migrations more important because they generate plausible-looking code quickly, and the gates are how you verify the code is actually correct.