Spring Boot 3.5 to 4: The Complete Migration Guide (with Every Error We Could Verify)

Spring Boot 3.5 to 4 migration guide: four tiers of breaking changes, from compile errors to silent failures

OSS support for Spring Boot 3.5 ended on 30 June 2026. If you’re running any 3.x release in production today, you’re on an unsupported framework unless you’re paying for commercial support. This guide covers the migration from Spring Boot 3.5 to Spring Boot 4.x in full: what changed, what breaks, the exact errors you’ll see, and how to fix each one.

One thing before we start. While researching this piece we found that the migration guides currently ranking on Google contradict each other on basic facts (more on that later), so we checked every breaking change in this article against a primary source: the official release notes, the migration guide wiki, or the GitHub issue where someone actually hit the problem. Every error message quoted here comes from a real report, with a link to it. Where we couldn’t verify a claim, we left it out.

We covered what’s new in Spring Boot 4 when it released in November 2025; this article is the companion piece for actually getting there. It’s a long one, so here’s everything the guide covers.

Should you migrate now, or buy time?

Not every team should migrate this quarter, and the vendors publishing migration content all have a product that colours their advice. So here’s the support position as it stands, taken from the official support table.

Spring Boot Support Timeline

Free support for 3.5 is gone, but because 3.5 is the final 3.x minor, it qualifies for Spring’s extended enterprise window: commercial support runs all the way to 30 June 2032. Broadcom sells that directly through Tanzu Spring, and third parties such as HeroDevs and OpenLogic sell extended support for end-of-life Spring versions too. We have no relationship with any of them; the point is simply that paying to stay on 3.5 can be a sound engineering decision.

Note the shape of the 4.x windows as well. Each 4.x minor gets roughly a year of free support, so 4.0 drops out of OSS support at the end of December 2026. If you migrate now, target 4.1, not 4.0. Most of what 4.0 merely deprecated, 4.1 removed (the deprecated starter aliases among them), so landing on 4.0 first means paying part of the migration cost twice.

So which path fits your situation? In our consultancy work the decision usually comes down to three questions. First, do any of your critical dependencies lack a Boot 4 compatible release? Check the compatibility table further down; if a blocker library still hasn’t shipped support, the decision is made for you. Second, do you have the team capacity for a piece of work that typically lands somewhere between a day for a small well-kept service and several weeks for a large application with custom security, heavy Jackson use and an old test suite? Third, is anything external forcing your hand: a security audit, a customer questionnaire, a compliance framework that flags EOL dependencies? Vulnerability scanners started flagging Spring Boot 3.5 the day OSS support lapsed, and for teams selling into the EU, the Cyber Resilience Act makes “we run supported software” a question you’ll be answering in writing.

Decision flowchart for choosing between migrating to Spring Boot 4.1 now, a staged migration via classic starters, or commercial 3.5 support

If the answers are “no blockers, some capacity, some pressure”, migrate to 4.1 and use the rest of this guide. If you’re blocked or simply short on people this year, commercial 3.5 support buys you up to six years, and a well-planned migration in 2027 beats a rushed one in 2026. What we’d steer you away from is drifting into the third option because nobody made a decision: staying on unsupported 3.5 with no CVE coverage. We’ve written before about what stale software actually costs a business, and an unpatched web framework is the worst case of it. If you’d like an experienced pair of eyes on the decision itself, our Spring Boot consultancy does exactly this kind of assessment.

Getting to a clean 3.5 baseline

The official guidance is blunt and correct: before touching a 4.x version number, upgrade to the latest 3.5.x patch and fix every deprecation warning, because everything deprecated in 3.x has been deleted in 4.0. It’s dull work and teams skip it, and then hit years of accumulated deprecations at the same time as the breakage that’s actually new.

Four things belong in this preparation phase. Get onto the newest 3.5.x and turn deprecation warnings on in your build so you can actually see them (-Xlint:deprecation for javac, or just read your IDE’s strikethroughs for once). Work through the Spring Security team’s Preparing for 7.0 guide while still on 6.5, since it lets you adopt most of the Security 7 API surface ahead of time with a working fallback. Inventory your dependencies and mark anything that touches Jackson, servlet internals or Spring auto-configuration; that shortlist is where your surprises will come from. And if the codebase carries years of accumulated clutter, a cleanup pass now pays off twice, something we covered in our guide to finding and fixing code smells.

