Modernizing legacy code with AI: knowledge base first, code second

Asking an agent to rewrite a legacy app does not work. Our method: document the system, fix the exit invariants and reimplement aggregate by aggregate.

Cover for Modernizing legacy code with AI: knowledge base first, code second
Updated: Oct 31, 2026

When we modernize a legacy application with an agent, the new version does not get a single line of code until the old system has been described in a folder of markdown files and someone on the team has read that description and signed it off.

The opposite temptation is huge. An agent like Claude Code reads a whole repository and produces the Kotlin equivalent of a ten-year-old controller in minutes. Typing “migrate this to Spring Boot” and watching files appear is very convincing. What doesn’t appear goes unnoticed until someone in production misses a validation that was hidden in a form event.

On the team we maintain a Claude Code plugin with skills and commands (I describe it in how to organize a team plugin). One of its pieces is a legacy modernization command, backed by a skill we distilled from modernizing several real applications that integrate with educational platforms. Here I explain the method it encodes, why it is ordered the way it is and where it still falls short.

What happens when you just ask the agent to rewrite it

A team from the Universidade Federal de Minas Gerais and Ontario Tech published a case study on migrating a Visual Basic 6 ERP to C# .NET 10 with Claude Code (the PDF is distributed as a SCAM 2026 paper). The ERP has been in production for more than twenty years and serves over 200 customers. They migrated 12 features from two modules using a single-shot generation strategy: the agent receives a standard prompt, performs the full migration and nobody corrects it during the session.

They didn’t hand it the raw code: each working directory had a CLAUDE.md with goal, scope and conventions, another one describing the VB6 code and a third with the target architecture. With that context, these were the numbers:

  • An average equivalence of 70% between the migrated code and the original, measured against 331 instructions catalogued before starting (business rules and database operations).
  • A sharp asymmetry by complexity: 92% for simple features, 81% for medium ones and 47% for complex ones.
  • Low cost: $50.29 in total, about $0.15 per migrated instruction, and under five minutes per feature on average.

The interesting part is the failure analysis. Most failures are not badly written code but missing code. The agent left out 42 “adjacent modules” (batch imports, reports, email notifications), 22 database operations and 20 specific rules. In a purchase request feature, the migrated version always created the request in an open state and never sent the email that the hierarchical approval depended on: the code compiled and ran, but it implemented a different process.

The authors point to two causes. The first is fragmentation: when a rule is spread across several files, the agent loses it, “even when the files are provided in full”. The second is that the agent migrates explicit rules well and ignores implicit ones: an IdMarca > 0 filter written in the form that loads the list of brands, a boolean parameter that excludes one type of consumption, a global variable loaded from the database at startup. Their recommendation is not to leave the inference of important constraints to the agent, and to write them down as clearly and explicitly as possible.

The other case that usually gets cited points in the same direction, with less rigor. Peter Winston, CEO of ICS, describes in Embedded Computing Design how three developers spent two weeks trying to port Motif code to Qt 6 with Claude Code. All three got 80-90% of the functionality very quickly and none of them finished on time. The agent tended to generate Qt 5 patterns, because there is far more public code for that version, and errors piled up if they weren’t caught early. The one who automated the most (around 95%, on sample applications with little business logic, as the author himself acknowledges) followed an ordered process: document the code, create tests, port and validate.

Neither case says AI is useless for modernization. What they show is an agent that is fast and cheap on the visible part of the system and unreliable on the part nobody ever wrote down. Our method comes from taking that seriously.

The method in four phases

The command takes the path of the legacy project and goes through four phases in order, with two stops where the agent waits for human approval. Four rules govern it: document before coding, ask instead of assuming, preserve the original functionality unless agreed otherwise, and record every decision in the documentation.

The second one looks obvious, but it goes against a model’s natural inclination, which is to fill a gap with something plausible rather than admit it doesn’t know why a column is called flag2.

Phase 1: the legacy knowledge base

Before touching anything, the agent creates a references folder inside the legacy project and documents the current system in separate markdown files, starting from blank templates that ship with the skill. Each file covers one aspect: architecture and execution flow, data model, exposed API and its authorization, domain entities, external integrations, security, deployment infrastructure and, finally, known issues. If the domain calls for it, more are added, for example one dedicated to the LTI launch flow.

