The situation nobody writes down
A director calls us with some version of the same story. There is a system at the center of the business. It settles the trades, prices the policies, schedules the plant, or produces the rating that customers pay for. It is old. It works. The vendor that wrote part of it no longer exists, the internal team that wrote the rest has turned over twice, and the documentation on the wiki is a diagram of the intended architecture from a decade ago plus a page of setup instructions that no longer apply. When someone asks why a particular discount caps at 22.5 percent, the answer is a person opening a file and reading a conditional.
What makes this hard is not the age of the language. COBOL is fine for what it does, and so is the Delphi, the VB6, the PL/SQL package with 11,000 lines in it, or the 2009-vintage Java monolith that is identical in every way that matters. The hard part is that the code absorbed twenty years of decisions recorded nowhere else. Every regulatory change, every accommodation for a large customer, every fix for a bug found in production at 2am is in there as a branch. Some are load-bearing. Most look identical to the ones that are dead.
So the first question is not what to build. It is what the current system actually does. Teams that skip it and start writing the replacement do not fail on day one. They fail eight months in, in user acceptance testing, when a controller finds the new system producing a number three cents different on 1.4 percent of records and nobody can say which one is right.
Where Modernization Budgets Actually Go — Our Ranking by Effort
Relative weights from the shape of the work when the source system is undocumented. Your mix will differ; the bottom row is always smaller than the plan assumed.
The rewrite is the expensive answer, and it is usually the wrong one
The instinct is to declare a rewrite. New stack, clean architecture, two-year program. This keeps happening because a rewrite is easy to fund: a clear story, a clear end state, a number. It keeps failing because the old system does not stop changing while you build the new one. Regulations move, customers make demands, competitors ship. Two teams now implement the same changes twice, and the replacement chases a target running away from it.
The Government Accountability Office has published case study after case study on federal modernization programs that ran long or were abandoned, and the findings are consistent enough to be predictive: scope defined as a full replacement, institutional knowledge that left before it was captured, and no regression evidence proving the new system matched the old. Commercial programs fail the same way. They just do not publish the report.
Incremental migration is not a compromise. It is the only approach where you find out you are wrong in week six instead of month sixteen. The tradeoff is real: you carry two systems for a while, and the seam between them is work a clean rewrite would not have. That cost is bounded and visible. The cost of a rewrite that misses is not.
Recover the behavior from the running system, not the source
Reading the code tells you what it says. Instrumenting the running system tells you what it does. Those diverge more than people expect, because dead branches read exactly like live ones.
The move that changes the odds is capturing production inputs and outputs at the boundary you intend to replace. For a batch program, the input and output files across thousands of real runs. For a service, request and response pairs. For a pricing engine, every quote in and every number out. Six months of captured traffic tells you which of the 340 conditional branches ever fired, the real distribution of inputs, and where the edge cases live. It also tells you definitively that eleven branches have not executed since 2019 and can be retired rather than reimplemented. That finding alone has cut scope by a fifth on work we have done.
Capture has a compliance dimension to settle before turning it on. If the traffic carries PII, PHI, or anything covered by a customer agreement, the capture store is a new system of record and needs the source system's controls: access control, retention limits, and encryption at rest mapped to whatever already governs you, whether NIST SP 800-53, 800-171 for controlled unclassified information from a government customer, or a HIPAA security rule assessment. Tokenizing the sensitive fields at capture time is cheaper than expanding an audit boundary.
Characterization tests: pin the behavior before you touch it
A characterization test does not assert that the system is correct. It asserts that the system does what it currently does. Michael Feathers named the technique in Working Effectively with Legacy Code, and it converts an unknowable system into a measurable one.
The mechanism is simple. Take the captured input and output pairs, wrap the component under test, and record the output as the expected value. You are not judging whether the 22.5 percent cap is right. You are pinning it, so that if the replacement produces 25 percent a test goes red in ninety seconds instead of a controller finding it in month nine. When the business later decides the cap should be 25, the change is a documented decision instead of an accident.
Grade the suite honestly. Line coverage is close to meaningless here, because a suite can execute a line without checking the result. Mutation testing gives a real signal: PIT for the JVM, mutmut and Cosmic Ray for Python, Stryker for JavaScript and .NET deliberately break the code and report how much damage the tests catch. A suite that survives 60 percent of mutations will let a rewrite through with a defect in it. Property-based testing in the Hypothesis tradition earns its place here too, since properties expressed against captured traces stay true across an implementation change in a way example-based assertions do not.
Where AI helps and where it quietly hurts
Language models are genuinely useful on this work, in a narrow way. They are good at producing a first-pass explanation of an unfamiliar 800-line procedure, at drafting the docstrings and module summaries that make an unfamiliar codebase navigable to a new engineer, and at generating candidate test inputs that a coverage-guided fuzzer then explores. Those uses share a property: a human or a deterministic tool checks the output before anything depends on it.
They are unreliable on exactly the things that decide a modernization. Loop invariants, aliasing, arithmetic precision, concurrency hazards, and the semantics of a numeric type are where models produce fluent wrong answers, and a fluent wrong answer is more dangerous than none. A model that explains a COBOL COMP-3 packed decimal field as an integer has introduced a rounding defect into a financial system, and the explanation will read perfectly.
The rule we hold to is that AI generates hypotheses and deterministic tools produce evidence. A summary saying a function validates input length is a hypothesis. A property checked by symbolic execution, or a differential test against 90,000 captured records, is evidence. Teams that blur the two ship a modernization that demos beautifully and fails acceptance. If you are building governance around this, the NIST AI Risk Management Framework's Map and Measure functions are the right scaffolding, and ISO 42001 is the certification path if a customer starts asking.
Confidence Before Cutover — What We Require Green
A gate list, not a scorecard. Anything short of the stated bar moves the cutover date rather than lowering the bar.
The dependency graph you do not have
Before anything moves, you need to know what calls the thing you are moving. Static analysis gets you most of the way: CodeQL queries, LLVM whole-program analysis, and language-specific call graph tools produce a defensible first map. The problem is what static analysis structurally cannot see: reflection, dynamic library loading, configuration-driven dispatch, stored procedures invoked from a string, a scheduled job on a server nobody inventoried, and the report a finance analyst built in 2014 that reads the production table directly.
That last category takes systems down. We have never seen a legacy system where the consumer list the organization believed in was complete. Runtime tracing closes the gap: turn on connection-level logging, watch a full business cycle including month-end and quarter-end, and reconcile against the static map. Month-end matters because a quarter of the surprise consumers only appear then.
Produce a software bill of materials in CycloneDX or SPDX on the same pass. It is now a procurement expectation on anything touching a federal customer, and it forces someone to answer what third-party code is actually in the build. On old systems the answer is often a library version with published vulnerabilities and no upgrade path, which you want surfaced in week three rather than during a customer security review.
Strangler boundaries and the anti-corruption layer
The migration pattern that works is Martin Fowler's strangler fig. Put a facade in front of the legacy component and route all traffic through it. Move functionality behind that facade one bounded piece at a time, the facade deciding per request which implementation serves it. When the last piece has moved, the old system is dark and you turn it off. There is never a big-bang weekend.
Two details determine whether this works in practice. First, the boundary has to sit somewhere the data model is stable. If you draw it in the middle of a transaction that writes to six tables, you have created a distributed transaction problem in exchange for a modernization, and that is a bad trade. Draw it at a place where a request comes in, work happens, and a result goes out.
Second, build an anti-corruption layer rather than letting the legacy data model leak into the new code. Legacy systems have shapes that only make sense as history: a status field with eleven values where three are unreachable, a date stored as a packed integer, a customer identifier that means two things depending on a prefix. Let those shapes into the new implementation and you have built a new system with the old one's debt already in it. The translation layer is a few hundred lines, and it is the difference between modernizing and reupholstering.
Parallel running is the only proof that counts
Run both systems on the same live input for a full business cycle. The legacy system stays authoritative. The new implementation runs alongside, output compared record by record, every difference logged with enough context to investigate. This is where the money is earned.
The classic finding is a rounding bug in the legacy system that has been quietly producing wrong numbers for years. Someone now has to decide whether the new system reproduces it for continuity or fixes it and restates. That decision belongs to the business, and it needs to be made explicitly, with the amount written down.
Set the comparison tolerance in advance, at zero for anything financial or regulatory. A one-cent tolerance is an invitation to stop investigating, and the difference you stop investigating is the one that turns out to be a systematic error on a subset. A divergence with an explanation and a sign-off closes. Without both, it is open, and cutover waits.
What this costs and how long it takes
Honest ranges, for a single meaningful subsystem rather than an entire estate. Discovery and behavior recovery on a component of 50,000 to 150,000 lines runs six to twelve weeks and $120,000 to $300,000, depending on how much production capture exists and how many integration points turn up. It ends with a dependency map validated by runtime data, a characterization suite with a measured mutation score, and a migration sequence with the boundaries drawn.
The migration itself, running behind a strangler facade with parallel validation, typically runs four to nine months and $400,000 to $1.2 million for that subsystem. A full estate is a multi-year program, which is exactly why it should be sequenced as a series of independently valuable subsystem migrations rather than sold as one program with a single completion date.
The number worth comparing against is not zero. It is what the current system costs to stay alive: the specialist contractor rate for a language with a thin labor market, the elongated change cycles, the incidents, and the deals lost because a customer required an integration the system could not support. Most organizations have never added that up. It is a two-week exercise and it usually settles the argument.
How to tell whether a team has done this before
If you are evaluating engineering firms for this work, the questions that separate them are specific. Ask what they do first, and listen for whether behavior recovery precedes design. Ask how they will prove the new system matches the old, and listen for differential testing on captured production traffic rather than a test plan. Ask what happens when they find a bug in the legacy system, and listen for whether the decision routes to the business. Ask for the mutation score they hold themselves to, and where they would draw the first boundary and why there.
A firm that answers with a migration factory model and a stack diagram is selling a rewrite with different words on it. A firm that starts talking about instrumentation, capture, and what the first two weeks produce has done this work. The habit that distinguishes them is willingness to say what they do not yet know about your system, and exactly how they would find out.
Bottom line
When the code is the only documentation, the modernization is a knowledge recovery project with a software deliverable at the end. Capture what the running system does, pin it with characterization tests you have graded honestly, map every consumer including the ones nobody claims, migrate behind a strangler boundary with an anti-corruption layer, and prove equivalence on live input until the divergence log is empty and explained. In that order, the risk stays bounded at every step. Skip the first half and the project is a rewrite with a nicer name, which is the version that shows up in the case studies about programs that did not finish.
Frequently asked questions
It can produce a syntactically plausible translation quickly. That is not the hard part. The hard part is proving the translation behaves identically on real data, and numeric types, packed decimals, and file handling semantics are where machine translation reliably goes wrong. Use it to accelerate the boilerplate at a strangler boundary, then verify with differential testing against captured production runs.
At least one full business cycle, and for anything with month-end or quarter-end processing, through that close. Cycles shorter than that miss the periodic code paths, which are both the least exercised and the most consequential when they fail.
That is the normal starting condition and it is workable. Characterization tests built from captured production inputs and outputs give you a regression suite without anyone having to first understand the code. Grade it with mutation testing so you know what the suite is actually worth before you rely on it.
It creates a new store that inherits the source system's obligations, so treat it that way from the start. Tokenizing or synthesizing sensitive fields at capture time keeps the audit boundary from expanding, and mapping the capture store's controls to the framework you already operate under (800-53, 800-171, or a HIPAA security rule assessment) is a short exercise done up front and an expensive one done late.
Not in the same step. Change the code behind a stable data contract first, prove equivalence, then migrate the schema as a separate move with its own reconciliation. Changing both at once means a divergence has two possible causes and you cannot tell which.