Spring Boot 4.0 is out, built on top of Spring Framework 7, and a lot of teams are now staring at the same question I was: how much pain is the Spring Boot 3 to 4 migration actually going to be?
I just took a production service from 3.5 to 4.0, and the honest answer is that it is mostly a dependency and build exercise.
The framework jump broke only a few small things in my own source code. The parts that actually took time were the test classes and, more than anything, the Spring Cloud Gateway and Netty stack.
This is the version I wish I had read before I started.
It walks the upgrade in the order I would do it again: get current on 3.5 first, move the BOM, delete the version pins the BOM now owns, fix the handful of source and test breaks, sort out the gateway, and tidy the Gradle build on the way out.
I will keep the war stories concrete and the snippets small.
Why move to Spring Boot 4 now
The first reason is unglamorous but real: support windows.
Each Spring Boot generation has a fixed open source support period. Once a line falls out of free maintenance, you stop getting security patches without a commercial agreement.
Moving to Boot 4 while you are still comfortably inside the 3.x window means you upgrade on your own schedule, not under the pressure of a deadline or a freshly published vulnerability.
The second reason is what you gain.
Boot 4 sits on Hibernate 7 and Spring Data 2025.1, which bring a lighter persistence bootstrap and better behaviour under virtual threads. So the work you may have already done on Java 21 pays off more here.
There is also a first-party OpenTelemetry starter for observability, and improved ahead-of-time processing for teams heading toward GraalVM native images.
None of that forces a rewrite. It is upside you collect for staying current, which is the most boring and most reliable kind of engineering win.
What actually changes in Spring Boot 4
Spring Boot 4.0 is a major release, so the whole stack underneath it moves up together. The headline pieces are Spring Framework 7, Spring Security 7, Hibernate 7, Jakarta EE 11, and Spring Cloud 2025.1.
That sounds like a lot, and the version numbers are intimidating. But most of it is invisible if your application already speaks the modern Spring dialect.
Two facts took the temperature down for me right away.
First, Java 17 is still the supported floor, so Boot 4 does not force you onto Java 21 to even compile. Java 21 is the recommended baseline and where you want to land eventually, but you can decouple the two moves.
Second, Jackson 2 is still the default in Boot 4. Jackson 3 exists and is opt-in, so your serialisation behaviour does not silently change underneath you on day one.
The other structural change worth knowing is modularisation. Boot 4 splits the old monolithic autoconfigure jar into smaller, focused modules, and several starter POMs were renamed to line up with them.
If you depend on the normal spring-boot-starter-* artifacts, the dependency management handles this for you. If you reach past the starters and pin internal artifacts directly, that is where you will feel it.

Get to the latest 3.5.x before you touch Boot 4
If you remember one thing from this guide, make it this: do not jump from an old 3.x straight to 4.0.
Move to the latest 3.5.x release first and clear every deprecation warning it gives you.
Spring Boot 3.5 exists largely to be the bridge to 4.0. Anything that is removed in Boot 4 is deprecated in 3.5, with a warning that points you at the replacement.
So when you run a clean build on the newest 3.5.x and the log is quiet, you have done most of the discovery work for the major upgrade while still on a release you can ship today.
That is a much calmer place to debug from than a wall of compile errors against a brand new major version.
My order of operations looked like this, and it is the sequence I would repeat:
- Bump to the latest 3.5.x patch and build clean.
- Fix every deprecation warning until the build log is quiet.
- Switch the Boot and Cloud BOMs to the 4.0 line.
- Delete the version pins the new BOM now manages.
- Fix the source-level breaks the compiler points at.
- Run the full test suite, then plan the deploy and a one-line rollback.