Splitting it this way means that, during the reimplementation, the agent can read only the API part when working on a controller, or only the integrations part when touching an adapter, without loading the whole system into context. It is the same router-with-references pattern we use in skills, applied to the project. It also spreads the human review: whoever knows the integrations reviews that file, and whoever knows the data model reviews theirs.

The document that adds the most value is the known issues one. It collects bugs, technical debt and migration risks, and it has a section for decisions that need to be confirmed by a person. That section puts in writing exactly what the UFMG study found the agent skips. When the agent comes across an odd filter in a form, or behavior that depends on a global variable, it doesn’t have to decide whether it is a business rule or an accident: it writes it down as a question. Someone on the team answers, and the answer is documented for the next phase.

An implicit rule in the code thus becomes explicit in a document the agent will read when it reimplements that part. That doesn’t guarantee it will respect it, but it turns a silent failure into something you can check. At the end of the phase, the agent stops and asks for validation: this is the first gate.

In that review, what deserves the most attention is what the UFMG study found gets lost: batch imports, reports, notifications and integrations that are not part of the main flow, plus rules the agent describes with more confidence than the code allows. An architecture document that mentions no scheduled process in a ten-year-old system should raise suspicion rather than confidence.

Phase 2: target stack and invariants

The second phase sets the destination. By default the command brings the stack the team works with (Kotlin, Spring with reactive WebFlux, DDD with domain, application and infrastructure layers, an API documented with OpenAPI and local startup with docker-compose), and any deviation has to be asked about.

If you don’t pin the stack, the agent picks whatever it has seen most in training (it happened to the ICS team with Qt 5). Pinning it also makes all the new versions look alike, which is what you want when the same team is going to maintain all of them.

The most useful part of this phase is the exit invariants: conditions the v2 has to meet before the work counts as done, checked on every iteration of the loop. We have five:

  1. Multitenancy, if the legacy wasn’t multitenant.
  2. Up-to-date dependencies and a green build.
  3. Separate modules if the legacy runs scheduled tasks.
  4. Architecture guardrails active as tests.
  5. Consistent documentation for both the legacy and the v2, with local startup and a documented API.

The first one changes the design the most. Our applications integrate with educational platforms through LTI 1.3, and a single installation serves several customers. In LTI, each customer is identified by the combination of issuer, client_id and deployment_id. Many older applications were born for a single customer and were replicated or configured per instance. The invariant forces the v2 to have one registration per customer with that key, to keep configuration, credentials and branding isolated per customer, and to carry the tenant identifier through the domain, the use cases and the batch processes, instead of resolving it halfway or accepting it as a free parameter in a request.

What makes this work with an agent is phrasing each invariant as something you can check in the code. “Make it multitenant” is an intention. “Every repository query on business data receives the tenant identifier as a parameter” is something you can review in a diff or turn into an automated rule.

The third invariant follows the same reasoning: if the legacy has scheduled tasks, the v2 splits the API and the jobs into separate modules, and each job runs as an independent process per customer, so a heavy task for one customer doesn’t share a process with another customer’s API.

We use invariants rather than a task list because an agent declares victory far too easily. With “migrate the endpoints” as the criterion, it migrates the endpoints and calls it done. With “these five conditions green”, there is something to compare against on every iteration, and almost all of it is verified with a tool instead of taking the agent’s word for it.

Phase 3: the plan, with the legacy still in production

The third phase produces a roadmap: the order of bounded contexts and aggregates, what gets rewritten and what gets reused, the risks and how functional parity will be validated. The guiding principle is that the legacy stays in production throughout the migration. There is no big bang: each phase is deployed separately.

It includes a gap matrix between legacy and v2, feature by feature, the design differences accepted as improvements and the model changes that require migrating data (for example, the new customer reference in every table), which is the list that prevents surprises at cutover. Alongside it go a rollback plan and a zero-downtime deployment plan.

Here is the second gate. The agent proposes the plan and waits for approval before generating code. If this sounds like writing a spec before implementing, that’s because it is: the knowledge base and the roadmap work as the v2 specification, only extracted from a system that already exists instead of written from scratch.

Phase 4: the per-aggregate loop

Once the plan is approved, the agent works in a new project (the legacy is not touched) and repeats the same loop for each aggregate on the roadmap:

  1. Extract the aggregate into three layers: a framework-free domain with its ports, an application layer with the use cases, and infrastructure with adapters and thin controllers.
  2. Write domain tests without Spring, application tests with mocked ports and integration tests with Testcontainers.
  3. Verify functional parity against what was documented about the legacy.
  4. Pass the quality gate: formatting, tests (architecture tests included) and coverage threshold.
  5. Document the decision in the v2 folder and tick the box on the roadmap.

