Skip to content
Aug 14, 2026·8 min read

A legacy system migration plan before code generation

Build a legacy system migration plan around evidence, extraction order, parity gates, and engineering decisions before generating replacement code.

A legacy system migration plan before code generation

A legacy migration fails long before the generated code looks wrong. It fails when the team asks a model to infer the system, choose a new architecture, preserve hidden behavior, schedule the work, and judge its own output inside one prompt. That prompt may produce an impressive repository. It cannot produce a defensible chain of decisions.

The plan has to exist before the first target-language file. I mean more than a backlog with boxes called "convert billing" and "migrate reports." A useful plan names the evidence for each behavior, the order in which the team will extract it, the target boundary that will replace it, and the test that permits the next change. If any of those fields is blank, generation should wait.

This distinction matters because a language model generates from the context it receives, while an engineer is responsible for finding the context that nobody thought to include. Old systems hide rules in job control, database triggers, operator habits, file layouts, printer settings, retry scripts, and month-end exceptions. The hard work is deciding what counts as behavior and proving that the replacement still has it.

Inventory behavior before you inventory files

A migration inventory must describe observable behavior, not merely source files and language counts. File lists help estimate parsing work, but they say almost nothing about the contract that users and connected systems depend on. A twelve-line JCL step can decide whether yesterday's settlement file gets replayed. A large report module may produce output that nobody reads.

Start at the system boundary. Record every input, output, scheduled event, operator action, external call, persistent store, and failure signal. For each one, capture a real example and identify who can say whether it is correct. That creates a behavioral surface you can test. Only then map source units onto that surface.

I use four evidence classes because teams routinely mix them together:

  • Executed evidence: production requests, batch inputs, database changes, files, messages, and outputs that the current system actually handled.
  • Declared behavior: manuals, interface agreements, copybooks, schemas, help text, and runbooks that say what should happen.
  • Implemented paths: branches, queries, jobs, triggers, and error handlers present in the code.
  • Operational practice: timing, manual corrections, restart points, and exceptions that operators apply outside the program.

These classes can disagree. That disagreement is a finding, not an inconvenience to smooth over. If the manual says a field is required but recorded traffic contains blanks, the migration plan must decide whether compatibility or the written rule wins. A single generation usually picks whichever artifact appears most authoritative in its context. An engineer records the conflict, finds the owner, and turns the decision into a test.

Build a boundary table before writing target code. A small version might look like this:

BoundaryEvidenceOwnerCompatibility ruleVerification
Nightly account fileThree accepted files and one rejected fileOperations leadPreserve fixed widths and reject code 17Replay all four files
Tax calculationRecorded requests plus current rate tableFinance systems ownerMatch rounded total and audit fieldsCompare response and ledger rows
Statement printSpool samples and printer setupCustomer service leadPreserve page breaks and sort orderRender and inspect named cases

The table exposes empty evidence early. It also prevents the usual mistake of treating a database schema as the whole domain model. A column may permit null because a failed import parks partial data there, while every normal transaction requires a value. Schema shape and business meaning are different facts.

The migration ledger is the actual plan

A useful legacy system migration plan is an executable ledger of claims, dependencies, and gates. It should let another engineer answer five questions for any work item: what are we changing, what evidence defines its current behavior, what must already exist, how will we compare old and new, and who accepts a deliberate difference?

A ticketing board alone does not do this. Tickets drift, acceptance notes become prose, and dependencies turn into memory. Keep a machine-readable ledger in the repository and make reviews refer to its identifiers. The format can be YAML, JSON, or a database table. The important part is that missing proof remains visible.

- id: CALC-014
  boundary: POST /interest/accrue
  evidence:
    traffic_set: traffic/interest/month_end_2025_01.ndjson
    state_snapshot: snapshots/ledger_before.sql.zst
  depends_on: [DATA-006, CLOCK-002]
  target: services/interest/accrual.go
  invariants:
    - response.status == legacy.response.status
    - ledger.delta_cents == legacy.ledger.delta_cents
    - audit.event_type == legacy.audit.event_type
  allowed_differences:
    - field: response.request_id
      rule: compare_presence_only
      approved_by: architecture-review-27
  gate: parity/CALC-014

This fragment prevents three failures. It stops a developer from testing the calculation without the state that drives it. It makes nondeterministic request identifiers an explicit comparison rule instead of a growing ignore list. It also ties approval to a particular difference, so nobody can quietly widen the exception later.

The ledger should separate discoveries from decisions. "The legacy service returns 200 for a duplicate request" is an observation. "The replacement will preserve that response" is a compatibility decision. "We should return 409" is a design preference. Mixing those sentences lets a desirable cleanup masquerade as faithful migration.

