← Viachaslau Strelnikau — CV

Struts 2 → Spring

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.

The skills this produced

The problem

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.

What “identical” had to mean

“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:

Modernising 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 unit of work: a functional slice

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.

Compatibility forwards, not redirects

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.

Making progress auditable

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:

StatusMeaning
inventory-pendingKnown, not yet characterized
partially-implementedSome operations migrated, others pending
implementedSpring 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
retiredThe 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.

Verification as a separate role

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:

OutcomeMeaning
PASSThe migrated functionality demonstrably works from the API, client, or user perspective.
FAILIt does not. Blocking; goes back to implementation.
INCOMPLETEIt 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”.

Was → became

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.

158→0Struts action classes
815→0compatibility aliases
163→0authored .do callers
174→667routes catalogued
DimensionWas — 2026-07-15Became — 2026-08-29
Request dispatchCORS → MDC → Spring Security → Struts filter on /* → Spring MVC on /rest/*CORS → MDC → Spring Security → Spring MVC on /rest/*
Struts action mappings180 across 45 packages, 50 of them wildcard0
Struts configuration files30
Java action classes1580 — the package is deleted
Spring controllers1466, carrying 468 @Controller and 632 @RequestMapping declarations
Authored .do calls in Ext JS and JSP1630
JSPs using Struts tags190
Struts, XWork, OGNL, FreeMarker and Velocity JARs13 — including four concurrent struts2-core versions0, and 0 of the 25,575 entries in the packaged WAR
Compatibility aliases— the mechanism did not exist yet815 at peak, then 0
Java test classes01,710, green on Java 8 in CI
BuildAnt and Eclipse over a 242-JAR vendored lib/Maven
Route catalog174 routes in the first generated inventory667 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.

How it ended

667routes, all Spring-owned
0legacy action classes
0active compatibility aliases
0Struts classes in the WAR

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.

Why this became a public repository

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.

View agent-skills on GitHub Back to CV