One more preparatory check that most guides omit: if you deploy with Undertow, deal with that before anything else. Undertow is gone from Boot 4 entirely, with no shim and no workaround, because there’s no Servlet 6.1 compatible Undertow release. Swap to Tomcat 11 or Jetty 12.1 while you’re still on 3.5, verify performance under real load, and get that change out of the way as its own deployment.

That’s the groundwork done. Now for what actually changed.

What actually changed: the five big shifts

Spring Boot 4 sits on Spring Framework 7, Spring Security 7, Spring Data 2025.1, Hibernate 7 and Jackson 3, all major versions released within weeks of each other in late 2025. That’s why this upgrade is bigger than 2.7 to 3.0 felt for many teams: you’re absorbing five major upgrades in one dependency bump. The platform baselines moved too. Boot 4 requires Java 17 or later (runs up to Java 26, and the Spring team encourages the latest LTS, currently Java 25), Jakarta EE 11 with Servlet 6.1, Kotlin 2.2+ if you use it, and Gradle 8.14 or 9.

Shift one: the modular reorganisation

The headline structural change is that the old monolithic spring-boot-autoconfigure jar, which carried auto-configuration for every technology Spring Boot supports, has been split into technology-specific modules. Each technology now lives in its own spring-boot-<technology> module with a matching starter, and crucially, having a library on the classpath is no longer always enough to activate its auto-configuration. You need the right module too.

Some renames you’ll meet immediately: spring-boot-starter-web becomes spring-boot-starter-webmvc, spring-boot-starter-aop becomes spring-boot-starter-aspectj, and the OAuth2 starters gain a security- prefix (spring-boot-starter-security-oauth2-client and friends). Others are additions rather than renames: Flyway, Liquibase, Kafka, RestClient and WebClient previously activated from a bare library dependency, and each now needs its own starter. The old names still resolve in 4.0 as deprecated aliases, but they’re gone in 4.1, so treat the renames as mandatory.

Mapping of Spring Boot 3.5 starters to their Spring Boot 4 replacements, including renamed starters and newly required ones

The renames aren’t the real problem, since they fail loudly at build time. The problem is the technologies that quietly did things for you before and now don’t. Flyway is the canonical example and appears in the error index below, but the pattern repeats for the H2 console, Spring Batch’s job repository and several observability features. When something that “just worked” on 3.5 does nothing on 4.x, your first suspicion should be a missing module.

For teams that can’t absorb all the renames in one go, Spring publishes transitional spring-boot-starter-classic and spring-boot-starter-test-classic starters that pull in roughly the old world. Despite what some guides claim, we checked Maven Central and both still exist in 4.1.0, so a two-step migration (bump the version with classic starters, then unpick them starter by starter) remains available on 4.1. They’re explicitly temporary, so don’t let “temporary” become three years, but as a bridge they work.

Shift two: Jackson 2 to Jackson 3

If I had to nominate the single change most likely to cost you an unplanned week, it’s this one. Jackson 3 moved its Maven group IDs and packages from com.fasterxml.jackson to tools.jackson, with one deliberate exception: the annotations jar keeps its old coordinates and package, so @JsonProperty, @JsonView and friends don’t change. Your imports change, your custom serialisers change (JsonSerializer signatures moved), and Boot’s own Jackson API renames follow: @JsonComponent becomes @JacksonComponent, Jackson2ObjectMapperBuilderCustomizer becomes JsonMapperBuilderCustomizer, and there’s no Jackson2ObjectMapperBuilder any more, you build a JsonMapper via JsonMapper.builder().

The compile errors are the easy part. The dangerous part is that Jackson 3 changes serialisation behaviour. Properties are now sorted alphabetically by default (that’s an official default change to SORT_PROPERTIES_ALPHABETICALLY, though @JsonPropertyOrder still wins where present). Exceptions are now unchecked: JacksonException extends RuntimeException, so a catch (IOException) that used to handle serialisation failures no longer catches them. And Spring Boot now auto-registers every Jackson module it finds on the classpath, where Boot 3 registered only well-known ones, so a transitively-dragged-in module can change your JSON without a line of your code changing.

Two escape hatches exist and you should know both. spring.jackson.use-jackson2-defaults=true tells the Jackson 3 mapper to behave like Jackson 2 for the defaults Boot controls, and the deprecated spring-boot-jackson2 module keeps a real Jackson 2 ObjectMapper available for libraries that need one (Jersey users currently have no choice, as Jersey 4.0 doesn’t support Jackson 3). Both are bridges with a stated end date: Spring Framework plans to disable Jackson 2 auto-detection in 7.1 and remove it in 7.2,so anything you park on the compatibility flags still has to be dealt with before then.

