Somewhere in your organization, there is a slide that says the migration is 80 percent complete.
Ask a different question. Ask how many of the old systems have been switched off. Ask what the data center bill was last quarter compared to the one before the program started. In the migration programs I've been brought into over the last two decades, those two answers usually don't sit anywhere near the 80 percent on the slide.
That gap is the whole story. And I've come to think most migration programs are measured backwards.
The double-run tax
Here is what "80 percent migrated" usually means in practice. Eighty percent of the workloads have a new home. Containers are running. Pipelines are green. The dashboards look excellent.
Meanwhile, the legacy platform is still up, because a handful of things still depend on it. A nightly batch job nobody owns. A reporting extract that finance needs on the third of every month. Two integrations with a partner whose contract renews next year. So the old estate keeps running, fully staffed, fully licensed, alongside the new one.
You're now paying for both. Not just infrastructure. You're paying for two on-call rotations, two sets of security patches, two audit trails, and an engineering team that has to keep the two in sync, because data written on one side has to show up on the other. That synchronization layer is usually the most fragile code in the entire program, and it was never in the original design.
I call this the double-run tax, and the thing that makes it dangerous is that nobody budgets for it. The business case was built on the savings you get after the old thing goes away. If the old thing never goes away, the business case was fiction, and the program will be quietly declared a success anyway because 80 percent of something got moved.
So my working definition: a migration is complete when the old system is off. Not when the new one is on. Everything else is a build program wearing a migration's clothes.
The last 20 percent is not technical debt. It's archaeology.
The reason programs stall in the same place every time is that the remaining workloads share a common trait. Nobody knows exactly what they do.
The people who wrote them have moved on. The documentation, if it exists, describes an intent from 2009 that the code stopped honoring in 2013. There's business logic living in stored procedures, in a scheduler nobody logs into, in a config file with a comment that says "do not change, ask Raj." Raj retired.
You can't rewrite what you can't describe. And the standard response to this — assign a senior engineer to "do discovery" — is a six-week task per system, which is why the tail takes years and eventually gets abandoned in favor of running both platforms forever.
This is the part of the job where generative AI has actually earned its place on my programs, and it's not where people expect.
The honest use of an LLM on a migration
I have limited patience for the pitch that a model will rewrite your monolith into microservices. What it does well is read.
Point a model at a legacy codebase and you can get, in an afternoon rather than six weeks: an inventory of every external system the code talks to, an inventory of every scheduled job and what it touches, a plain-English description of what each stored procedure appears to do, and a list of every place where the behavior contradicts the documentation.
That last one is the valuable output. Not the code. The map.
But the model will also confidently describe behavior that doesn't exist, which on a migration is worse than no description at all, because someone will build against it. So the rule I put on every one of these efforts: the model doesn't get to assert anything. It produces claims with citations, and the claims get verified.
finding_id: F-114
system: billing_batch
claim: "Job writes a reversal record when a payment is voided
within 24h of capture."
evidence:
- file: BatchReconcile.java, lines 402-437
- file: proc_void_payment.sql, lines 88-95
confidence: medium
contradicts_documentation: true
verification: pending # replay 90 days of prod traffic
owner: unassigned
Every claim carries file and line references, so a human can check it in two minutes instead of two days. Anything that touches money, compliance, or customer state gets verified against replayed production traffic before it becomes a requirement. The model compresses discovery. It does not get to be the source of truth about what your business does.
Used that way, the archaeology stops being the reason the program dies.
Events are how you make cutover survivable
Once you know what a legacy system does, you still have to leave it, and this is where big-bang cutovers earn their reputation. Everyone agrees they're a bad idea. Everyone does them anyway, because the alternative looks harder to plan.
The alternative is to put a seam in. Have the legacy system emit an event for every state change that matters, and let the new service consume it. For a while, both systems are live. The legacy path still owns the write. The new path builds its own state from the stream and you compare the two, silently, in production, on real traffic, for as long as it takes to stop finding differences.
Then you flip the write. The legacy system becomes a consumer instead of a producer. Then you flip that off too.
What this buys you is the ability to be wrong safely. The comparison window is where you discover that the legacy system rounds in a way nobody documented, or applies a discount rule that only fires for accounts created before a certain date. You want to find that while the old system is still authoritative, not during a weekend cutover with the CFO on the bridge call.
It's slower on paper. It's dramatically faster than the alternative, once you count the failed cutover and the rollback.
Give decommissioning its own budget and its own deadline
Everything above is engineering. This part is not, and it's the part that actually determines whether the old system dies.
Decommissioning is nobody's favorite work. It generates no demos. It ships no features. When budgets get squeezed at month nine, the retirement workstream is always the first thing to slip, because slipping it has no visible cost this quarter.
So it can't be a phase at the end. It has to be a funded workstream with a named owner from day one, and it needs a forcing function that lives outside the engineering org. A hardware lease that isn't renewed. A support contract with a hard end date. A license that expires. Something the program cannot quietly extend by three months in a steering committee.
The metrics I'd actually report
Drop "percent migrated." It measures effort, not outcome, and it can sit at 80 for two years.
Report systems fully retired. Report total double-run months across the estate, and watch whether the number is falling. Report the share of production traffic still served by the legacy path, which is the only honest measure of how close a cutover really is. And report the run rate of the old estate against the business case that funded the program, because that's the number the CFO thought she was buying.
None of these are flattering in year one. That's the point. The flattering metric is exactly the one that lets a program run for three years and end with two platforms instead of one.
What I've stopped believing
I used to think the hard part of modernization was the architecture. Pick the right decomposition, get the boundaries right, choose your event backbone carefully, and the rest follows.
The architecture is mostly a solved problem now. Twenty-two years in, the pattern library is good, the platforms are mature, and I can staff a team that will build you a clean Kubernetes estate with proper pipelines and sane service boundaries.
What I haven't seen get any easier is turning the old thing off. That's an organizational problem dressed as a technical one, and it doesn't yield to better architecture. It yields to somebody owning the kill date and being willing to be unpopular in month nine.
The migration isn't done when the new platform goes live. It's done when someone unracks the old one.