Spring Boot end of life: what the upgrade costs

Two Java estates reported the same finding: an unsupported Spring version. For one, getting current was weeks of mechanical work. The other had already done half the job and stalled on the two services that matter most to it. Same signal, different work, and the version number distinguished them not at all.

That finding is easy to come by. Dependabot raises it, Snyk and OWASP Dependency-Check raise it, endoflife.date will tell you the same thing in a browser tab. None of them is wrong. They answer “which version are you on, and does it have advisories against it”, accurately and cheaply, and that is a different question from “what does it cost to move”. Nothing in a dependency list can answer the second one, because the facts that decide it are in your own code.

Below are the two estates, what separated them, and the five things worth checking. The last section is a handful of shell commands that will place your own codebase in about ten minutes. Both estates were assessed from their public repositories alone, and neither is named, for reasons set out at the end.

Two estates, one finding

EstateWhat the tools reportedWhat it was
Estate ASpring Boot 2.7.x, out of OSS support since 2023Weeks. Mechanical, and well covered by tests
Estate BSix Boot versions, none supportedHalf-done already, and the remainder is straightforward

Estate A: further behind, and a mechanical upgrade

Over 600,000 lines across some 6,600 Java files and more than fifty modules, on Spring Boot 2.7.x. By the version number this was the worse of the two, because it was further behind.

It carried roughly 1,700 test classes and over 9,000 test methods, about one test class for every three production classes. There was one dependency with no jakarta build, a Swagger integration abandoned in 2020, but it touched a single file and had a drop-in replacement. Nothing structural lacked a successor. The upgrade was weeks of mechanical work, and the real content was verifying the HQL and reconfiguring Spring Security rather than the rename itself.

Estate B: half-migrated, with the largest services remaining

Nine services, six Spring Boot versions, none of them supported. Part of the organisation had crossed the jakarta boundary and part had not: four services were on Boot 3 and past it, and the two that were not were the largest and the most actively developed.

Migrations stall for entirely ordinary reasons — competing priorities, a shared library that has to move first, a team that moved on. The published source shows the state, not the cause, and the state was better than it looked. The remaining javax references numbered in the low hundreds and every one was an import line, with no fully-qualified references inline in code, which is the difference between an OpenRewrite recipe and a hand edit. Neither service used WebSecurityConfigurerAdapter, the most disruptive removal in Spring Security 6. Between them they had a few dozen entities and over a thousand test methods: a small, mechanical migration with good test coverage.

The third case: when no forward version exists

Neither estate was the case that changes a plan rather than a sprint. Some blockers have no forward version at all. Struts 1 was last released by Apache in December 2008; Axis 1 stopped around the same time. An estate whose web tier is built on one of those cannot follow Spring 6 across the javax to jakarta rename, because there is nothing on the other side to move to. That is not an upgrade that needs scheduling, it is a replacement that needs funding, and no version number anywhere in the dependency list says so.

It is worth being specific about the tool that does attempt this work. OpenRewrite has recipes for exactly this migration, and on both estates above they would do most of it. Pointed at an estate in that third category they would rewrite what they can and stop, because there is nothing to rewrite towards. The recipe is not the missing piece. Knowing in advance whether the recipe can finish is.

What each verdict means for a plan

The three verdicts justify three different decisions. One is a scheduled piece of maintenance. One is a couple of sprints that stops an internal split getting worse. The third is a funded programme that will not fit in a maintenance window and should never have been promised as an upgrade.

Committing to the wrong one is expensive in both directions. Treating a replacement as a version bump means a migration that stalls halfway with the estate split across two namespaces. Treating a mechanical upgrade as a rewrite means asking for a budget you did not need. Which is why it is worth ten minutes to find out which one you are.

What decides the cost

