Writing · Java
Spring Boot 2.7 End of Life: What the Upgrade to 3 Costs
What decides the cost, and how to check your own estate.
Two Java estates reported the same finding: an unsupported Spring version. One needed weeks of mechanical work to get current. The other had already done half the job and stalled on its largest services. The version number did not tell them apart.
The short answer. The version number tells you that you are behind. It does not tell you what catching up will cost. Five things in your own code decide that: whether your blocking libraries have a forward version, how entangled they are, whether javax references are import lines or inline in method bodies, how well tested the changing code is, and whether artifacts named jakarta.* really use the jakarta packages.
Dependabot, Snyk, OWASP Dependency-Check and endoflife.date all report which version you are on and its advisories, accurately and cheaply. What it costs to move is a separate question, and the answer to it is in your code, where a dependency list cannot see it.
Below are the two estates, what separated them, the five checks, and the shell commands that place your own codebase in about ten minutes. Both estates were assessed from their public repositories, and neither is named (the reason is at the end).
Two estates, one finding
| Estate | What the tools reported | What it was |
|---|---|---|
| Estate A | Spring Boot 2.7.x, out of OSS support since 2023 | Weeks. Mechanical, and well covered by tests |
| Estate B | Several Boot versions, none supported | Half-done already, and the remainder is straightforward |
Estate A: further behind, and a mechanical upgrade
Several hundred thousand lines of Java across dozens of modules, on Spring Boot 2.7.x: by version, the worse of the two. It had a test class for every few production classes. One dependency had no jakarta build, an abandoned API-documentation integration, but it touched very little code and had a drop-in replacement. The upgrade was weeks of mechanical work. Most of the effort went on checking the HQL and reconfiguring Spring Security; the rename itself was quick.
Estate B: half-migrated, with the largest services remaining
About ten services across several Spring Boot versions, none supported. Some had crossed to Boot 3 and the jakarta packages; the ones that had not were the largest and the most actively developed. The source shows where the migration stopped but not why, and what was left was in good shape. The remaining javax references numbered in the low hundreds, all of them import lines, which an OpenRewrite recipe handles. None of the remaining services 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, well-tested migration.
The third case: when no forward version exists
Some blockers have nothing to upgrade to. Apache last released Struts 1 in December 2008, and Axis 1 stopped around the same time. A web tier built on either cannot follow Spring 6 across the javax to jakarta rename, because nothing exists on the other side. The web tier has to be replaced, and that needs its own budget. Nothing in the dependency list tells you this.
OpenRewrite’s recipes would do most of the work on both estates above. On this third kind they rewrite what they can and stop. What you need to know in advance is whether the recipe can finish.
What each verdict means for a plan
The three cases call for three decisions: scheduled maintenance; a couple of sprints that stop an internal split getting worse; or a funded programme that will not fit a maintenance window. Mistaking one for another costs in both directions. Treat a replacement as a version bump and the migration stalls halfway, with the estate split across two namespaces. Treat a mechanical upgrade as a rewrite and you ask for a budget you did not need.
What decides the cost
The version number was the least informative fact. These mattered more:
- Does a forward version exist for your blockers? For most libraries, yes. For the long tail that stopped in the 2000s, no, and the blocker then has to be replaced.
- How entangled is the blocker? “We use Struts” and “Struts is a third of our Java files” carry different budgets.
- Import lines or inline references? A
javaximport is a one-line automated change; a fully qualified reference in a method body is a hand edit. - Test coverage where the change lands? It decides whether you can verify the migration.
- Do the artifact names match their namespaces? Jakarta EE 8 releases carry
jakarta.*coordinates butjavaxpackages. A dependency list counts them as migrated even though the code still usesjavax.
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
Keep the word boundaries. Without them @Entity also counts @EntityListeners and @EntityScan, @Test counts @TestConfiguration, and @Query counts every @QueryParam in a JAX-RS codebase, which can inflate it tenfold.
Then list your blockers: anything that uses the javax EE APIs and has had no release since the rename. Libraries that never touched those APIs are unaffected, however old. The counts give a verdict in this order:
What this does not tell you
Whether any of it is exploitable. Advisories pile up on an unsupported version, but a CVE in code your application never calls is a different risk from one on a path it runs every request. Telling them apart needs the built artefact and a call graph. Do that before an advisory count sets the timeline: it usually shortens the urgent list.
Why these estates are unnamed
The figures are rounded. This site says where we have worked, so a count precise enough to match a repository would identify an estate, and naming an organisation’s unpatched versions is not the same as describing the pattern. Neither estate is a client, neither team was asked for anything, and both showed sound engineering: one had already done most of a migration that many estates have not started. The method is the same either way, and 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 system to build, or one that needs work?
Get in touchOr follow new writing in a reader: Atom feed.