Working per aggregate responds to the other finding of the UFMG study: the agent performs much better with small, well-bounded pieces. As future work, the authors propose breaking complex features down before migrating, which is what the loop does by design.

The fourth step is what holds it together without relying on the agent’s memory. The gate runs in a fix loop until it is green, and the architecture guardrails are tests that run with the rest of the suite. With Konsist, a layering rule looks like this (an illustrative example with a made-up package):

@Test
fun `domain does not depend on other layers`() {
    Konsist.scopeFromProject().assertArchitecture {
        val domain = Layer("Domain", "com.example.app..domain..")
        val application = Layer("Application", "com.example.app..application..")
        val infrastructure = Layer("Infrastructure", "com.example.app..infrastructure..")

        domain.dependsOnNothing()
        application.dependsOn(domain)
        infrastructure.dependsOn(domain, application)
    }
}

If the agent introduces a Spring dependency in the domain, the test fails and the gate doesn’t pass, without depending on whether it remembers the rule it read forty files ago.

The fifth step feeds a log of the reimplementation: how to run it locally, the migrated modules with their parity and gate status, the decisions and their reasons, and the outstanding debt. It resembles an architecture decision record, except the agent writes it as it works and a person reviews it. If the person leading the migration changes, or a new session starts without the previous context, that document lets you carry on.

Why the order matters more than the prompt

Taken as a whole, the method doesn’t do anything a senior developer wasn’t already doing in a manual migration. What changes with an agent is the price of skipping phases: they stop being a good habit you drop when you’re in a hurry and become the only thing standing between you and delivering 70% of a system that looks like 100%.

The agent is very good at generating code and rather bad at knowing which code is missing. Phases 1 and 3 exist so that what’s missing becomes visible before anything is generated, and the invariants and the gate exist so that what gets generated doesn’t degrade as the loop moves forward. Switching models or tuning the rewrite prompt barely moves that equation; changing the order of the work moves it a lot.

That’s why we packaged it as a command with a skill behind it rather than as a wiki page. The command enforces the order and the stops; the skill carries the detail (DDD patterns with examples, the multitenant model, the job separation, the testing strategy) and only loads when needed; the templates make every migration produce the same documentation. Turning what we learned across several migrations into that skill deserves its own article: how to distill a team’s knowledge into skills.

What the method doesn’t solve

Before copying it, you should know where it is weak.

Functional parity is verified against the legacy documentation, not against the running legacy. If the knowledge base leaves out a rule and nobody spots it in the review, the v2 will leave it out too, and step 3 of the loop will accept it. The method moves the risk from the code to the documentation, where it is easier to review, but it doesn’t remove it. What would close the gap is capturing the real behavior of the legacy (requests and responses, database effects) and replaying it against the v2 as characterization tests. That is not part of the method today.

Human review is the bottleneck, and it should be. The two approval gates only work if whoever approves knows the system; if nobody remembers why a flow exists, the knowledge base will describe it and nobody will be able to confirm whether it is a rule or an accident.

The cost moves to the beginning. Phases 1 to 3 produce no deployable code, and anyone expecting to see progress in the form of endpoints perceives them as wasted time. The UFMG study shows that generating code is cheap; the expensive part is deciding which code needs to exist.

What makes up for that cost I can’t express in timelines or figures, but I can in what remains afterwards. Modernizations done this way have given us cohesion across our projects, because every v2 shares the same stack, invariants and way of being documented; security layers the legacy apps didn’t have; and a structure that also lets us build automations on top of it.

If you are going to modernize a legacy system with an agent

I would start with the known issues list: ask the agent to go through the code and write down as a question everything it doesn’t understand, without trying to resolve it. That list will tell you how much implicit knowledge the system holds and how much a direct rewrite would lose.

Then write the exit invariants as checkable conditions and turn as many of them as you can into tests. An agent respects what breaks its build far better than what you ask for in the prompt.

And work per aggregate, with the gate green before moving on to the next one. It’s slower than asking for the whole migration in one go, and it’s the only way I know to tell, at any moment, which part of the new system is really finished.

If you want to see how this command fits with the rest of the team’s skills, the pillar article on agent skills gives the overall picture.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved