We just did the Spring Boot upgrade from 3.5 to 4.1 in our services, and it was definitely a lot easier than my previous upgrade from Spring Boot 2 to 3. Back in January I wrote a Spring Boot 4 Migration Analysis trying to guess where the pain would be. This is what actually happened.

A few things made it easier. We didn’t have a lot of deprecated libraries like we did last time. Last time we needed to rebuild the auth server, the API server, and a lot more, and I wrote all of it down in a detailed 2.7 to 3.2 migration guide.

Another thing that helped was that we were keeping the libraries up to date. Not perfectly on the latest ones, but at least not lagging too far behind, and a lot of that is Dependabot doing the boring part for us.

We also had greater test coverage, in unit, integration, and E2E tests. This helped find issues a lot sooner and gave us confidence in the final service.

Experience from last time also helped, as I knew a bit about what broke before, so it was easier to verify and to think outside the box this time.

And lastly, the biggest change since the previous update was the existence of AI. Since the beginning of this upgrade I went AI first, which allowed me to have a markdown knowledge base of the stack, what should be done, and known issues.

The first step I did was to upgrade our core library BOM. While doing that, I started to create a markdown file with the knowledge base.

After the library was upgraded and the tests were passing, I published it locally, chose 3 to 5 of the biggest and most unique services, and went one by one, compiling and fixing the tests.

For every learning on that service, AI documented it in the markdown file. So the next service was easier, as we already knew how to fix the issues found in the previous ones. After a few services were done, we had a good enough prompt that could upgrade a service in a few hours.

Last time I did most of the upgrades myself. This time the owner teams of each service did their own. As we had a shared prompt, their job was easier.

The upgrade by the numbers

I like numbers, so once the last PR was merged I went back through the tracker to see what the upgrade actually looked like. 33 repositories, 91 pull requests, 70,870 lines changed across 3,970 files, 36 people involved, and 35 days from the first PR to the last merge, with no reverts and no rollbacks.

Four of those numbers are worth passing on, because they say where the work actually lands.

We changed more test code than production code. 29,186 lines of test code against 23,893 of production code. Spring Boot 4 broke our test setup harder than it broke the applications themselves: context loading, Testcontainers initialisers, MockMvc, a few JUnit 4 holdouts and Jackson fixtures. If you are planning your own upgrade, budget for the test suite first. Every one of those breaks was also a problem found before it reached production.

Only 15.7% of the churn was build configuration. The version bumps everyone pictures when they hear “framework upgrade” were the smallest part of the job.

Jackson 2 to Jackson 3 was the real migration. 800 source files moved to the new tools.jackson packages, and it was the only change that produced real fallout after the merge. If you prepare for one thing, prepare for this one.

Library first, then two PRs per repository. Our core library absorbed 20% of the entire churn and shipped on day one, so the 25 services behind it averaged only 2.7 PRs each. Inside each repository, a separate pre-upgrade PR before touching the Spring version kept the median PR at 18 files. We ran the formatter as its own PR too, which sounds like busywork until you see that four of the ten biggest PRs in the whole upgrade were formatting, and none of that noise landed in the diffs people actually had to review.

The number that made me think the most was a different one. Two of us touched almost every repository, while 13 of the 36 people touched exactly one. That’s a healthy shape, people came in for their own service and left, but it means the end to end context lived in two heads. I did a lot of this, and 36 people did the rest. The test coverage that caught everything was written over years by people nobody was asking to write it.

Spring Boot 4 also dragged part of the toolchain forward on its own. Most repositories are now on Gradle 9, because the Boot 4.1 BOM asks for it. Separately, we moved tracing from Brave to OpenTelemetry during the same weeks. That was its own piece of work and not something Spring Boot 4 required, but doing both at once meant the services were only disrupted once.

The knowledge base and the prompt

The knowledge base ended up being the real deliverable of this upgrade, more than any individual service.

