ES

Migrating a legacy app to a modern stack today

Leo G.7 minLeer en español

A modern glass building and a weathered old facade standing side by side.
A modern glass building and a weathered old facade standing side by side.

Migrating a legacy app —Web Forms, VB6, a .NET Framework monolith with fifteen years of patches— to a modern stack is work almost nobody wants to do and almost everybody needs to do. With a coding agent involved (Claude Code, Copilot, Cursor, whichever you use), that work changes scale: what used to take months of manual rewriting can be finished in weeks. But speeding up a badly planned migration only gets you faster to a new system with the same old problems, written in a modern language.

This is what I would do today if I had to take on that migration: not a list of prompts, but the order in which the decisions are worth making — which is what separates a migration that leaves the system better from one that merely leaves it different.

1. Map the domain before touching a single line

Before asking the agent to translate any code, you have to understand what that code does — not how it is written, but which business process it solves. A Web Forms page with six postback events and logic spread across the code-behind, a couple of stored procedures and a JavaScript file from 2011 does not get migrated by reading the file top to bottom.

This is where it pays to use AI the other way around from how it is usually used: not to write new code, but to read the old code and summarize what it does. Ask it to document every screen, every validation rule and every side effect before writing a single line of the new system. That documentation —even if you throw it away afterwards— is what lets you migrate the real behavior of the legacy, not the literal reading of the code that implemented it.

2. Migrate by layer, not by screen

The temptation is to start with what you can see: turn every .aspx into a React component and call it a migration. The problem is that the real business logic almost never lives on the screen — it lives scattered across UI events, database procedures and some Session["something"] nobody dares to touch.

It is worth inverting the order: first the domain model and the business rules, expressed as pure, testable functions with no UI around them. Only then the data layer that feeds them, and the interface last. Migrated that way, the logic ends up somewhere a coding agent can read and modify with confidence, instead of being hidden behind a different framework all over again — which is the difference between arriving at a modern stack and arriving at a new legacy with better typography.

3. Characterization tests before touching anything

Without a safety net of tests, migrating with AI is like asking someone to translate a book into a language they do not master and trusting that the meaning survived. And that net cannot be written against the intended behavior —nobody has it fully documented— but against the actual behavior of the legacy, exactly as it is today, bugs included.

That is what characterization tests are: run the old app with representative cases, capture the output exactly as it comes out, and use that output as the reference.

// Before migrating anything: run it against the old system
// and store the real output as the reference (golden master).
const oldOutput = await oldSystem.calculateInvoice(order123);
storeAsReference("invoice-order-123", oldOutput);

// After each migration step: compare against that reference.
const newOutput = await newSystem.calculateInvoice(order123);
assert.deepEqual(newOutput, reference("invoice-order-123"));

A coding agent can generate dozens of these cases from real (anonymized) data far faster than a person writing them by hand. What it cannot decide on its own is which cases matter — that part is still the job of whoever knows the business.

4. Coexistence: strangler fig, not big-bang

Rewriting everything at once and only then switching off the old system looks tidy on a diagram and goes wrong almost every time: on cutover day the cases nobody migrated show up, and there is no way back without losing days of data.

The pattern that works is the strangler fig, named after the fig tree that grows around another tree, gradually envelops it and ends up taking its place without there ever being a moment with no tree standing. It works the same way here: the new system coexists with the legacy, and traffic is redirected route by route, screen by screen, as each part is migrated and validated. A proxy or a router up front decides, per endpoint, which of the two systems each request goes to.

If something you migrated fails, the remedy is to switch that one route off and send it back to the old system — not to unwind the whole migration. Each step stays small, reversible and easy to review, which is exactly the task size where a coding agent does its best work: bounded context, a clear definition of done, and a test that says whether it worked.

5. Target architecture defined before you start

A coding agent without a defined architecture does not make bad decisions: it makes no overall decision at all, because it has nothing to derive one from. Each session solves the file in front of it in whatever way looks reasonable to that session, and two screens migrated at different moments end up with different layers, different naming conventions and a different way of handling errors.

Before migrating the first file, it is worth having it written down —in a short document, not in somebody’s head— what layers the new system has, what things are called, and which pattern is used for whatever repeats: error handling, validation, data access. That document is what you hand the agent as context on every task, so the architecture gets decided once by a person instead of fifty times by fifty different sessions.

6. Mandatory human review on critical business logic

Not every part of the app carries the same weight. Tax calculations, billing logic or permission rules deserve someone reading line by line what came out, even when the characterization tests pass — a test covers the cases you thought of writing, not every case that exists.

The practical rule is to classify each module before migrating it: the mechanical parts (a grid, a simple create form) get a quick look; anything that moves money, decides permissions or touches sensitive data gets reviewed as seriously as a change written by someone new on the team — because, in practice, that is exactly what it is.

7. Document decisions, not just code

Every choice made during the migration —why a library was dropped, why this screen ended up structured differently from the rest, why a module was left unmigrated for now— is forgotten within weeks if it is not written down somewhere. A short decision log (ADRs: what was decided, why, which alternatives were discarded) takes minutes to write and saves hours later, both for the next person and for the next agent session that has to touch that part.

It is also the only thing that stops history from repeating: the legacy you are migrating today was once a modern stack too, and what got lost along the way was not the code but the reasoning behind each decision.

The most expensive mistakes

  • Migrating everything at once. Without intermediate steps there is no way to tell when something broke, or to go back without losing all the work.
  • Starting to migrate without characterization tests. Without them every change is a leap of faith: it may work, or it may break an edge case nobody notices until production.
  • Letting the style vary depending on which session wrote each file. Without a written target architecture, the modern stack ends up as inconsistent as the legacy was, only in a newer language.
  • Migrating only the easy parts. It is tempting to take the simple screens and leave the oldest, most critical logic for last. That is exactly backwards: the complex parts are the ones that benefit most from early tests and review, and the ones that take longest to understand properly.

The order matters more than the tool

Migrating a legacy app with AI is not faster by default: it is faster when the order of the decisions is right. Map the domain before translating code, migrate by layer rather than by screen, write characterization tests before touching anything, and keep both systems coexisting while you migrate in small, reversible steps.

AI speeds up every one of those steps — but the order is still set by whoever plans the migration, not by the agent. Reaching a modern stack is the easy part; reaching one that is still maintainable five years from now is what these seven steps decide.

Comments