If your services talk JSON to each other, put contract tests or captured-payload comparisons in place before you upgrade anything. Serialise your key DTOs on 3.5, store the output, and diff it against the same DTOs on 4.x. It’s an afternoon of work that turns “why is the mobile app crashing” into a red build.

Shift three: Spring Framework 7

Framework 7 removals that bite real code: ListenableFuture is gone (use CompletableFuture), all javax.annotation and javax.inject support is gone (Jakarta only, no more mixed codebases), OkHttp3 client support is gone, and HttpHeaders no longer extends MultiValueMap, which breaks any utility code that treated headers as a map (there’s a born-deprecated asMultiValueMap() bridge). The Hibernate support package moved from org.springframework.orm.hibernate5 to org.springframework.orm.jpa.hibernate. And the whole portfolio adopted JSpecify null-safety annotations, which matters enormously for Kotlin codebases: nullability that used to be advisory becomes a hard compile error, and in our experience this produces a long tail of small fixes rather than any single big one.

Not urgent, but plan for it: RestTemplate is on the way out. It’s deprecated in the 7.0 documentation, gains a formal @Deprecated in 7.1, and is slated for removal in Framework 8. Nothing breaks today, but new code should be written against RestClient, and the migration is mechanical enough to fold into this upgrade if you have the appetite.

Shift four: Spring Security 7

Security 7 finishes the DSL transition that 6.x warned about for years. The chaining style is dead: .and() and authorizeRequests() are removed, and only the lambda DSL compiles. (If you’re somehow still carrying a WebSecurityConfigurerAdapter, that debt is older; it went back in Security 6.0.) A chained configuration that still compiled on 3.5, like this:

http.csrf().disable()
    .authorizeRequests()
        .requestMatchers("/api/public/**").permitAll()
        .anyRequest().authenticated()
    .and()
    .httpBasic();

becomes:

http.csrf(csrf -> csrf.disable())
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/public/**").permitAll()
        .anyRequest().authenticated())
    .httpBasic(Customizer.withDefaults());

The subtler change, and the one behind most of the “my API returns 403 after upgrading” reports, is request matching. AntPathRequestMatcher and MvcRequestMatcher are gone; PathPatternRequestMatcher is the default, and the DSL now requires absolute paths. Critically, Security no longer infers your servlet path. If your dispatcher servlet is mapped to a prefix like /api, rules that previously matched via MVC-aware matching silently stop matching, requests fall through to stricter rules, and you get 401s or 403s with nothing useful in the logs. The fix is to state the base path explicitly with PathPatternRequestMatcher.withDefaults().basePath("/api"), per the official migration guide. If your authorisation rules are complex, this is also a sensible moment to revisit the model itself; we’ve covered attribute-based access control in Spring Security separately.

Also removed: the OAuth2 password grant, the legacy AccessDecisionManager/AccessDecisionVoter API, and OpenSAML 4 support (OpenSAML 5 now). Method security expressions referencing parameter names now require compiling with -parameters. None of these are exotic; audit for all of them.

Shift five: Spring Data 2025.1, Hibernate 7 and Batch 6

Boot 4 manages Hibernate 7, and Hibernate 7 removed the legacy Session methods (save, update, delete, load) in favour of the JPA verbs (persist, merge, remove, getReference). It’s stricter about model validation, GenerationType.SEQUENCE now wants an explicit @SequenceGenerator, persisting a detached entity throws EntityExistsException where it used to slide through, and native queries return java.time types by default. The annotation processor artifact was renamed from hibernate-jpamodelgen to hibernate-processor; keep the old one and your static metamodel silently stops regenerating, which is a fun one to diagnose.

Spring Data 2025.1 renames @PersistenceConstructor to @PersistenceCreator, moves some core types, and changes MongoDB defaults in ways that touch stored data: BigDecimal now maps to Decimal128 and the UUID representation must be configured explicitly. Mongo connection properties also moved from spring.data.mongodb.* to spring.mongodb.*. JPA derived queries are now generated as JPQL strings rather than translated criteria, which is mostly invisible but occasionally changes generated SQL, so watch slow-query logs after the upgrade.

Spring Batch 6 deserves its own warning because its biggest change is silent. The default job repository is now in-memory. The old spring-boot-starter-batch no longer persists job metadata; if you want the database-backed repository that every production Batch deployment relies on for restartability, you must switch to spring-boot-starter-batch-jdbc. Your jobs will run and pass every functional test without it. What you lose is the ability to restart a failed job from where it stopped, and you’ll only discover that during an incident.

The upgrade, step by step

With the theory covered, here’s the sequence we follow on client projects. It assumes you’ve done the 3.5 baseline work above.

Start with OpenRewrite, but know what it won’t do

The community OpenRewrite recipe automates a worthwhile chunk of the mechanical work. The exact recipe ID matters because at least one published guide gets it wrong; it lives in the boot4 package:

mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:RELEASE \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0

The recipe chains the 3.5 upgrade, Framework 7 and Security 7 migrations, the property renames, @MockBean replacement, Hibernate 7.1 and the Testcontainers 2 migration. On a small, conventional service it gets you most of the way; Julien Wittouck documented a real 3,000-line project migrated in about an hour with it. What it does not do, based on that write-up and our own runs: it misses some package relocations (his build still broke on the EndpointRequest move), it can’t rewrite security rules whose behaviour changed rather than their API, and it has no opinion about your JSON contracts. Treat its diff as the first commit of the migration. Gradle users: the equivalent rewrite-gradle-plugin invocation works the same way, and either way you review the diff before committing, since recipes are code transformations, not judgement.

Bump the version and fix the build

Update the parent (or the platform BOM for Gradle), and if you’re on Spring Cloud, move to the 2025.1.x “Oakwood” release train in the same commit, because the 2025.0.x train pairs with Boot 3.5 and the mismatch fails fast. Then work through the compile errors in dependency order: imports first (Jackson packages, the health actuator move, EntityScan, the Hibernate ORM package), then starters, then the security DSL. A typical dependencies diff for a web service looks like this:

<!-- before (3.5) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<!-- after (4.x) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-flyway</artifactId>
</dependency>

Note the Flyway change is more than a rename: the migration engine used to activate from the library’s presence, and now activates from the starter. Getting this wrong doesn’t fail the build, which is exactly why it’s in the error index below.

Run with the properties migrator

Add the migrator as a runtime dependency and start the application:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-properties-migrator</artifactId>
    <scope>runtime</scope>
</dependency>

It logs every renamed or removed property it recognises and temporarily maps old names to new ones at runtime, which converts a class of silent misconfiguration into a readable startup report. The renames are extensive this time (the complete list is the official configuration changelog); ones we see most are the Jackson namespace moves, the MongoDB connection-property move covered earlier, and the session store properties gaining a data segment. Fix the properties it reports, then remove the migrator once the report is clean; it adds startup cost while present.

Verify behaviour, not just the build

A green build and a started application prove less than usual on this upgrade, because several of the worst changes are behavioural. Before calling the migration done: confirm your database migrations actually executed (check the Flyway/Liquibase history tables, not the logs alone), diff serialised JSON for your most important payloads against captured 3.5 output, confirm Batch job metadata is landing in the database if you rely on restarts, and run your security tests against every servlet-path variant you deploy behind. The whole of the next section is effectively the checklist for this step.

The error index: what breaks, and the exact fix

These are the failures teams have actually reported since 4.0 went GA, with the error text verbatim so you can find this page by searching for it, each linked to its source. They’re ordered by how the failure presents, and the order matters:

The louder a breaking change announces itself, the less it should worry you.

Compile errors are just a to-do list, and even a startup failure at least tells you where to look. The changes that run cleanly and do the wrong thing are how you end up explaining a data problem to a customer.

Tier 1: won’t compile

Security DSL methods gone. cannot find symbol: method and() and the same for authorizeRequests(). Every chained-style security configuration fails like this. Rewrite to the lambda DSL as shown earlier; there’s no flag to bring the old style back.

@MockBean and @SpyBean gone. Replace with Framework’s @MockitoBean and @MockitoSpyBean. Not quite a find-and-replace: the new annotations can’t be used inside @Configuration classes. If you shared mocks that way, move them onto the test classes with @MockitoBean(types = SomeService.class), or build a composed annotation. Details in the testing section below.

Actuator health imports fail. AbstractHealthIndicator, Health and Status no longer resolve under org.springframework.boot.actuate.health. Nothing was removed; the classes moved to org.springframework.boot.health.* in the new spring-boot-health module, which the actuator starter already brings in. Fix the imports and move on; the maintainers confirmed as much on issue #48281.

HttpHeaders stops being a MultiValueMap. Utility code that passed headers around as a map fails to compile against Framework 7. The asMultiValueMap() bridge exists but arrived already deprecated, so prefer the new accessor methods.

Jackson imports and Boot’s Jackson API. tools.jackson packages, @JacksonComponent, JsonMapperBuilderCustomizer, as covered above. Remember annotations keep the old package; if a find-and-replace across com.fasterxml.jackson touches your annotation imports, you’ve gone too far.

Kotlin null-safety errors across the codebase. JSpecify annotations make Spring’s nullability contracts hard errors under Kotlin 2.2. Each fix is small; the volume is the problem. Budget time proportional to how much of your code touches Spring signatures directly.

Library code using PropertyMapper.whenNonNull(). Removed. This mostly bites teams maintaining internal starters, and it’s part of what broke the OpenFGA starter (issue #145). Use .always() or restructure the mapping.

Tier 2: compiles, won’t start

Undertow users stop here. There’s no error message to quote because there’s nothing left to fail: the dependency doesn’t exist in Boot 4’s managed dependencies. Move to Tomcat 11 or Jetty 12.1 before the upgrade, as discussed.

Zalando Logbook (pre-4.x) kills startup.

Caused by: java.lang.ClassNotFoundException:
  org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
  at org.springframework.util.ClassUtils.resolveClassName(ClassUtils.java:343)
  ~[spring-core-7.0.1.jar:7.0.1]

Logbook’s auto-configuration referenced a Boot class that moved in the modular reorganisation. Logbook 4.x supports Boot 4 and Jackson 3; upgrade it in the same change (write-up, tracking issue).

springdoc-openapi on an old version kills startup.

BeanCreationException: Error creating bean with name
  'springdocBeanFactoryPostProcessor' ...
Caused by: java.lang.ClassNotFoundException:
  com.fasterxml.jackson.databind.node.ObjectNode

Boot 4 no longer puts Jackson 2 on the classpath and swagger-core still wanted it (issue #3200). A relative of this one surfaces through Spring HATEOAS, where the old Jackson2HalModule no longer exists (issue #3095). Both are fixed in the springdoc line built for Boot 4; use springdoc 3.x and check their compatibility table when picking the exact version.

Protobuf version clash via OpenTelemetry.

Detected incompatible Protobuf Gencode/Runtime versions when loading
  io.opentelemetry.proto.resource.v1.Resource: gencode 4.32.0, runtime 4.31.0.
  Runtime version cannot be older than the linked gencode version.

Seen on Boot 4.0.1 with Spring Cloud 2025.1.0, where the two BOMs managed different protobuf-java versions and the OTLP meter registry failed to instantiate at startup (issue #48663). Later BOM releases align the versions; if you’re pinned to affected ones, pin protobuf-java explicitly.

Spring Modulith apps refuse to start until the event schema migrates. Modulith 2.0 changed its event publication table schema, and the application fails at startup without the migration. Sequence it deliberately: finish the Boot 4 upgrade, then take Modulith 2.0 with its schema migration as its own change.

Test-support libraries referencing moved Boot internals. The pattern generalises beyond any one library: anything that reflected on or extended Boot’s auto-configuration classes may fail with NoClassDefFoundError on 4.x. A live example is zonkyio’s embedded-database library tripping over the relocated LiquibaseProperties (issue #319, still open at the time of writing). Audit your test classpath, not just your main one.

Tier 3: starts, fails at runtime

403s (or 401s) on endpoints that worked yesterday. The most-reported post-upgrade symptom, and most write-ups misdiagnose it as a CSRF change. As covered in the Security 7 section: the request-matcher replacement plus the end of servlet-path inference means previously-matching permitAll or CSRF-exemption rules can stop matching. Check your matchers and base path first, not your CSRF config.

H2 console returns 404. The console’s auto-configuration now lives in a dedicated spring-boot-h2console module, and we verified nothing pulls it in by default, not even DevTools. Add it explicitly as a runtime dependency in dev profiles. There’s no starter for it, which is why it’s easy to miss.

Paged API clients fail to deserialise.

tools.jackson.databind.exc.InvalidDefinitionException:
  Cannot construct instance of 'org.springframework.data.domain.Page'

Spring Data’s Jackson 3 setup registers a serialiser for PageImpl but no deserialiser, and the Jackson 2 community fixes (like OpenFeign’s PageJacksonModule) don’t compile against Jackson 3. If your services consume paged responses from each other, you’ll need a mixin registered through a JsonMapperBuilderCustomizer, mapping Page to a concrete type with wrapper-typed fields; there’s a worked solution in this write-up.

Date projections from native queries blow up at the web layer.

HttpMessageNotWritableException: Could not write JSON:
  Cannot project java.time.LocalDateTime to java.time.LocalDate;
  Target type is not an interface and no matching Converter found

Hibernate 7 changed the default Java types returned by native queries to java.time types, and Spring Data REST projections can’t always convert the result (issue #48570). Workarounds reported by the community: the hibernate.query.native.prefer_jdbc_datetime_types property, an explicit @SqlResultSetMapping, or converting in the projection expression.

Batch jobs refuse to re-run with changed parameters.

JobInstanceAlreadyCompleteException: A job instance already exists and is
  complete for identifying parameters={...} If you want to run this job again,
  change the parameters.

Thrown even though the parameters did change, with incrementers receiving an empty parameter map; a Batch 6 identity-handling regression tracked as spring-batch #5125. If you run parameterised scheduled jobs, test the re-run path specifically and watch that issue before upgrading Batch-heavy workloads.

swagger-ui returns HTTP 400 when API versioning is enabled. Boot 4’s new API versioning support and springdoc’s doc endpoints initially didn’t cooperate: enable spring.mvc.apiversion settings and both /swagger-ui.html and /v3/api-docs can return 400 with nothing in the logs (issue #3163). Track the issue for the fix version if you’re adopting versioning.

Tier 4: runs fine, does the wrong thing

Flyway and Liquibase migrations silently skipped. The single nastiest one in this list. Keep depending on flyway-core alone and your application starts normally, serves traffic, and never runs another migration; the auto-configuration that used to trigger on the library’s presence now lives in the starter (reported here, and as spring-boot #47315). Your first hint may be a production deploy that needed a schema change it never got. Use spring-boot-starter-flyway or spring-boot-starter-liquibase, and verify against the schema history table after the first 4.x deploy.

Batch metadata quietly in-memory. Covered above, listed again here because this tier is its natural home: everything works until you need a restart.

Your JSON changes shape without any code change. Alphabetical property ordering, unchecked exceptions, every classpath module auto-registered. Snapshot tests, strict consumers and byte-sensitive integrations (HMAC signing of serialised payloads, for instance; we’ve written about HMAC in Spring Boot before and this upgrade is exactly where such schemes wobble) all deserve a before/after diff.

Kubernetes probes appear on your health endpoint. Liveness and readiness probes are enabled by default in 4.x. Harmless for most, but if your platform monitors the health endpoint’s exact response shape, or you’d deliberately disabled probes, revisit management.endpoint.health.probes.enabled.

MongoDB writes change at the data level. BigDecimal to Decimal128 and explicit UUID representation, as covered in the Data section. This one can require a data migration rather than a config line, so involve whoever owns the database before flipping it in production.

Deploy scripts using the executable-jar launch mode break. The “fully executable” jar with the embedded launch script is gone; ./app.jar start era init.d deployments stop working while java -jar continues fine. If provisioning scripts are old enough to use this, they’re old enough to be worth replacing with a systemd unit anyway.

Internal starters registering auto-configuration through legacy spring.factories stop contributing beans. Technically broken since Boot 3.0 but widely surviving until now, this fails as a NoSuchBeanDefinitionException somewhere far from the cause, often costing a day of confused debugging, as one practitioner documented in his migration write-up. If your organisation maintains shared libraries, check they register via META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports before anything else.

Migrating your tests

Test suites take a disproportionate share of the migration effort, partly because the testing changes are real and partly because a broken test suite removes your safety net exactly when you need it. Migrate the tests first where you can; they’re what tells you the rest of the migration worked.

The @MockBean replacement is the big one. The mechanical rewrite:

// Boot 3.5
@SpringBootTest
class OrderServiceTest {
    @MockBean
    PaymentGateway paymentGateway;
}
// Boot 4.x
@SpringBootTest
class OrderServiceTest {
    @MockitoBean
    PaymentGateway paymentGateway;
}

The non-mechanical part is anywhere you declared @MockBean inside a shared @Configuration class to reuse mocks across tests. @MockitoBean doesn’t support that; the sanctioned replacements are @MockitoBean(types = PaymentGateway.class) at the test-class level or a composed annotation you define once and reuse. While you’re in there, note that MockitoTestExecutionListener is gone too, so plain @Mock and @Captor fields need Mockito’s own MockitoExtension registered explicitly.

@SpringBootTest also slimmed down. It no longer auto-configures MockMvc, so add @AutoConfigureMockMvc where you inject one, and it no longer provides a TestRestTemplate bean. The new arrival is RestTestClient, a non-reactive analogue of WebTestClient enabled with @AutoConfigureRestTestClient, and it’s pleasant enough that we’d suggest adopting it rather than chasing the relocated TestRestTemplate. Test slices moved packages along with everything else (@WebMvcTest now lives under org.springframework.boot.webmvc.test.autoconfigure), and the modular starters have test companions, so @WithMockUser now wants spring-boot-starter-security-test.

One runtime surprise specific to test suites is worth its own error block, because the log spam is alarming and the cause is obscure:

WARN i.m.registry.otlp.OtlpMeterRegistry : Failed to publish metrics to
  OTLP receiver (context: url=http://localhost:4318/v1/metrics ...)
Caused by: java.net.ConnectException: Connection refused

Tests never used to try publishing metrics because a context customiser disabled export; that customiser moved into the modular test artifacts, so without them your suite attempts real OTLP calls on every context shutdown. The maintainers’ answer on issue #47806: add spring-boot-starter-opentelemetry-test (or the micrometer metrics/tracing test modules) to your test classpath.

Testcontainers users get a companion migration, since Boot 4’s dependency management tracks Testcontainers 2 with renamed artifacts (testcontainers-kafka and so on, dropping the old per-module group layout). It’s well documented upstream, but wrappers around Testcontainers lag behind; check anything that abstracts it for you. And to correct a claim circulating in other guides: JUnit 4 support was not removed. Framework 7 deprecates it, and spring-boot-starter-test hasn’t shipped the vintage engine since Boot 2.4, so if your JUnit 4 tests were running on 3.5 they’ll keep running on 4.x through whatever explicit vintage dependency you already had. It’ll go eventually, but nothing has been removed yet.

Third-party compatibility, as of July 2026

This table summarises the ecosystem position at the time of writing. Versions move quickly; treat it as a starting point for your own dependency audit rather than a promise, and check each project’s own documentation for the current minimum.

Library / projectBoot 4 status (July 2026)Notes
Spring Cloud2025.1.x “Oakwood” required2025.1.2+ for Boot 4.1; the 2025.0.x train is Boot 3.5 only
Spring AI2.x lineSpring AI 1.x pairs with Boot 3.5; 2.0 tracks Boot 4
Spring Modulith2.0Requires event publication schema migration at startup
springdoc-openapi3.x line built for Boot 4Earlier versions fail at startup (Jackson 2, HAL module)
Testcontainers2.x managed by Boot’s BOMRenamed artifacts; third-party wrappers lag
Flyway / LiquibaseCompatibleBut now require the dedicated Boot starters to activate
resilience4j2.4.0+ with the new resilience4j-spring-boot4 moduleEarly releases had BOM packaging hiccups
RedissonSupported after a gapNo Boot 4 starter existed at GA; support followed within weeks
MyBatismybatis-spring-boot 4.0.xNew release line specifically for Boot 4; 3.0.x stays on Boot 3.5
Zalando Logbook4.xPre-4 versions fail at startup on Boot 4
jOOQ3.20+Requires Java 21 from Boot 4.1’s managed version
JerseyWorks, with a caveatJersey 4.0 has no Jackson 3 support; needs the spring-boot-jackson2 bridge
UndertowNot availableRemoved from Boot 4 entirely; no compatible release exists
Cloud vendor starters (AWS, GCP, Azure, Alibaba)MixedCheck your specific starter; several spent months with open Boot 4 support issues

The general lesson from watching this ecosystem catch up over eight months: anything that integrates with Boot’s auto-configuration or serialises JSON needed a real release for Boot 4, not just a version bump. If a library you depend on is small, unmaintained and does either of those things, test it early and have a plan B.

Things other guides get wrong

We fact-checked the popular migration guides against primary sources while researching this piece, and some corrections seem worth publishing, partly because you’ll read those guides too and partly because a few of the myths cause real planning mistakes.

“Spring Boot 4 requires Java 21.” It doesn’t. The official system requirements say Java 17 minimum, compatible up to Java 26, with the latest LTS encouraged. If Java 17 is where your organisation is stuck, that alone doesn’t block the migration (though individual libraries can raise the bar, as jOOQ does).

“CSRF protection now applies to REST APIs and causes 403s.” No CSRF default changed in Security 7 at all; the 403s are the request-matcher and servlet-path changes described earlier. The distinction matters because the “fix” the myth suggests (disabling CSRF) can leave the actual misconfigured matcher in place, unexamined.

“The classic starters were removed in 4.1.” Both transitional starters are published for 4.1.0 on Maven Central. The staged migration path remains open.

“JUnit 4 support was removed.” Deprecated, as discussed in the testing section. Different planning consequence entirely.

“There are exactly 83 breaking changes” / “exactly 115”. Both numbers circulate, attributed to different vendors, and both are counting artefacts of how you slice the release notes. The number that matters is how many apply to your application, which is what your deprecation report and dependency audit are for.

Shipping it safely

Everything above gets the application migrated; this section is about not betting production on it in one afternoon. The advice scales down deliberately, because most of the teams we work with run a handful of services, not a thousand repos.

Stage the rollout so the blast radius grows with your confidence: an internal or low-traffic service first, then a canary instance of something that matters, then the fleet. Define the rollback trigger before deploying rather than during the incident. And be clear-eyed about what rollback means on this particular upgrade, because two things quietly become one-way doors. If Hibernate 7 or your own migrations have altered schema, rolling the application back doesn’t roll the database back, so keep schema changes backwards-compatible with the 3.5 build until you’re past the point of no return (the same expand-then-contract discipline we described in our legacy migration process guide). And if consumers have already adapted to your Jackson 3 output, rolling back flips their world a second time; the compatibility flag spring.jackson.use-jackson2-defaults=true during a transition window makes the output stable in both directions and is the cheapest insurance in this entire article.

In CI, three additions carry most of the value. A dependency-diff step that prints what the BOM changes actually did to your resolved versions, so surprises arrive in review rather than at runtime. The JSON contract check described earlier, run against captured 3.5 payloads. And a startup smoke test that asserts on the things that fail silently: migration history advanced, Batch metadata present, expected beans in the context. None of these need new tooling; they’re an afternoon each with what you already run. Keep a mixed fleet working during the transition by upgrading services independently behind stable API contracts; what you can’t do is mix Boot 3 and Boot 4 modules inside one application, which the migration guide warns against and which the Jackson split makes impossible in practice anyway.

What you get for the effort

It’s fair to ask what you get on the other side of all this work. From the projects we’ve moved across already: the payoff is real, but it’s mostly about what the new baseline enables rather than the upgrade itself.

Moving to the Java 25 LTS as part of the same effort brings mature virtual threads, and we’ve benchmarked what that does for a Spring Boot 4 service under load; for thread-per-request workloads the throughput difference is substantial, with the usual caveats about pinning and pool-bound resources covered in that piece. The AI ecosystem has also moved decisively onto this baseline: Spring AI 2.0 tracks Boot 4, so the patterns we’ve written about, from RAG with pgvector to multi-step agentic workflows, assume the platform this migration gives you. Beyond that, first-class API versioning replaces a decade of homegrown header hacks, the OpenTelemetry starter simplifies observability wiring, HTTP service clients reduce boilerplate, and JSpecify null-safety helps a little on every refactor from here on. Grails teams reading this: Grails 8 rides the same Boot 4 foundations, so the pain points here are a preview of your own upgrade.

Final thoughts

No article makes a major-version migration painless, and we’d be suspicious of one that claimed to. What we’ve aimed for is that nothing in your upgrade comes as a surprise. The hard cases are the ones this guide can only wave at: applications with years of custom auto-configuration, byte-sensitive JSON integrations, or a test suite thin enough that “the build is green” proves very little. That last one is really a testing problem rather than a migration problem, and it’s worth solving as one.

Some suggestions before you start your own migration:

  • Run the deprecation report today. Even if migration is months away, knowing your number changes the planning conversation.
  • Capture your JSON contracts now. Serialised payloads from 3.5 are the cheapest test fixtures you’ll ever collect, and you can only collect them before you upgrade.
  • Migrate one real service end to end. Estimates from one honest data point beat estimates from any article, including this one.
  • Audit your internal starters. If you maintain shared libraries, they’re your longest lead-time item; start them first.

We’ll keep this guide updated as the 4.x line and the ecosystem move. And if you’d rather have someone who has done this a few times handle your migration, that’s exactly what our Spring Boot consultancy is for; you can see how we’ve handled similar modernisation work in our case studies, or just get in touch.

David Brown founded Tucanoo Solutions in 2011 and has written software in a professional capacity for over 30 years, back to the Java/J2EE days of Sun Microsystems. He builds cloud and web applications for clients in legal tech, government and beyond, including several years consulting to the United Nations on customs and transport systems. He writes these tutorials himself, with the full code on GitHub.

Leave a Reply

Your email address will not be published. Required fields are marked *