Move the BOM and let it own your versions
Here is the part that delivers most of the value and most of the cleanup: the Spring Boot 4 dependency BOM now manages a long list of libraries that teams used to pin by hand.
Once you import the 4.0 BOM, a surprising number of explicit version numbers in your build file become redundant, and a few of them become actively wrong.
Start by switching the BOM imports:
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:4.0.0"
mavenBom "org.springframework.cloud:spring-cloud-dependencies:2025.1.0"
}
}
Then go hunting for explicit versions you no longer need.
In my build, the BOM took over the versions for the web and servlet stack (Tomcat embed and Catalina), the reactive stack (Reactor Netty and the Netty codecs), logging (Logback classic and core), and a cluster of common utilities like SnakeYAML, Apache Commons Lang, the Nimbus JOSE JWT library, the Apache HttpClient 5 client, and Lombok.
Every one of those had a hard-coded version that I deleted, because the BOM now resolves a known good, mutually compatible set.
The pattern in the dependency block is simple once the BOM is in place. You declare the artifact and say nothing about the version:
dependencies {
// Versions come from the Boot 4 BOM, so no version string here.
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-mail'
}
The benefit is not just fewer lines. Hand-pinned versions are how you end up with a quietly incompatible jar six months later.
Letting the BOM drive the managed set means your transitive graph stays internally consistent, and your future upgrades are a single version bump instead of a spreadsheet of individual libraries.
What still needs an explicit version
Not everything lives in a Spring BOM, and those libraries still need a version from you.
The ones that stayed pinned in my build were the database driver, the scheduling lock library, the cloud provider SDK, and the OpenAPI tooling:
- The Oracle JDBC driver (
com.oracle.database.jdbc:ojdbc11) is not BOM managed, so keep its version explicit. - ShedLock for distributed scheduler locks needs a version, and you want the line that targets Boot 4.
- The AWS SDK you use for storage or messaging is on its own release train.
- springdoc-openapi for Swagger UI ships its own coordinates, and you move it to the line built for Boot 4.
dependencies {
// Not in any Spring BOM, so these keep an explicit version.
runtimeOnly 'com.oracle.database.jdbc:ojdbc11:21.11.0.0'
implementation 'net.javacrumbs.shedlock:shedlock-spring:6.1.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.1.0'
}
The mental model that helped me: if Spring or Spring Cloud publishes it, delete your version and trust the BOM. If it is third party and outside that universe, keep the pin and bump it deliberately.
That single rule cleared most of the noise out of my dependency file.

The code changes that broke the build
This is the section everyone wants the truth about.
After the BOM move, the framework jump produced only a small set of source-level breakages, and most of the application compiled and ran untouched because it was already using the modern APIs.
The main one in the application code was an @Where rename; a couple more lived in the test classes, which get their own section below.
Hibernate 7 removed @Where, use @SQLRestriction
Hibernate deprecated @Where back in the 6.x line and removed it outright in Hibernate 7, which is what Boot 4 pulls in. If you use @Where for soft deletes or row filtering, the build will stop the moment you move up.
The replacement is @SQLRestriction, and it takes the same clause string, so this is a rename, not a rewrite.
// Before: Hibernate 6, removed in 7
import org.hibernate.annotations.Where;
@Entity
@Where(clause = "deleted = false")
public class Account {
// fields
}
// After: Hibernate 7
import org.hibernate.annotations.SQLRestriction;
@Entity
@SQLRestriction("deleted = false")
public class Account {
// fields
}
Two notes from doing this for real.
The annotation argument changes from @Where(clause = "...") to a bare string in @SQLRestriction("..."), so do not just swap the name and leave clause = behind.
And because the generated SQL is identical, the safe way to verify is to load and query the affected entities against a real schema and confirm the filtered rows behave exactly as they did before.
Hibernate 7 is a little stricter about entity mapping in general, so this is a good moment to actually exercise those queries rather than assume.
JUnit Jupiter 6 and a sneaky MediaType clash
One of these breaks lived entirely in test code, and it was the sneaky kind. The spring-boot-starter-test in Boot 4 pulls in JUnit Jupiter 6, which ships a brand new class: org.junit.jupiter.api.MediaType.
If a test class wildcard-imports both org.junit.jupiter.api.* and org.springframework.http.*, the bare name MediaType is suddenly ambiguous, and the compiler is right to refuse it.
// This used to compile because only Spring shipped MediaType.
import org.junit.jupiter.api.*;
import org.springframework.http.*;
// Boot 4 fix: import the MediaType you actually mean, explicitly.
import org.springframework.http.MediaType;
The same upgrade surfaced a second, related ambiguity. Spring Framework 7 added a new ResponseEntity constructor that takes a MultiValueMap of headers, which made a call like new ResponseEntity<>(null, status) ambiguous against the existing HttpHeaders constructor.
The fix is the same principle: be explicit about the type you are passing so the compiler can pick the right overload.
The lesson I took away is an old one that this upgrade reinforced. Explicit imports beat wildcard imports, and they age better, because a future library can always introduce a class that collides with a name you were importing by the bucket.

The test classes needed more work than I expected
The application code came through the upgrade nearly intact.
The test classes did not, and this is the area I would tell anyone to budget for, because the failures here are quieter and easier to miss than a red compile.
The first change is a straight rename, but a load-bearing one. Boot 4 removed @MockBean and @SpyBean, which were deprecated back in 3.4.
The replacements are @MockitoBean and @MockitoSpyBean, and for the common case of a mock declared as a field on a test class, it is a find and replace down to the import:
// Before: Spring Boot 3
import org.springframework.boot.test.mock.mockito.MockBean;
@SpringBootTest
class PaymentServiceTest {
@MockBean
private LedgerClient ledgerClient;
}
// After: Spring Boot 4
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@SpringBootTest
class PaymentServiceTest {
@MockitoBean
private LedgerClient ledgerClient;
}
The catch is that the new annotation is not a drop-in everywhere. @MockitoBean is meant to sit on a field of a test class, and unlike the old @MockBean it is not supported on a field of a @Configuration class.
If you had a shared test configuration that declared @MockBean fields to publish mocks into the context, that pattern has to move, either onto the test classes themselves or into an explicit @Bean method that returns a Mockito mock.
That is the one spot where a rename quietly turned into a small redesign for me.
The second change is the dangerous one, because it fails silently. Boot 4 drops the JUnit Vintage engine, the bridge that let old JUnit 4 tests run on the JUnit 5 platform.
Any test still written against org.junit.Test does not error after the upgrade, it simply stops running, with nothing in the output to tell you. A green build can hide an entire class of tests that quietly went dark.
Before you trust the suite, compare the test count against your pre-upgrade baseline, and grep the codebase for org.junit.Test and @RunWith to flush out anything still on the JUnit 4 API.
Two smaller things rounded it out. The spring-boot-starter-test umbrella has been modularised, so depending on which slice tests you run you may need a more specific starter such as spring-boot-starter-webmvc-test.
And TestRestTemplate is now deprecated in favour of the new RestTestClient, which is worth moving to when you are already in those tests, though the old type still works for now.
None of these is dramatic on its own. Together they are why the test source took longer than the production code, and why "the build is green" was not the moment I trusted the upgrade.
The part that actually hurt: Spring Cloud Gateway and Netty
If you run an API gateway, this is where the time goes.
The framework breaks were minutes of work; the gateway was an afternoon, and it landed two problems at once.
The first is a rename. Spring Cloud 2025.1, the release train that pairs with Boot 4, finished a starter rename that had been telegraphed for a while: the old spring-cloud-starter-gateway is gone.
The reactive gateway now lives behind spring-cloud-starter-gateway-server-webflux, and there is a servlet sibling, spring-cloud-starter-gateway-server-webmvc, for the MVC stack.
The new names spell out two axes at once, server versus proxy-exchange and WebFlux versus WebMVC. That is clearer once you know it and a hard stop until you do, because the build will not resolve the old coordinate.
// Before: Spring Cloud 2024.x and earlier
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
// After: Spring Cloud 2025.1 (the Boot 4 line) — name the stack explicitly
implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux'
The second problem is the one that cost me real time: Netty. The reactive gateway runs on Reactor Netty, and the Boot 4 BOM moves both io.netty and reactor-netty to a newer line.
If anything in your build still pins a Netty or Reactor Netty version by hand, directly or dragged in transitively by another library, you get a classic runtime mismatch.
The gateway was compiled against one Reactor Netty API and a different version wins on the classpath, so instead of a clean compile error you are met with a NoSuchMethodError or NoClassDefFoundError when the application starts.
It is exactly the trap the BOM section warns about, with the highest stakes, because the gateway is the front door to everything behind it.
The fix is the same discipline applied harder: let the BOM own every Netty and Reactor Netty version. Delete your manual pins, then prove it rather than assume it:
./gradlew :gateway:dependencies --configuration runtimeClasspath
Read the tree and confirm a single, consistent Netty version resolves.
If a third-party library is dragging an older Netty along, either bump that library to a Boot 4 aligned release or exclude its transitive Netty so the BOM's version stands alone.
One last gateway gotcha while you are in there: the reactive gateway wants the reactive web stack. Keep spring-boot-starter-webflux and make sure spring-boot-starter-web has not crept onto the gateway's classpath, or autoconfiguration gets confused about which kind of server it is meant to start.
Tidy the Gradle build while you are in there
A major upgrade is the right time to pay down small build debt, and Boot 4 plus Gradle 9 nudge you to.
Boot 4 wants Gradle 8.14 or newer, with Gradle 9 recommended, and Gradle 9 deprecates a couple of patterns you have probably been carrying for years.
The one I hit was the top-level sourceCompatibility and targetCompatibility properties. Gradle 9 warns on those, and the clean replacement is a toolchain inside the java { } block:
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
A toolchain is better than the old properties anyway, because it pins the JDK that compiles and runs the build rather than just the bytecode level, so the build behaves the same on every machine.
While I was in the file I also removed a couple of ext version properties that only existed to pin libraries the BOM now manages, and I merged two stray test { } blocks into one.
Everything else, the repository configuration, the static analysis setup, and the artifact publishing, I left exactly as it was. A migration is not the time to refactor things that already work.
Benign warnings you can ignore
Not every warning in the build log is yours to fix, and chasing the wrong ones wastes a day.
Two showed up in my build that are safe to leave alone, and it helps to know them in advance so you do not treat them as blockers.
The first is a Gradle deprecation notice that says the build uses features incompatible with a future Gradle version. When I traced it, the source was third party Gradle plugins for artifact publishing and static analysis, not my own build script.
Those plugins will catch up on their own release schedule, and my script was already clean.
The second is a "some input files use or override a deprecated API" note, which came from an older cloud provider SDK compiled on a newer JDK. It is informational and harmless.
Read each warning to its source before you act on it, then write down the ones you have consciously decided to accept so the next person does not re-investigate them.
Config: what did not change
The test classes and the gateway, covered above, were where my time actually went.
Configuration was the opposite, the calm part of the upgrade. It is worth saying plainly what did not need touching, because the scary list of new major versions makes you brace for more than actually arrives.
On configuration, I did not have to rename a single property. My application.yml files were untouched, and the management endpoints, datasource, mail, and scheduling keys all resolved correctly under Boot 4.
That will not be universally true. Boot 4 does rename some configuration keys, so the safe move is to add spring-boot-properties-migrator as a temporary dependency.
It runs at startup, reads your existing properties, and logs a clear report of anything that has been renamed or removed. Pull it back out once the report is clean.
This is the same caution I bring to any backend change, the kind of work I describe across my backend engineering write-ups: trust the tooling to find the surprises, then verify by hand.
It is worth keeping an explicit list of the things you confirmed were already compatible, because that list is what lets you sign off with confidence rather than hope. The ones I checked and ticked off were:
- The
jakarta.*namespace was already in use everywhere for persistence, validation, mail, and transactions, so there was nojavaxtojakartamigration left to do. If your service made that move during Boot 3, you have already paid this tax. - The core JPA mapping annotations like
@Entity,@Table,@Column,@Id,@OneToOne, and@JoinColumnare unchanged in Hibernate 7, so entity classes compiled as-is apart from the@Whererename above. - The WebClient exception hierarchy on the reactive side kept the same
WebClientResponseExceptionsubtypes, so error handling around outbound calls did not move. - The database dialect resolved the same way, so generated SQL stayed consistent against the same schema.
- The ShedLock annotation packages and its table schema were unchanged, which meant no database migration and no DDL for the scheduler locks.
The point of writing that list down is that "it compiled" is not the same as "I verified it."
Naming what you checked turns a hopeful upgrade into a deliberate one.
The change most teams trip on: Spring Security 7 CSRF
This one did not break my particular service, but it is the single most reported failure after a Boot 4 upgrade, so it belongs in any honest guide.
Spring Security 7 changed the CSRF protection defaults.
The very common symptom is a REST API that worked fine on Boot 3 suddenly returning 403 on every state-changing request after the upgrade, because protections that you were implicitly relying on being off are now on.
If you have an explicit SecurityFilterChain bean that configures CSRF the way your API needs, you are in control and likely fine.
If your security configuration leaned on framework defaults, read the Security 7 notes carefully before you deploy and test your POST, PUT, and DELETE paths against the new defaults.
This is the failure that turns a quiet upgrade into an incident, and it is entirely avoidable if you check for it on purpose.
What to watch on the first deploy
A clean build and a green test suite are necessary but not sufficient.
A few runtime behaviours shift under Boot 4 even when nothing in your code changed, and these are the ones I made a point of checking on the first deploy:
- Swagger UI path. Moving springdoc-openapi to its Boot 4 line changed the default Swagger UI path. If a health probe, a bookmark, or a smoke test hits the old URL, update it, because the page is fine but the address moved.
- Logback patch level. The BOM now resolves a newer Logback patch than I had pinned, and that newer patch already covers a vulnerability I had previously worked around with a manual override. Once the BOM gives you a version at or above the fixed release, delete the override so you are not fighting it later.
- Reactor Netty line. If you run on the reactive stack, the BOM moves Reactor Netty to a newer line. Once the version conflict from the gateway section above is resolved, runtime behaviour was unchanged for me, but it is still worth a glance at your connection and timeout configuration.
- Third party and internal libraries. Confirm that anything proprietary in your build has a version that is genuinely aligned to Boot 4, and get that confirmation from whoever owns the library before you promote to production. A library that merely compiles is not the same as one that is supported on the new line.
Can you automate the upgrade?
Mostly, and you should use the tooling as a first pass. OpenRewrite ships a dedicated UpgradeSpringBoot_4_0 recipe that renames the modular starters, replaces deprecated APIs, and chains the underlying framework upgrades.
Paired with spring-boot-properties-migrator for the configuration side, it does a lot of the mechanical work that this article describes by hand.
The caveat is the same one I apply to any generated change: read every diff.
Automation is excellent at the rename-this-import, swap-this-annotation work, and it is exactly the kind of well understood, pattern-based task where it shines.
It is not a substitute for running your real test suite and thinking about the runtime behaviours above. Use it to clear the boring 80 percent, then spend your attention on the parts that need judgment.
The takeaway
For a service that is already kept current on 3.5.x, the Spring Boot 4 migration is far less dramatic than the version numbers suggest.
The framework jump itself was a short list of small source and test fixes.
The real work, and the real risk, lived in the dependencies I pinned myself, Spring Cloud Gateway and the Netty stack most of all, and in the runtime behaviours that shift quietly underneath a green build.
So do it in this order. Get current on 3.5 and clear the warnings.
Move the BOM and delete the versions it now owns. Fix the compile breaks the compiler hands you.
Run the whole suite, check the security defaults and the first-deploy behaviours, and keep your rollback boring: reverting the build files should be enough to put you back.
If you want to know more about the kind of production systems I work on, that is on my about page.
Take it one branch at a time, keep every change reviewable, and Boot 4 turns out to be a tidy upgrade rather than a scary one.
FAQ
- Does Spring Boot 4 require Java 21?
- No. Java 17 is still the supported floor for Spring Boot 4, so you are not forced onto Java 21 to make the jump. Java 21 is the recommended baseline and you get the best runtime behaviour there, but a service still on Java 17 can move to Boot 4 first and raise the Java version as a separate step.
- Will my Hibernate @Where annotations still work in Spring Boot 4?
- No. Hibernate 7, which ships with Spring Boot 4, removes @Where. Replace it with @SQLRestriction from org.hibernate.annotations. The clause string stays exactly the same, so it is a rename of the annotation and its import, not a rewrite of the query.
- How long does a Spring Boot 3 to 4 migration take?
- For a service that is already on the latest 3.5.x with its deprecation warnings cleared, most of the work is dependency and build cleanup plus a couple of small code fixes. The real cost sits in the libraries you pin yourself, not in the framework, so the audit of your own dependencies is what to budget time for.
- Can I automate the Spring Boot 4 upgrade?
- Largely, yes. The OpenRewrite UpgradeSpringBoot_4_0 recipe renames modular starters and replaces deprecated APIs, and spring-boot-properties-migrator flags renamed configuration keys at startup. Treat both as a strong first pass, then read every change and run the full test suite before you trust it.
- What changed for Spring Cloud Gateway in Spring Boot 4?
- The starter was renamed. The old spring-cloud-starter-gateway is gone in Spring Cloud 2025.1, the release line that pairs with Boot 4, and the reactive gateway is now spring-cloud-starter-gateway-server-webflux, with a servlet variant spring-cloud-starter-gateway-server-webmvc. The bigger risk is Netty: let the Boot 4 BOM own the io.netty and reactor-netty versions, because a hand-pinned version leaves you with a NoSuchMethodError at startup instead of a clean compile error.