It settled into two files, and keeping them separate is what made it work. The first is the agent file, the prompt itself, and it has one rule at the top: it may only hold what is reusable when upgrading the next service. Generic tasks, known fixups, the version catalog. Never the state of one particular service. The second is a report file that lives in the repository being upgraded, and that’s where everything service specific goes, the pinned versions, the checklist, what broke and what we chose to leave behind.

That split is what stopped the prompt from rotting. Every time we learned something, we had to decide which of the two files it belonged in, and the shared one stayed useful precisely because that decision was forced every time.

By the end the agent file was around 3,000 lines, with 126 known issues and 15 post-upgrade fixups written down. The instruction that paid off the most was a boring one: before trying to fix a failure, go read the known issues, because someone has probably already hit it.

It’s also an honest record of what actually broke. 39 of those 126 entries are test infrastructure, things like JUnit, Mockito, WireMock, Testcontainers and the test slices. It is the same story the numbers tell, one failure at a time.

Two other things from that file I would keep for the next upgrade:

  • A readiness checklist that gets copied into every service report. The same list each time, ticked off per repository. It’s what made the tenth service feel like the first one.
  • The agent does not run the tests. Its job ends at a clean compileJava and compileTestJava. Then it stops and asks me to run the suite. Test runs are slow, they spin up containers, and they’re exactly the part I want a human watching. So the loop is that the agent fixes, I run, I report back, and we go again. This is the same thing I was getting at in why async agentic AI does not mean free productivity. The agent moves fast, but somebody still has to look.

Dependency and configuration changes

These are the changes that came up in almost every service. The examples use Gradle Groovy, same as the 2.7 to 3.2 guide.

Running OpenRewrite first

Most of the mechanical work was done by OpenRewrite, and it’s genuinely good at it. A clean rewriteRun is the start of the job though, not the end. Three things to know before you run it:

  • Use two recipes. The Spring Boot 4 recipe leaves the Jackson 2 to 3 move half done, so you want UpgradeJackson_2_3 alongside it.
  • It only processes the project it is applied to. On a multi module build, putting the plugin on the root project quietly does nothing for the subprojects. Every module with source to migrate needs the plugin, the recipe and the dependency, and any subtree that the root build excludes from its shared configuration block gets skipped without a word.
  • It doesn’t get along with the Gradle configuration cache. rewriteRun fails in about three seconds, before any task actually runs, because the task holds a reference to the project. Run it with --no-configuration-cache.

When you are done, delete the plugin, the recipe block and the dependency. That scaffolding should never reach the merged build.

Starter renames

The web starter changed name, so this

implementation 'org.springframework.boot:spring-boot-starter-web'

becomes

implementation 'org.springframework.boot:spring-boot-starter-webmvc'

The OAuth2 starters gained a security- segment, so this

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

becomes

implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-client'

The same applies to -oauth2-authorization-server and -oauth2-resource-server. OpenRewrite often misses these, and because the old names no longer exist, a missed one fails at dependency resolution rather than at compile time.

The test starters split as well:

  1. spring-security-test became spring-boot-starter-security-test
  2. spring-boot-starter-webmvc-test is new, for the @WebMvcTest slice
  3. spring-boot-starter-restclient is needed if you use TestRestTemplate

And spring-boot-starter-aop became spring-boot-starter-aspectj. This one is sneaky, because it usually arrives as a runtime transitive from some other library. It compiles fine and only fails when the test or runtime classpath is resolved, so it can hide until you run the suite.

Jackson dependencies

Jackson 3 folds jackson-datatype-jsr310 and jackson-datatype-jdk8 into the core, and they are auto registered. OpenRewrite rewrites each of those lines into the same jackson-databind, so you end up declaring it two or three times. This

implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8'

becomes just this

implementation 'tools.jackson.core:jackson-databind'

plus whatever format modules you actually use, like tools.jackson.dataformat:jackson-dataformat-yaml.

One thing to be careful with, do not blanket replace com.fasterxml.jackson with tools.jackson in your imports. Only databind and core moved. The annotations stayed where they were, so this still compiles