Do not put percentages such as "module is 80% migrated" in the ledger. Progress percentages hide which behavior remains. Count closed gates against named boundaries instead. Ten minor report variants do not outweigh one unverified posting path, and the plan should make that obvious.

The plan also needs stop conditions. Generation stops when evidence is missing, a dependency has no verified target, the comparison produces an unexplained difference, or an owner has not approved a deliberate change. Without stop conditions, schedule pressure turns every red result into a future cleanup ticket.

Extract from boundaries toward the center

The safest extraction order starts with observable boundaries, then moves through data semantics and orchestration, and only then reaches internal algorithms. This order gives every later generation a contract and a way to measure its output. Starting with the most self-contained module feels efficient, but it often creates an island whose interfaces were guessed.

First, capture inputs and outputs at stable seams. For an online service, that may mean request bodies, response bodies, headers, database effects, and emitted messages. For a batch system, it means input generations, control cards, return codes, spool output, files created, and restart behavior. For a desktop application, include user actions, local files, reports, registry or configuration state, and calls to shared databases.

Second, extract data meaning. Map identifiers, units, encodings, null rules, decimal scale, date rules, and record lifecycles. Do not normalize yet. A packed decimal field, a blank-padded code, and a local-time timestamp may look ugly, but each can carry behavior. Record how each value enters the system and where it is observed before choosing a cleaner representation.

Third, reconstruct orchestration. Old systems often place business order outside the business modules. JCL defines which program runs after a return code. CL scripts swap libraries. A shell wrapper retries one command but not the next. A scheduler supplies a business date that differs from the machine clock. If generation sees the called programs without that orchestration, it builds individually plausible functions in the wrong sequence.

Fourth, identify state transitions and invariants. Describe a transaction as before state, stimulus, after state, and emitted effects. This is more precise than translating procedures one by one. It also reveals duplicate handling, partial commits, compensating actions, and recovery points.

Only after those layers exist should the team generate internal implementations and target architecture. The target service boundaries should follow ownership, consistency needs, and change patterns. They should not copy the directory tree of the source. A one-for-one module translation preserves accidental coupling and calls it modernization.

There is one practical exception. Sometimes you need a thin parser or emulator early to read evidence locked in a proprietary format. Build it as an extraction tool, label it disposable, and test it against known samples. Do not let that convenience component silently become the production architecture.

Evidence, interpretation, and decisions need separate records

Engineers need separate records for what the system did, what they think it means, and what the project chooses to preserve. A long prompt collapses all three into fluent prose. Once collapsed, a reader cannot tell whether a generated requirement came from traffic, source code, or inference.

Use a claim record for every behavior that will control implementation. It does not need ceremony. It needs provenance and status.

{
  "claim_id": "BATCH-031",
  "statement": "A rerun skips records already posted for the same business date",
  "kind": "observed",
  "evidence": ["run-884/input.dat", "run-884/ledger-after.csv", "ops-runbook-4.2"],
  "confidence": "confirmed",
  "decision": "preserve",
  "tests": ["parity/batch_031_first_run", "parity/batch_031_rerun"]
}

The distinction between observed and inferred behavior is easy to dismiss until it causes a data error. Suppose the code checks an "already posted" table before writing. A generator may infer idempotency. Production evidence may show that the table is cleared during a particular recovery procedure, making some reruns intentionally post again. The code path, operational practice, and intended contract do not line up. The record forces the team to resolve that mismatch.

Treat comments as claims, not truth. The same applies to variable names, dead branches, old design documents, and tests that have not run against production-like state. Each can guide investigation. None should override executed evidence without a named decision.

Confidence labels must have operational meaning. "Confirmed" might require two independent evidence types and an owner review. "Provisional" might allow parser work but block target implementation. "Unknown" should create an extraction task. If labels merely communicate a feeling, they will bend under deadline pressure.

Keep deliberate improvements in a change register linked to the compatibility claim. Examples include rejecting an invalid date that the old system accepts, replacing a weak authentication method, or changing a report layout. Test the preserved path and the new behavior separately. Otherwise, a parity failure and an intended improvement become indistinguishable, which makes both review and rollback harder.

Every generation ends at a parity gate

Rewrite the whole dependency chain
The platform reads every language in the tree in parallel, including orchestration around core programs.

Each generation step should end with a parity gate that compares observable effects against the current system. Code review and target-language unit tests are necessary, but they cannot prove compatibility. They tell you whether the new code is internally reasonable, not whether it behaves like the system people use.

A useful loop has five moves:

  1. Select one ledger item whose dependencies have passed.
  2. Assemble only its approved context: claims, schemas, evidence samples, target constraints, and allowed differences.
  3. Generate or revise the smallest target slice that can satisfy the boundary.
  4. Replay the same stimulus against old and new systems from equivalent state.
  5. Classify every difference, then pass, revise, escalate, or amend the decision record.

