Modernizing Legacy Software with AI: A Migration Playbook for Rails and React Teams
A phased playbook for modernizing legacy Rails and React codebases with AI coding agents: inventory, characterization tests, strangler-fig migration, and the mistakes agents make on old code.

Modernizing legacy software with AI is a different job from greenfield
Legacy code is where most of the value sits, and where agents are most dangerous if used carelessly. Modernizing legacy software with AI works when the mechanical, well-specified work goes to agents and the judgement calls stay with engineers who understand the system. This playbook is the process we use on Rails and React codebases that are typically five to ten years old, several major versions behind, and load-bearing for the business.
The overriding rule: an agent can move fast only inside a safety net, and on legacy code that net usually does not exist yet. Building it is step one.
Step 1: Inventory before anything else
You cannot migrate what you have not measured. The first week of an engagement produces an inventory, and this is one place where agents are excellent because the work is broad and mechanical:
- Dependency graph and versions. Ruby, Rails, Node, React, every gem and package, with the gap between installed and current.
- Framework surface in use. Which Rails APIs, which deprecated React patterns (class components, legacy context, string refs), which build tooling.
- Hot paths. The endpoints and jobs that carry most traffic and most revenue, taken from logs and APM rather than from memory.
- Dead code candidates. Routes with no traffic in ninety days, feature flags nobody toggles, tables nothing reads.
- Test coverage by module, and, more importantly, which of the tests actually run in CI and pass.
An agent with repository access can draft most of this in hours. An engineer then corrects it, because the inventory will contain confident mistakes: a gem the agent believes is unused but is loaded by name at runtime, or a "dead" route that a partner integration hits once a month.
Step 2: Characterization tests, not unit tests
Legacy code usually has tests that document what the code was meant to do years ago, if it has tests at all. What you need before touching anything is a record of what the code does now, including the bugs customers have come to depend on. These are characterization tests: capture the inputs and outputs of the current system, and assert that they do not change.
For a Rails app, that means request specs against the highest-traffic endpoints with recorded fixtures, plus snapshot tests of rendered views and serialized JSON. For a React app, it means rendering key screens with representative props and snapshotting the output, plus end-to-end tests of the three or four flows the business would notice within an hour if they broke.
This is the most valuable place to put agents in the whole migration. Backfilling tests is tedious, well-specified, and easy to verify: run the test against the unchanged code and it must pass. We typically have an agent generate characterization tests module by module, with an engineer reviewing for coverage of the flows that matter rather than the flows that are easy.
Step 3: Strangler fig, not big bang
The strangler fig pattern is the safest shape for modernization: put a routing layer in front of the old system, build the new implementation of one slice behind it, cut that slice over, and repeat until the old system has nothing left to do.
For a Rails monolith the routing layer is often Rails itself, with new controllers or a mounted engine handling migrated routes. For a React front end it is usually a module boundary: new screens written with current patterns and a shared design system, mounted inside the old shell until the shell itself is replaced last.
The important property is that each cutover is small and individually reversible. That is what lets you use agents aggressively: the blast radius of any single mistake is one slice, behind a route you can flip back.
What to hand to agents
Agents earn their keep on legacy code when the task is mechanical, has a clear definition of done, and can be verified by running something.
- Framework upgrades. Following the Rails upgrade guide one minor version at a time, an agent can apply deprecation fixes, update configuration, and run the suite after each step. The engineer reads the changelog and decides what is risky.
- Type annotations. Converting a JavaScript React codebase to TypeScript, or adding Sorbet or RBS signatures to Ruby, is exactly the kind of exhaustive, low-judgement work that agents do well and humans do badly for more than an hour at a time.
- Test backfill. Characterization tests first, then unit tests around the code you are about to change.
- Documentation generation. Module-level summaries, sequence descriptions for the hot paths, an architecture overview new engineers can read. Agents produce a good first draft from the code; an engineer corrects the parts where the code lies about its intent.
- Pattern migrations. Class components to hooks, callback-style code to async/await, old ORM query styles to current ones. Give the agent one worked example and the linter as a judge.
What agents get wrong on legacy code
The failure modes are consistent enough that we review for them explicitly.
- Inventing intent. Faced with strange code, an agent will guess why it is strange and "fix" it. Legacy strangeness is often a workaround for a real constraint nobody documented. Preserve behaviour first; refactor only with a characterization test in place.
- Widening the change. Asked to upgrade a gem, an agent may also reformat files, rename variables, and modernise unrelated code it passed on the way. Each of those changes is noise in the review and a possible regression. Constrain the scope in the instructions and reject diffs that exceed it.
- Trusting the tests too much. An agent will happily make a red test green by changing the assertion. Review test diffs with more suspicion than code diffs.
- Missing runtime-only behaviour. Rails metaprogramming, dynamic method definitions, YAML-driven configuration and monkey patches are invisible to static reading. Anything an agent proposes to delete needs a production-traffic check.
- Mixing eras. Agents trained on years of mixed documentation will blend Rails 5 and Rails 7 idioms, or React 16 and React 19 APIs, in one file. Pin the target versions in the task context and lint for the old idioms.
The Thoughtworks Technology Radar has tracked the industry's evolving view of where AI-assisted coding is reliable; the pattern of "good for the mechanical, careful with the judgement" matches what we see on the ground.
Incremental cutover and measuring risk
Each slice moves through the same gates:
- Characterization tests green against the old implementation.
- New implementation passes the same tests.
- Shadow traffic: run both, compare outputs, log differences without serving the new result.
- Canary: a small percentage of real traffic on the new path, with a one-line rollback.
- Full cutover, and the old code deleted within a fixed window so it does not linger.
Risk is measured, not felt. We track a small set of numbers per slice: characterization coverage of the slice, difference rate in shadow mode, error rate and p95 latency on the canary versus the old path, and the DORA measures of change failure rate and time to restore across the whole migration. If the difference rate in shadow mode is not near zero, the slice does not go to canary, however good the diff looks.
A phased plan
For a mid-sized product — say a five to eight year old Rails API with a React front end — the phases look like this. Durations are indicative and depend heavily on test coverage at the start.
| Phase | Focus | Mostly agent | Mostly engineer |
|---|---|---|---|
| 0. Inventory (about a week) | Dependency map, hot paths, dead code candidates, coverage | Drafting the inventory | Correcting it, choosing the first slice |
| 1. Safety net (2–3 weeks) | Characterization tests on hot paths, CI that runs them, shadow-traffic harness | Test generation | Deciding what matters, harness design |
| 2. Foundations (2–4 weeks) | Rails and Node version steps, TypeScript adoption, lint rules for target idioms | Upgrades, annotations | Reviewing risky changelog items |
| 3. Slices (1–2 weeks each) | Strangler-fig migration of one bounded area at a time | Pattern migration, tests, docs | Boundary design, review, cutover decisions |
| 4. Retirement | Remove the old shell, delete dead code, finalise docs | Deletion PRs, documentation | Confirming nothing depends on what is removed |
Two things make this plan work in practice. First, phase 1 is not skippable, however much pressure there is to show progress; it is what makes every later phase safe to run fast. Second, the slices in phase 3 should be ordered by value and risk together: high-traffic, well-covered areas early, so the process is proven where it matters and the team learns the agents' failure modes before touching the parts nobody understands.
For teams in India running a decade-old product on a small engineering budget, this is also the honest answer to "rewrite or migrate". A rewrite spends comparable money, has no safety net until it is finished, and finishes later than planned. A strangler-fig migration with agents doing the mechanical work delivers value from the first slice.
Further reading
- Martin Fowler: Strangler Fig Application
- Upgrading Ruby on Rails, the official guide
- React documentation for the patterns a migration should target
- DORA metrics and research
- Thoughtworks Technology Radar