import com.fasterxml.jackson.annotation.JsonProperty;

and the tools.jackson version of it does not exist. Keep jackson-annotations on the 2.x line too, Jackson 3 requires it.

Testcontainers 2

The module artifacts are prefixed now, so this

testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'

becomes

testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-postgresql'

Bump the Testcontainers BOM to 2.x in the same change, and do it before you run OpenRewrite. The old BOM only supplies versions for the old names, so the renamed dependencies resolve to an empty version and the recipe fails while building its AST.

hypersistence-utils

If you followed the 2.7 to 3.2 guide you are on the hibernate-63 line. Hibernate 7 needs the 73 one, so this

implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.7.3'

becomes

implementation 'io.hypersistence:hypersistence-utils-hibernate-73:3.15.3'

Do not trust the recipe here. OpenRewrite picks hibernate-71, which is the Jackson 2 line. It compiles perfectly and then throws AbstractMethodError at runtime.

Spring Retry moved into the framework

Spring Framework 7 absorbed Spring Retry into org.springframework.resilience, and you can drop the explicit org.springframework.retry:spring-retry dependency. OpenRewrite does not migrate this one, so expect cannot find symbol on the old imports. The annotations changed on the way in, so this

@Retryable(retryFor = EntityNotFoundException.class, maxAttempts = 4, backoff = @Backoff(delay = 500, multiplier = 2))

becomes

@Retryable(includes = EntityNotFoundException.class, maxRetries = 3, delay = 500, multiplier = 2)

@EnableRetry also becomes @EnableResilientMethods. And note the 4 turning into a 3, that is the off-by-one I mentioned above.

Feign moved from OkHttp to Apache HttpClient 5

Spring Cloud OpenFeign 5 dropped OkHttp. The spring.cloud.openfeign.okhttp.enabled property does not exist anymore, and the only client configuration that ships now is the HttpClient 5 one. If you do not add the new client, Feign falls back to the JDK HttpURLConnection, which cannot do PATCH, and you get this:

feign.RetryableException: Invalid HTTP method: PATCH

So this

implementation 'io.github.openfeign:feign-okhttp'

becomes

implementation 'io.github.openfeign:feign-hc5'

Keep feign-okhttp only if your own code uses the okhttp3 library directly.

The dependency swap is one line, but the new client behaves differently in two ways that took us a while to find, and both of them were latent bugs that OkHttp had been hiding.

The response body is one shot now. OkHttp buffered it, so any code that read response.body() twice silently worked. Apache HttpClient 5 gives you a streaming body, and the second read throws StreamClosedException: Stream already closed. The annoying part is where this shows up. It is usually inside an error decoder, while handling a response that already failed, so a second failure about a closed stream ends up masking the real error from the remote service. If you have a custom ErrorDecoder or a response interceptor, read the body once and pass the string around.

HttpClient 5 retries on its own. Its default retry strategy retries 503 and 429 responses, and IOExceptions, once. That sits on top of whatever retrying Feign is already doing, so every Feign attempt becomes two HTTP requests. We found it because a test asserting an exact number of downstream calls started seeing double, which in production means a struggling dependency gets hit twice as hard right when it is already in trouble. We turned it off so Feign stays the only retry layer:

@Bean
HttpClient5FeignConfiguration.HttpClientBuilderCustomizer disableHc5AutomaticRetries() {
    return HttpClientBuilder::disableAutomaticRetries;
}

Configuration properties

The error properties moved out of server, so this

server:
  error:
    include-message: always

becomes

error:
  include-message: always

And if you use MongoDB, the connection properties split away from the data ones. spring.data.mongodb.uri, .host, .port, .database, .username and .password all became spring.mongodb.*. This one deserves a warning, because the old keys are simply ignored rather than rejected, so the client quietly connects to localhost:27017 instead of where you told it to go. In our case every integration test failed at context load and the stack trace pointed at whatever happened to be listening on that port.

