A framework upgrade across twenty services is never treated as a project, which is exactly why it turns into one. Every individual service has a version bump nobody has time for this sprint, the CVE list grows quietly, and eventually a security review converts a year of deferred maintenance into an urgent deadline.
I led a Spring Boot upgrade across 20+ microservices at Zafin that eliminated 15+ CVEs. The per-service work was mostly mechanical. What made it finish was sequencing it correctly and refusing to do the services first.
Why CVEs accumulate in the first place
The mechanism is unglamorous: patches for a framework are published against supported lines only. Spring's support policy is explicit that each minor line gets open-source support for a defined window and commercial support beyond it, after which fixes simply stop arriving for that line. Stay on an unsupported minor and your vulnerability count can only go up, because the fix exists and is not being backported to where you are.
That also means most of the 15+ CVEs were not in code anyone at the company wrote. They were in transitive dependencies — a serialisation library, an HTTP client, a logging backend — pulled in by the framework and updated by a framework release. The fix for the large majority of them is not a patch. It is being current.
Worth knowing where the authoritative record actually lives. CVE assigns the identifier; the National Vulnerability Database adds analysis and scoring; and Spring's own security policy is where Spring-specific advisories are published. A scanner's output is a starting point for reading those, not a substitute.
Severity scores are a prioritisation input, not a verdict
The instinct is to sort by score and work downward. That produces the wrong order, because CVSS base scores describe a vulnerability's intrinsic characteristics, deliberately without reference to your deployment. The specification provides environmental metrics precisely because the base score is not the whole answer.
In practice the adjustment is large. A 9.8 in a code path your services never invoke, on a library reachable only from a CLI tool, is less urgent than a 6.5 in the request-parsing path of an internet-facing gateway. We used the scores to build the list and reachability to order it — which had the side benefit of making the remaining items defensible to a security reviewer, because “deferred, not reachable, here is why” is a position you can hold and “lower number” is not.
Sequence: shared libraries first, services last
This is the decision the whole thing turned on. The intuitive plan is to upgrade a low-risk service first as a pilot. That fails at service three, when you discover a shared internal library still compiled against the old framework, and every service that depends on it needs the same fix applied independently.
So: the dependency bill of materials first, shared internal libraries second, applications last. By the time a service's turn arrives its change really is a single version line, because everything it depends on already moved. That is also what makes the work parallelisable across a team — twenty near-identical pull requests are reviewable; twenty differently-shaped ones are not.
A BOM is what makes wave one possible at all. If every service pins its own versions, there is no wave one, only twenty negotiations. The dependency plugin's tree goal was the tool I used most across the whole exercise, because the question that comes up constantly is not “what version am I on” but “which of my dependencies is dragging in the old one”.
The breaking changes that actually cost time
Compilation errors are cheap; the compiler finds them all and each has one fix. The expensive changes are the ones that compile.
Package and namespace migrations are the volume: the move from javax.* to jakarta.* under Jakarta EE touches every persistence and servlet import in a codebase. Tedious, mechanical, and safe, because it does not compile until it is complete.
Behavioural changes are where the real risk sits. A default that changed, a property renamed with the old name still accepted, a validation that became stricter — these build and pass unit tests and fail in production under a specific input. Release notes are the only defence, and reading them for every intermediate minor version you are skipping is genuinely the work. There is no shortcut and no tool that substitutes for it.
Configuration deprecations deserve their own warning. A property that silently stops being honoured is worse than one that fails on startup, because the service comes up healthy with the setting ignored. Anything that turns a silent default into a startup failure — failing fast on unknown configuration keys — pays for itself on an upgrade of this size.
Making it not happen again
An upgrade that ends with everything current and no changed process just resets the clock. Three things kept the debt from re-accumulating.
Scanning in the pipeline, not in a report. OWASP Dependency-Check as a build step means a new vulnerable dependency is a build signal rather than a quarterly document. It needs a suppression file with justifications and expiry dates, or false positives train the team to ignore it — which is worse than not scanning.
Automated version bumps. Dependabot version updates raise the pull request before the CVE is published, so patch-level drift never compounds. Most are boring and mergeable, which is the point: the upgrades that hurt are the ones that skipped eleven minors.
A known end-of-support date on the roadmap. The published support timeline for each Spring Boot line means the next upgrade can be scheduled rather than triggered. An upgrade you chose the date for is a week of unremarkable work. The same upgrade after a security escalation is the same week, done badly, under pressure, with no canary budget.
What the 15+ CVEs really measured
The number is a good headline and a slightly misleading one, because almost none of those vulnerabilities were individually exploited or exploitable in our deployment. What the count actually measured was distance from supported — and that is the metric worth carrying forward, because it is forecastable. You always know how far behind you are and roughly what it will cost to catch up. You never know when someone will publish a critical advisory against the line you are stranded on.