Removing a framework from a live enterprise application, one route at a time
A case study in incremental legacy modernisation — method, guardrails, and what shipped.
Completed 2026-08-29. 667 routes, 68 days, 1,078 numbered migration commits, no rewrite.
A large, long-lived enterprise Java web application in logistics and cross-border cargo handling. Hundreds of Struts 2 .do actions, a Hibernate 3 data layer, an Ext JS front end, JSP views, and a Spring Security configuration grown over years. Real users on it every working day.
The framework was long past support and blocking every dependency upgrade behind it. The obvious answer — rewrite it — was not available: there was no second team to run the old system while a new one was built, no downtime budget, and no complete specification of what the application actually did. The behaviour was the specification, and it only existed in the code.
So the constraint was set the other way round: the application stays live and keeps behaving identically, while the framework underneath it is replaced one route at a time.
“Preserve behaviour” is uselessly vague until you write down what is observable. For this migration the contract was explicit, and every one of these had to survive a route move:
text/html, quirks intactModernising Spring itself, the Java language level, and the container was separate, later work. Folding it in would have made every regression ambiguous — was it the route move, or the upgrade? The target was the Spring MVC 3.2 baseline already present in the application, not the Spring of the day.
The migration never moved “a controller”. It moved a route together with everything that calls it: the JSP, every Ext JS caller, any external consumer, and the tests. A route migrated without its callers is a route with two implementations and no owner.
Each slice ran in a fixed order:
Slices were size-capped on purpose: at most eight read-only JSON endpoints, or five for anything that changes state, uploads, downloads, renders a JSP, or touches security. A slice small enough to hold in your head is a slice you can prove.
Old URLs cannot simply disappear — bookmarks, cached JavaScript, integrations, and clients nobody has an inventory of still call them. The usual reflex is an HTTP redirect. That is the wrong tool: a redirect can change the method, drop the body, re-run authentication, and alter the status code the caller sees. Any of those is a behaviour change wearing a compatibility costume.
Instead, a registry mapped each legacy path — and, where an action multiplexed on a selector parameter, each individual operation — to an internal server-side forward reaching the same Spring-native implementation the canonical route used. One implementation, two doors. The caller could not tell which door it came through.
An alias stayed active while any registered client was still pending, and retired on evidence — an observation window with no traffic — rather than on a date in a plan.
The whole layer was scaffolding by design. It grew to 815 active aliases at its peak and came down to zero at cutover; the registry file no longer exists in the repository.
A migrated route had to be Spring-native end to end. A Spring controller that reaches back into a Struts action bean has not migrated anything; it has added a layer. That rule was enforced by contract tests, not by good intentions.
At this scale the hard problem is not moving a route. It is knowing, six weeks later, which routes really moved, which are half-moved, and what evidence exists for either. Memory and commit messages do not survive that.
Every route has a record: legacy pattern, canonical path, compatibility mode, request contract, response contract, security, side effects, the exact client files updated, the tests that cover it, and — the field that matters most — what remains unverified.
routes:
- id: customer-search
legacy: { method: POST, path: /customer/search.do }
canonical: { method: POST, path: /rest/customers/search }
compatibility:
mode: server-side-alias
legacy_route_active: true
contract:
breaking: false
request_changes: none
response_changes: none
authentication: existing-session
backend_status: migrated
clients:
- { name: customer-jsp, status: migrated }
- { name: reporting-client, status: pending }
Status is a closed vocabulary, not free text, so progress cannot be inflated by adjective:
| Status | Meaning |
|---|---|
inventory-pending | Known, not yet characterized |
partially-implemented | Some operations migrated, others pending |
implemented | Spring owns the route; ownership verified in source |
implemented-verified | … and focused tests cover it |
implemented-live-verified | … and it has been verified in a running instance |
retired | The legacy compatibility alias has been removed |
A route may not sit at a terminal status while evidence is outstanding. Anything unproven stays visible in a non-terminal status or an explicit remaining field. The catalog is allowed to say the work is unfinished — that is what makes it worth reading.
This was not free. A mid-project audit found the vocabulary had quietly collapsed: roughly 180 distinct status: values had crept into the records. The fix was to enforce the six permitted values in a validator script rather than in a document — the only place a rule survives contact with six hundred files.
Whoever implements a slice is the worst available judge of whether it works. So acceptance was a distinct step with its own rules, run against the running application rather than the diff: focused review against project rules, the smallest useful build and test set, HTTP verification of every operation, browser end-to-end checks where UI integration mattered, and persistence checks for anything that mutates state.
It returns exactly one of three outcomes, and it is forbidden from touching source code to improve its own result:
| Outcome | Meaning |
|---|---|
PASS | The migrated functionality demonstrably works from the API, client, or user perspective. |
FAIL | It does not. Blocking; goes back to implementation. |
INCOMPLETE | It could not be verified — missing runtime access, an external system, credentials. Aliases stay, the slice stays runtime-pending. |
INCOMPLETE is the load-bearing one. Without it every unverifiable check quietly becomes a pass, and the catalog turns into fiction. “We could not check this” is not “this is fine”.
The “was” column is the project’s own baseline, captured on 2026-07-15 before the first slice moved. The “became” column is the state at the B3.986 cutover on 2026-08-29.
.do callers| Dimension | Was — 2026-07-15 | Became — 2026-08-29 |
|---|---|---|
| Request dispatch | CORS → MDC → Spring Security → Struts filter on /* → Spring MVC on /rest/* | CORS → MDC → Spring Security → Spring MVC on /rest/* |
| Struts action mappings | 180 across 45 packages, 50 of them wildcard | 0 |
| Struts configuration files | 3 | 0 |
| Java action classes | 158 | 0 — the package is deleted |
| Spring controllers | 1 | 466, carrying 468 @Controller and 632 @RequestMapping declarations |
Authored .do calls in Ext JS and JSP | 163 | 0 |
| JSPs using Struts tags | 19 | 0 |
| Struts, XWork, OGNL, FreeMarker and Velocity JARs | 13 — including four concurrent struts2-core versions | 0, and 0 of the 25,575 entries in the packaged WAR |
| Compatibility aliases | — the mechanism did not exist yet | 815 at peak, then 0 |
| Java test classes | 0 | 1,710, green on Java 8 in CI |
| Build | Ant and Eclipse over a 242-JAR vendored lib/ | Maven |
| Route catalog | 174 routes in the first generated inventory | 667 records, every one terminal |
The compatibility layer has no “was” value because it did not exist before the migration. It was built during it, peaked at 815 active aliases, and was dismantled completely at cutover — scaffolding, not architecture. The catalog grew from 174 to 667 for the same reason the work was tractable at all: a single wildcard action turned out to be many distinct operations, and each one needed its own contract before it could be moved.
All 667 routes cleared the final retirement gate: no pending compatibility evidence, no active aliases, a packaged WAR with no Struts or XWork classes, and a green regression run at cutover. The gate is a script rather than a judgement, so it prints its counters next to its verdict and returns an exit code either way:
STRUTS_FILTER_PRESENT=false WAR_ENTRIES=25575
ACTIVE_STRUTS_ACTION_MAPPINGS=0 WAR_STRUTS_XWORK_JARS=0
ACTIVE_COMPATIBILITY_ALIASES=0 WAR_STRUTS_XWORK_CLASSES=0
AUTHORED_LEGACY_CALL_LINES=0 WAR_ACTION_CLASSES=0
PENDING_ROUTE_EVIDENCE=0 WAR_STRUTS_FILTER_PRESENT=false
STRUTS_LIVE_FINAL_GATE=PASS
STRUTS_SURFACE_RETIREMENT_GATE=PASS
GATE_EXIT=0
Regression at cutover: OK (1733 tests). The full Java 8 suite now runs in CI as a delivery gate rather than as a migration artefact. Retired .do paths return 404 to anonymous and authenticated callers alike, and the canonical /rest/system/ping returns a body byte-identical to the 56 bytes the old /Ping.do returned — a last, small check of the contract this whole exercise existed to preserve.
The most instructive moment was a refusal. The analyser that had to confirm no client was still calling the legacy paths returned VERDICT=INCOMPLETE_SOURCE instead of PASS. It had seen zero compatibility events and zero legacy requests — but only from an idle local instance, and its own rule is that a local-idle window is not evidence about production.
The way past it was not to relax the rule. It was an explicit human attestation about who the remaining consumers actually were, encoded so the gate could check it rather than take it on trust:
RETIREMENT_EVIDENCE_SOURCE=external-consumer
RETIREMENT_EVIDENCE_WINDOW_HOURS=24.00
RETIREMENT_EVIDENCE_COMPATIBILITY_EVENTS=0
RETIREMENT_EVIDENCE_LEGACY_DO_REQUESTS=0
RETIREMENT_EVIDENCE_VERDICT=PASS_ZERO_LEGACY_TRAFFIC
A gate that can only pass is a decoration. This one could block the thing everybody wanted, and did.
Figures are the recorded B3.986 cutover evidence, frozen on completion. The application predates its current version-control history — it lived in Subversion for years before this work began — so commit counts here measure the migration, not the codebase.
A method applied six hundred times has to live somewhere other than one person’s head. Held informally it drifts: a slice gets migrated without its callers, a status gets rounded up, a verification step gets skipped because the previous route did not need it. All three happened, and the audit that caught them is why the rules ended up in scripts.
So the method was written down as agent skills — structured instruction documents an AI coding agent loads when a task matches, carrying the workflow, the task modes, and the guardrails with it. The migration workflow, the quality gate, the Hibernate Criteria conversion, test design for Java and Angular, and production logging are six of them.
The application is client-owned and private. The method is not, and it generalises: the same slice-and-catalog discipline applies to any framework migration where the old system has to stay up.