There are more, of course. The ones that cost us the most time were all silent like that one: a Kotlin @field: annotation that Jackson 3 no longer reads, a security URL pattern missing its leading slash. Neither fails to compile.

The Jackson decisions

We decided to make Jackson 3 as lenient as Jackson 2 was, so we would not break on unknown properties.

My favorite failure of the whole upgrade came from this. A mapper written as new ObjectMapper().disable(FAIL_ON_UNKNOWN_PROPERTIES) came out of the recipe as new JsonMapper(), with the disable call simply gone, because the Jackson 3 mapper is immutable and can’t be configured after it is built. It compiles, it reviews fine, and it fails at runtime on the exact leniency we had decided to keep. That is the kind of thing you only catch with tests.

Jackson 3 changed a handful of defaults, and each one is a small behavior change that only shows up at runtime, with real payloads, in a service you were not looking at. We have been here before on a much smaller scale, when a minor Spring Boot bump broke our object mapper. That one taught me to treat every Jackson default as something worth checking.

The builder gives you a configureForJackson2() that restores the whole Jackson 2 set in a single call, and it’s tempting. We didn’t use it. It also brings back FAIL_ON_UNKNOWN_PROPERTIES, WRITE_DATES_AS_TIMESTAMPS, FAIL_ON_EMPTY_BEANS, one based months and parameter name detection, and we wanted every one of those the Jackson 3 way. Delegating to it would have meant overriding more than it set.

So we wrote our own restoreJackson2Defaults instead, in one place, shared by every mapper we build. What it brings back:

  • Unknown properties are ignored again. FAIL_ON_UNKNOWN_PROPERTIES stays disabled, so a new field from a producer doesn’t break a consumer that hasn’t been updated yet.
  • Enums stay on name(). Jackson 3 flipped READ_ENUMS_USING_TO_STRING and WRITE_ENUMS_USING_TO_STRING to on by default, while Jackson 2 used name(). Our event bus speaks name() on the wire, so DAYS and not Days, and during the migration we had producers and consumers on both Jackson versions at the same time. Disabling both is what kept them talking to each other.
  • Trailing tokens are ignored again. Jackson 3 turned FAIL_ON_TRAILING_TOKENS on, while Jackson 2 ignored anything after the first value.
  • Nulls inside collections are skipped again. Jackson 3 rejects null elements in a collection, and EnumSet throws outright. Skipping content nulls means an array with a null entry still deserializes and drops the null, like before. For Kotlin we also had to disable StrictNullChecks on the Kotlin module, because in version 3 it is on by default and its per property contentNulls=FAIL quietly overrides the mapper level setting.
  • Nulls for primitives are tolerated again, by disabling FAIL_ON_NULL_FOR_PRIMITIVES.
  • Properties are not sorted. Jackson 3 turned SORT_PROPERTIES_ALPHABETICALLY on.
  • Months are written as text, through a ToStringSerializer for Month. We deliberately left ONE_BASED_MONTHS alone, because disabling it would make numeric reads zero based and misread every number we have written since the upgrade.

On top of that, the shared mapper disables FAIL_ON_INVALID_SUBTYPE and adds a problem handler for unknown enum values, so one value we don’t recognize doesn’t fail the whole payload.

There are also three Jackson 2 defaults we chose not to bring back, because none of them changes what we write:

  • USE_GETTERS_AS_SETTERS makes Jackson add into whatever a getter without a setter returns, so round tripping a derived getter throws UnsupportedOperationException.
  • ALLOW_FINAL_FIELDS_AS_MUTATORS lets inbound JSON overwrite final fields that the constructor computed.
  • STRIP_TRAILING_BIGDECIMAL_ZEROES renders a money value like 10.00 as 1E+1 on the valueToTree path.

And one deliberate deviation in the other direction. We keep WRITE_DATES_AS_TIMESTAMPS disabled, so dates stay textual instead of going back to epoch numbers the way Jackson 2 wrote them.