Equivalent state deserves attention. If the legacy run starts with thirty years of customer history and the replacement starts with hand-built fixtures, matching responses prove little. Snapshot the relevant state, mask it where required, preserve referential relationships, and document any state you cannot reproduce. For systems that cannot run twice against the same state, clone the state or record side effects at a seam.

Comparison should be semantic, not a raw text diff. Normalize fields only for reasons written in the ledger. You may compare timestamps within an approved tolerance, ignore generated identifiers while requiring their presence, canonicalize JSON object order, or compare a PDF through extracted text and page geometry. Never add a global ignore rule because it makes a red build green.

A parity report should show the item, evidence set, old result, new result, normalization rules, differences, and disposition. This compact result shape is enough for automation and review:

gate=CALC-014 evidence=month_end_2025_01
cases=184 matched=183 different=1 errored=0
difference[1].path=ledger.entries[2].amount_cents
difference[1].legacy=1250
difference[1].target=1249
difference[1].rule=exact
status=FAIL

The one-cent difference is not "close enough." It points to rounding order, decimal representation, or state mismatch. An engineer follows it through intermediate values, checks the source's arithmetic semantics, and adds the smallest test that isolates the cause. Regenerating the whole module with a stronger instruction often changes unrelated behavior and destroys the evidence trail.

Run gates continuously, not at a final acceptance phase. A passed boundary becomes a constraint on later work. When a shared data mapping changes, the dependency graph identifies which gates must run again. This is why the ledger and harness belong together: one says what can change, and the other shows what did.

One giant prompt fails in predictable ways

Bring the whole monolith
Systems over a million lines are read as one connected codebase, not as isolated prompt fragments.

A single migration prompt fails because its jobs conflict and its context has no enforcement mechanism. More context can improve recall, but it does not create evidence provenance, dependency order, independent judgment, or a durable test gate. The generated repository can be coherent and still be wrong at every boundary that was absent from the prompt.

Consider a nightly billing chain. The scheduler supplies a business date. A JCL step sorts transactions with a locale-specific collation. One program posts valid rows and writes rejects. A return code decides whether statements run. Operations can restart after posting without repeating it. The source modules contain pieces of this behavior, but no single module owns the whole contract.

A large prompt asks for a Go rewrite and includes the programs, copybooks, sample files, and a sentence saying "preserve behavior." The output uses the machine date, sorts strings with the target runtime's default, wraps the batch in one database transaction, and treats any reject as a fatal error. Each choice is defensible in a new system. Together they break month-end operation.

The first test file contains ordinary records, so both systems calculate the same totals. The team celebrates. The first restart repeats postings because the target has no checkpoint matching the legacy step boundary. A file containing a blank account suffix sorts differently, which changes grouping. A single bad row now rolls back valid work that the old chain would post. None of these errors looks like a syntax defect or an obviously foolish design.

A planned extraction would have caught them in order. Boundary capture records the supplied business date and return codes. Data analysis records collation and blank padding. Orchestration analysis records commit and restart points. Replay includes ordinary, rejected, and resumed runs. The target architecture can still improve the implementation, but it cannot erase those contracts by accident.

The popular response is to make the prompt longer. That helps only when omission is the sole problem. It makes conflict resolution harder, buries which evidence supported which instruction, and still lets the generator judge its own work. Breaking the prompt into agents does not fix this by itself either. Multiple generators need the same ledger, ownership rules, and parity gates or they merely distribute untracked assumptions faster.

Use generation as a bounded implementation operation. Give it one target slice, explicit constraints, the evidence needed for that slice, and failing parity output from the previous attempt. Then inspect the change and run the gate outside the generation process. That division keeps the model productive without granting it authority it cannot responsibly hold.

The engineer owns the unresolved parts

An engineer does the work that cannot be reduced to producing plausible code: finding missing evidence, deciding which contradiction matters, choosing a boundary, negotiating deliberate changes, and accepting risk. These are not temporary gaps that disappear when models get larger. They are acts of responsibility inside a particular organization.

The engineer asks who suffers when two artifacts disagree. If source code permits an overdraw but finance policy forbids it, the answer needs a product and compliance decision, not a probability-weighted synthesis. If operators rely on a restart trick that the new design should eliminate, the engineer must understand why it exists, design a safer recovery path, and obtain agreement that compatibility will intentionally break.

The engineer also controls decomposition. A generator tends to follow the structure it sees. The engineer can recognize that six programs form one consistency boundary, or that one monolith contains four independently owned capabilities. That judgment comes from transaction semantics, deployment needs, incident history, and the people who will maintain the target.