Across these assessments the version number was the least informative fact available. These mattered more:

  • Does a forward version exist for your blockers? For most libraries this is a non-question. For the long tail of things that stopped in the 2000s the answer is no, and it changes the shape of the whole programme rather than its size.
  • How entangled is the blocker? “We use Struts” and “Struts is a third of our Java files” are different statements with different budgets attached.
  • Import lines or inline references? A javax import is a one-line automated change; a fully-qualified reference in a method body is not. The ratio decides whether the rename is a recipe or a hand edit.
  • Test coverage where the change lands? This decides whether the migration is verifiable or a matter of hoping.
  • Do the artifact names match their namespaces? An artifact named jakarta.* is not necessarily migrated: Jakarta EE 8 releases carry jakarta coordinates and javax packages. Read from a dependency list alone, they count as already done.

Checking your own estate

None of this needs a tool. From the root of a Java or Kotlin repository:

# Only these javax packages moved to jakarta. javax.sql, javax.crypto, javax.tools and
# the rest of Java SE keep their names, so counting a bare "javax." tells you almost
# nothing: on one of our own already-migrated repositories it matches 17 files, of which
# 10 are Java SE packages that are never going to move.
EE='javax\.(servlet|persistence|validation|annotation|ws\.rs|inject|transaction|jms|mail|faces|ejb|xml\.bind)'
j() { grep -rE --include='*.java' --include='*.kt' --exclude-dir=build --exclude-dir=target --exclude-dir=out "$@" .; }

# Files to migrate, and how many import lines that is
j -l "$EE" | wc -l
j -h "^[[:space:]]*import( static)? $EE" | wc -l

# Fully-qualified references outside import lines: the expensive ones. Comments and string
# literals land here too, so read the hits rather than trusting the count.
j -n "$EE" | grep -vE ':[[:space:]]*import ' | wc -l

# The most disruptive removal in Spring Security 6
j -l 'WebSecurityConfigurerAdapter' | wc -l

# Mapping surface, and the HQL that Hibernate 6's parser rewrite actually bites
j -ho '@Entity\b' | wc -l
j -ho '@Query\b' | wc -l

# The safety net
j -ho '@Test\b' | wc -l

The word boundaries matter more than they look. Without them @Entity also counts @EntityListeners and @EntityScan, @Test counts @TestConfiguration and @TestPropertySource, and @Query counts every @QueryParam in a JAX-RS codebase, which can be an order of magnitude on its own.

Then find your blockers. Anything that uses the javax EE APIs and has no release after the rename cannot come with you; libraries that never touched those APIs are unaffected, however old they are. Those counts turn into a verdict in a fixed order, and the first question settles the shape of the work before any of the others matter:

A sequence of four checks. If every blocker has a forward version, if the javax references are all import lines, and if there are tests where the change lands, the work is a mechanical upgrade; a no at any step diverts to a larger piece of work. Does every blocker have a forward version? javax EE APIs with no release since the rename No — a replacement to fund, not an upgrade Are the javax references all import lines? compare the import count with the outside-imports count Inline refs — hand edits on top of the recipe Are there tests where the change lands? @Test near your @Entity and @Query surface Thin — write tests before you migrate All yes — a mechanical upgrade. Schedule it.
What the counts mean. The first question decides the shape of the work; the rest only decide its size.

What this does not tell you

Whether any of it is exploitable. An out-of-support version accumulates advisories, and a count of advisories is not a count of exposures: a CVE in a code path your application never calls is a different risk from one in a path it runs on every request. Separating them needs the built artefact and a call graph rather than a dependency list. It is worth doing before an advisory count is used to set the timeline, because it usually shortens the urgent list considerably.

Why these estates are unnamed

The figures above are rounded deliberately. Their source is public, but publishing a named organisation’s unpatched version list is a different act from describing the pattern, and precise counts are what turn a description into an identification. Neither estate is a client, neither was asked for anything, and nothing here is a criticism of the teams who run them: both showed sound engineering, and one of them had already done most of a migration that plenty of estates have not started. The method is described in full above and it is the same either way, which is rather the point — you can run it on your own code this afternoon.

We do this for a living: see what we do, or how we modernise legacy systems without a big-bang rewrite. If you would like a second opinion on your own estate, get in touch.

Have a big idea to build, a problem to solve, or a system that’s overdue an upgrade?

Get in touch