The enum decision is the one that deserves the extra attention. It caused the post-merge fallout I mentioned earlier, and the fix had to go into five different repositories.

The prompt

Here is the prompt I used, with the company specific parts taken out.

What is below is the skeleton, the part that would work in any codebase. The two big sections at the bottom, the known issues and the post-upgrade fixups, are the ones that grew to 126 entries while we worked, and those I cannot share, because every one of them is a description of our code. The template for writing them down is there, though, and honestly the template is the part that matters. Anyone can fill it with their own failures.

# Agent: spring-boot-4-upgrade

## Purpose

Perform the Spring Boot 4 major upgrade for this service. This agent assumes the pre-upgrade
preparation (latest Spring Boot 3.5.x, matching Spring Cloud release train, dependency cleanup)
has already been completed.

This agent file must contain **only information that is reusable when upgrading new services**:
generic tasks, known post-upgrade fixups, the shared known-issues knowledge base, and the version
catalog. Never record a single service's upgrade state here.

## Service Report

For each service upgraded, produce a report in a **new file in the root of the repository**
(e.g. `spring-4-upgrade-report-<service>.md`). This report, not this agent file, is where
everything specific to the service lives: the libraries and pinned versions, the readiness
checklist, issues found and fixed, internal deprecations noted, and coverage gaps.

Before starting, if the pre-upgrade phase left a report file in the repo root, read it first.

## Scope

- `build.gradle` / `build.gradle.kts`
- `gradle/libs.versions.toml` (version catalog if present)
- `settings.gradle` / `settings.gradle.kts`
- All source files under `src/`
- The service report file in the repo root

Do **not** modify:

- Internal deprecated code. Note it, but do not replace it.
- Production infrastructure or deployment configs.

## Tasks

### 1. Review pre-upgrade notes

Read the service's report file in the repo root, if the pre-upgrade phase created one. Load the
pinned versions, the service-specific notes, the internal deprecations noted but not resolved,
and any open coverage gaps. Use this as context throughout.

### 2. Add and run OpenRewrite

Add to the root build file:

```kotlin
plugins {
    id("org.openrewrite.rewrite") version ("latest.release")
}

rewrite {
    activeRecipe("org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0")
    activeRecipe("org.openrewrite.java.jackson.UpgradeJackson_2_3")
    setExportDatatables(true)
}

dependencies {
    rewrite("org.openrewrite.recipe:rewrite-spring:6.33.0")
    rewrite("org.openrewrite.recipe:rewrite-jackson:1.26.0")
}
```

The Spring Boot 4 recipe does not fully migrate Jackson to the `tools.jackson` namespace, which
is why the Jackson recipe is there too.

**Multi-module builds need more than the block above.** The tasks only process the source sets of
the project they are applied to. Applying the plugin to the root project alone does not recurse
into subprojects.

1. Declare the plugin on the root build classpath with `apply false`, then apply it per
   subproject inside `subprojects {}`.
2. In the Kotlin DSL, because the plugin is `apply false`, the type-safe accessors are not
   generated. Use `configure<org.openrewrite.gradle.RewriteExtension> { ... }` instead of
   `rewrite { }`, and `"rewrite"("...")` instead of `rewrite("...")`. The dependency goes in the
   regular `dependencies {}` block.
3. Watch for subproject trees the root `subprojects {}` block early-returns for. They are
   silently skipped unless you wire them up in their own build file. Sanity check that
   `rewriteRun` is registered on a sample of each group.
4. `rewriteRun` is memory heavy: it builds a typed AST for every module in one pass. If the daemon
   dies with a GC thrashing or OOM error, raise the heap temporarily, then restore it.
5. `rewriteRun` is incompatible with the Gradle configuration cache. It fails in about three
   seconds, before any task executes, because the task holds a project reference. Run it with
   `--no-configuration-cache` rather than disabling the cache globally.