Review should focus on claims and effects before style. Ask which ledger item the change closes, which evidence it uses, which target decision it embodies, and what the parity report says. A beautifully idiomatic service with an unexplained output difference is unfinished. An awkward adapter that preserves a difficult boundary may be exactly the right temporary component.

Engineers must also protect the harness from becoming a rubber stamp. Every normalization rule needs a reason. Every golden file needs provenance. Every changed expected result needs the same review as a production behavior change. If the team updates snapshots whenever a test fails, it has built an approval machine, not a parity harness.

There is still room for speed. Generate parsers, adapters, tests, mapping code, and implementation candidates in parallel when their ledger dependencies permit it. Keep evidence acquisition and gate results centralized. Parallel code production is useful; parallel truth is a contradiction.

Architecture changes only behind proven contracts

Test the awkward production cases
A parity harness compares the rewrite against recorded traffic, including behavior ordinary fixtures miss.

Modernization should change architecture behind preserved contracts, not translate the old structure line by line. Once a boundary has evidence and a parity gate, the team can replace shared state with explicit services, isolate numeric kernels, move data into Postgres, or build a TypeScript client without guessing whether the new shape changed user-visible behavior.

Decide architecture at the smallest level where you have enough evidence. Some decisions belong at the start: target runtime constraints, security boundaries, deployment environment, data residency, and which systems must coexist during cutover. Other decisions should wait: service splits, cache placement, asynchronous boundaries, and schema cleanup often depend on behavior discovered during extraction.

Avoid two extremes. Freezing every target detail before discovery produces an elegant plan for an imagined system. Letting each generation invent architecture produces inconsistent boundaries and duplicated infrastructure. Record binding decisions, keep deferred decisions visible, and state what evidence will close them.

Cutover planning belongs in the same ledger. Name the source of truth during transition, synchronization direction, reconciliation query, rollback point, and maximum accepted interruption. A replacement that passes isolated parity tests can still fail operationally if both systems write the same records or if rollback cannot recover target-only changes.

CodeHero uses this shape of work when it reads a whole legacy tree, rewrites it into Go, Rust, TypeScript, and Postgres, and checks behavior with a parity harness against recorded production traffic. Its under-30-day delivery promise depends on ordering extraction and verification tightly; it does not make the plan optional.

Before approving the first generated target file, demand one completed ledger item with real evidence, explicit dependencies, a comparison rule, and a named owner for differences. If the team cannot produce that item, it is not ready to migrate. It is ready only to generate code that looks migrated.

FAQ

Why can’t one long prompt migrate a legacy system?

A long prompt can generate a coherent codebase, but it cannot establish which evidence is authoritative or approve conflicts among code, operations, and policy. It also cannot provide an independent compatibility judgment when it evaluates its own output.

What should a legacy migration plan contain?

For every behavioral boundary, record its evidence, dependencies, target location, invariants, allowed differences, verification gate, and decision owner. The plan should also define stop conditions so missing evidence or unexplained differences block generation.

What should a team extract first from legacy code?

Start with observable inputs, outputs, scheduled events, operator actions, persistent effects, and failure signals. Then extract data semantics and orchestration before implementing internal algorithms, because those boundaries give later work a contract.

How do you verify a legacy system rewrite?

Replay the same stimulus against old and new systems from equivalent state, then compare every observable effect under written normalization rules. Classify every difference as a defect, an approved change, an evidence problem, or an environment problem.

Is unit testing enough for a legacy migration?

No. Unit tests show that target code behaves as its authors expect, while parity tests show whether it matches the system being replaced. You need both because a clean implementation can faithfully implement the wrong assumption.

Can AI agents plan a complete code migration?

Agents can help inventory code, propose mappings, generate bounded slices, and investigate failing tests. Engineers still own evidence quality, dependency order, architecture decisions, conflict resolution, and acceptance of deliberate behavior changes.

Should a modernization preserve every legacy behavior?

No, but every difference should be deliberate. Preserve the old path in a parity test, record the approved change separately, and test the new rule so a desired cleanup cannot hide an unrelated compatibility defect.

How do you handle nondeterministic fields in parity tests?

Write a narrow comparison rule, such as requiring an identifier to exist without requiring an exact value or allowing a documented timestamp tolerance. Never use a broad ignore list, because it will conceal meaningful differences elsewhere.

When should target architecture be decided?

Set hard constraints and security boundaries early, then defer choices that depend on discovered behavior. Decide service splits, data cleanup, and asynchronous flows only when evidence shows the consistency and ownership boundaries they must respect.

What is the first gate before generating migration code?

Complete one ledger item with real input and output evidence, known dependencies, explicit invariants, comparison rules, and an owner for disputed results. If that record is incomplete, the team should continue extraction instead of generating production code.