6. Bump `testcontainers-bom` to 2.x **before** running the recipe, and apply the artifact renames
   in the same change. The recipe renames the artifacts but the old BOM only supplies versions for
   the old names, so they resolve to an empty version and the run fails.

Then prompt the user:

> Please run: `./gradlew rewriteRun --no-configuration-cache`

Wait for confirmation before continuing. After the rewrite is applied and verified, **remove the
OpenRewrite block** from the build file. It must not be committed.

### 3. Get compilation clean (do NOT run the test suite yourself)

Your responsibility ends at a clean compilation of main and test sources. **Do not run
`./gradlew test` or any test-execution task yourself.** Test runs are slow, can spin up
containers, and are the user's to drive. Get these two green:

```bash
./gradlew compileJava
./gradlew compileTestJava
```

- Fix compilation failures caused by the upgrade: package moves, renamed or removed APIs,
  signature changes, dependency resolution errors.
- Before attempting a fix, check the Known Post-Upgrade Fixups and Known Issues sections below.
  The fix may already be written down.
- For each new issue found: if it is reusable across services, add it to Known Issues below.
  Service-specific details go in the service report file.

Once both compile tasks are clean, prompt the user:

> Compilation (main + tests) is clean. Please run: `./gradlew test`

Wait for the user to run the suite and report back. Then help fix the runtime and assertion
failures they surface, consulting the known issues first and recording new reusable findings.
Repeat the fix, ask, re-run loop until the user confirms the suite is green. Never run the tests
on their behalf.

### 4. Address remaining deprecations

Scan for Spring and third-party deprecated APIs not handled by OpenRewrite. Replace where the
replacement is safe and straightforward. Do not replace internal deprecated code; note it in the
service report instead.

### 5. Update the service report

Update the libraries to their post-upgrade versions, add anything discovered that is useful later,
and mark the checklist. Reusable findings belong in this agent file instead, so the next service
benefits from them.

## Readiness Checklist

Copy this into the service report file and check boxes as steps are completed.

- [ ] Pre-upgrade notes reviewed
- [ ] Formatter plugin updated and applied by the user
- [ ] OpenRewrite recipes (Spring Boot 4 + Jackson 2 to 3) added
- [ ] `./gradlew rewriteRun` run by the user
- [ ] OpenRewrite scaffolding removed, nothing committed
- [ ] Build file cleaned: duplicate `jackson-databind` collapsed, orphaned Jackson modules removed,
      starter renames applied (`web` to `webmvc`, `oauth2-*` gaining a `security-` segment)
- [ ] Shared internal libraries bumped to their Spring Boot 4 release line
- [ ] Dependencies audited: CVE override pins re-evaluated and removed where the new BOM covers them
- [ ] Version catalog updated
- [ ] `compileJava` / `compileTestJava` clean; test suite handed to the user, failures then fixed
      collaboratively
- [ ] Known issues documented (reusable ones here, service-specific ones in the report)
- [ ] Deprecations addressed or noted
- [ ] Coverage gaps identified and documented
- [ ] Service report created or updated in the repo root

## Known Post-Upgrade Fixups

> Manual fixes that OpenRewrite does not handle automatically. Check these before spending time
> debugging.

## Known Issues

> One entry per reusable failure. Keep them short enough that scanning the list is faster than
> debugging from scratch.

### Template

**Affected versions:** the library or recipe version the issue appears on
**Symptom:** the error message or observed behavior, as it actually appears
**Fix:** what resolved it
**Repositories affected:** where it has been seen so far

If you take one thing from this post, take the two rules at the top of that file. The agent file only holds what is reusable, and everything about one service goes somewhere else. It sounds like bookkeeping, but it’s the reason the prompt was still useful on the thirty-third repository.

Your services will have their own surprises. What broke for us came from our own mix of libraries, and there are plenty of Spring Boot 4 corners we never touched. But the shape of the work should transfer: the tests break more than the code, Jackson is the long pole, and whatever you learn on the first service is worth writing down while it is still fresh, because you are going to need it on the second one.

Cheers.