Everything in this guide was tested on Spring Boot 4.0.0, Spring Framework 7.0.0, Spring Security 7.0.0, and Java 21.0.3 (Eclipse Temurin). Behaviour details may differ on milestone or RC builds of Boot 4 โ check the release notes if you are running a pre-GA version.
Updated August 2026: Spring Boot 3.5 โ the last 3.x branch โ reached open-source end of life on June 30, 2026, and Spring Boot 4.1 (June 10, 2026, built on Spring Framework 7.0.8; latest patch 4.1.1, August 20, 2026) is now the current release. Boot 4.0’s own OSS support window closes December 31, 2026, while 4.1 runs to July 31, 2027 โ so 4.1.x, not 4.0, is the version to land on. If you’re planning this migration for a service still on 3.x, start with Spring Boot 3.x Is EOL. Don’t Stop at 4.0. Target 4.1 Instead. for the support-runway math and why the latest 4.1.x โ not 4.0 โ is the right deployment target.
Updated October 29, 2026: the “latest patch 4.1.1” above is now 4.1.2, and this guide gained a support-dates table, a runnable before/after demo with the real failures captured, an OpenRewrite section and a “breaks at runtime” checklist. 4.2 is at release-candidate stage, not GA.
Versions checked on October 29, 2026. Latest GA: Spring Boot 4.1.2 (Maven Central, published October 22, 2026; 4.1.0 was June 10, 2026). Spring Boot 4.2 is not GA: the newest artifact on Maven Central is4.2.0-RC1(also October 22, 2026). Every command output below was produced by runningboot4-migrationon JDK 25.0.5 and Maven 3.9 against Boot 3.5.16 and 4.1.2 — see the companion module. The original text of this guide, written against 4.0.0, is kept below; where a later check disagreed with it, I added a correction next to the original rather than deleting it.
TL;DR
- Spring Boot 4 keeps a Java 17 baseline (latest LTS encouraged) โ the hard bumps are Kotlin 2.2+, GraalVM 25+, Jakarta EE 11, and Servlet 6.1 (Tomcat 11 / Jetty 12.1; Undertow support is removed).
- All APIs deprecated in Boot 3.x are removed in Boot 4. There are no grace periods.
- Virtual threads remain opt-in (
spring.threads.virtual.enabled=true) โ but if you flip them on during this upgrade, pinning and ThreadLocal bugs change how you reason about concurrency. - Spring Security’s
HttpSecuritylambda DSL is now the only supported approach โ method chaining is gone. - The monolithic auto-configure jar is split into per-technology modules โ starter names change, every starter gains a
-testcompanion, and missing beans show up at runtime, not compile time. - Jackson 3 is the default JSON library โ new
tools.jacksonpackages, renamed Boot annotations, changed serialisation defaults, and a deprecatedspring-boot-jackson2stop-gap. - Testing breaks in specific ways:
@MockBean/@SpyBeanare removed, and@SpringBootTestno longer auto-configures MockMvc orTestRestTemplate. - Which version: land on 4.1.x (latest GA 4.1.2 on October 29, 2026). 3.5 lost OSS support on June 30, 2026, 4.0 loses it on December 31, 2026, and 4.2 is only at 4.2.0-RC1.
- Silent breakage: a missing starter (Flyway, RestClient), removed
logging.file.*keys, Jackson 3 property order and the removed Mockito listener all compile and run, so see the checklist further down.
The Migration Nobody Is Fully Ready For
Every few years, the Spring ecosystem ships a major version that makes us rethink our life choices. Spring Boot 3 brought the Jakarta EE namespace rename โ javax.* to jakarta.* โ and we spent weeks hunting down transitive dependencies that hadn’t been updated. Spring Boot 4 is a different kind of disruption. The changes are more surgical, spread across the runtime model, security wiring, observability stack, and configuration loading mechanism. Worse, many of the failures are silent โ your application starts, but acts weirdly under load.
This guide is for anyone running Spring Boot 3.x in production who’s planning (or being dragged kicking and screaming) toward Boot 4. I’ve put together the actual breaking changes, bad vs. good code examples, and what actually happens internally when things break. No hand-waving, no “just update your pom.xml” nonsense.
When to Migrate (and When NOT to)
Migrate to Spring Boot 4 when:
- Your team is on Java 17+ already, ideally committed to the current LTS.
- You want first-class virtual thread support on a modern LTS runtime.
- You’re building new services and want the latest GraalVM native image improvements.
- Your third-party starters and libraries have Boot 4-compatible releases available.
- You have adequate test coverage (integration tests, not just unit tests) to catch silent regressions.
Do NOT migrate yet if:
- Your stack runs on Undertow โ it has no Servlet 6.1 release, Boot 4 removed support for it, and there is no workaround short of moving to Tomcat or Jetty.
- Your stack relies on a key library (e.g., a JDBC driver, a messaging client) that hasn’t released a Boot 4-compatible version.
- You’re mid-sprint in a production-critical release cycle. Boot 4 migrations deserve a dedicated sprint.
- You have heavy use of deprecated Boot 3 APIs that were never cleaned up. You’ll need to fix those first, or the Boot 4 build will fail before you even start.
Which version to land on, and how long each one is supported (checked October 29, 2026)
The question under every migration plan is “which 4.x?”. The support dates answer most of it. I read them on October 29, 2026 from api.spring.io/projects/spring-boot/generations, the JSON feed that the support table on spring.io/projects/spring-boot is drawn from (the HTML page renders client side, so a plain fetch only returns the page shell; I did not read the rendered table itself). The feed agrees with endoflife.date/spring-boot, which I used as a second source.
| Line | Initial release (as listed) | OSS support ends | Commercial support ends | Status on Oct 29, 2026 |
|---|---|---|---|---|
| 3.5.x | 2025-05-31 | 2026-06-30 | 2032-06-30 | OSS ended; last OSS patch 3.5.16 (Maven Central, June 25, 2026) |
| 4.0.x | 2025-11-30 | 2026-12-31 | 2027-12-31 | OSS patch 4.0.9; about two months of OSS support left |
| 4.1.x | 2026-06-30 | 2027-07-31 | 2028-07-31 | GA, current: 4.1.2 |
| 4.2.x | 2026-11-30 | 2027-12-31 | 2028-12-31 | Not GA: 4.2.0-RC1 only |
Two readings of that table matter. First, Boot 3.5 stopped getting open-source fixes on June 30, 2026; paid support continues to June 30, 2032 for customers of the commercial offering. Second, the “initial release” column is a month-end placeholder, not the day anything shipped: 4.1.0 is listed as 2026-06-30 but its artifact reached Maven Central on June 10, 2026, and the project’s Supported Versions page says releases target the Thursday after the third Monday of May and November. Use the “support ends” columns, not the release column, for planning. The support-runway argument in full is in Spring Boot 3.x Is EOL. Don’t Stop at 4.0. Target 4.1 Instead., and the older lines are tabulated in Spring Boot Versions: Release and Support Dates.
Land on 4.1.x, then decide about 4.2 when it is GA. 4.0 loses OSS support on December 31, 2026, so migrating 3.5 → 4.0 now buys you roughly two months. The 4.2 release candidate exists, but a release candidate is not a target for production. Its wiki notes list a handful of upgrade items (a new default OCI builder image,spring-boot-properties-migratorgaining a fail-on-error switch,spring.mvc.pathmatch.matching-strategydeprecated) — see the 4.2 / Framework 7.1 checklist. I could not find RC2 artifacts on Maven Central or the Spring milestone repository on this date, although the project wiki already carries RC2 release-notes drafts, so I do not describe RC2 as released.
Breaking Change #1 โ The Baselines: Java 17 Floor, Everything Around It Raised
Contrary to a widely repeated claim, Spring Boot 4 does not raise the Java floor: the official requirement is Java 17 or later, with the latest LTS encouraged. What actually moved are the baselines around the JDK: Kotlin 2.2+, GraalVM 25+ for native images, and Jakarta EE 11 with a Servlet 6.1 floor โ which means Tomcat 11 or Jetty 12.1, and no Undertow at all: it has no Servlet 6.1 release, and Boot 4 removed support for it entirely.
The practical consequence: your CI pipeline, Docker images, and runtime environments must agree on one JDK (17 minimum, ideally the current LTS) โ and if you run Undertow, you are blocked until you move to Tomcat or Jetty. There is no flag to work past that one.
Opinionated Takeaway: The floor may be 17, but don’t camp on it. If you’re doing this migration anyway, land on the current LTS and use the excuse to rewrite your clunky switch statements with pattern matching.
Recommended โ pin the release flag to the JDK you actually run
<!-- Maven pom.xml - Spring Boot 4 configuration -->
<properties>
<!-- Java 17 is the minimum supported version; 21+ recommended -->
<java.version>21</java.version>
<!-- --release is stricter than source/target: also blocks internal JDK API usage -->
<maven.compiler.release>21</maven.compiler.release>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0</version>
</parent>
The --release flag (via maven.compiler.release) is stricter than the old source/target combination โ it also gates against accidentally using internal JDK APIs, which is desirable for production code anyway. Whatever version you pick, pin it consistently across the pom, the CI image, and the runtime base image.
Breaking Change #2 โ Virtual Threads: Still Opt-In, But This Is the Release to Audit Them
Boot 3.2 introduced virtual thread support behind spring.threads.virtual.enabled=true, and Boot 4 keeps them opt-in โ your executor model does not silently change on upgrade. In practice, though, most teams treat the Boot 4 migration (with its Java 21+ runtime) as the moment to enable them. If that’s you, treat this section as mandatory reading before you flip the switch.
Under heavy traffic, virtual threads allow your server to juggle hundreds of thousands of requests with minimal OS overhead. Benchmarks often show throughput doubling or quadrupling for I/O-heavy workloads, all without tweaking application code.
The catch? Code that was perfectly safe with platform threads can now exhibit subtle bugs due to thread-local state or pinning.
Opinionated Takeaway: Virtual threads aren’t magic pixie dust. If your code relies heavily on
synchronizedblocks, turning them on might actually degrade performance. Profile before you celebrate.
BAD โ Using synchronized on a virtual-thread-hostile path
@Service
public class CacheService {
// This is a problem with virtual threads:
// 'synchronized' pins the virtual thread to its carrier (platform) thread.
// Under high concurrency this creates a bottleneck identical to platform-thread exhaustion.
public synchronized String getOrLoad(String cacheKey) {
if (localCache.containsKey(cacheKey)) {
return localCache.get(cacheKey);
}
// Simulated remote I/O โ blocks while pinned to the carrier thread
String fetchedValue = remoteDataSource.fetch(cacheKey);
localCache.put(cacheKey, fetchedValue);
return fetchedValue;
}
private final Map<String, String> localCache = new HashMap<>();
private final RemoteDataSource remoteDataSource;
public CacheService(RemoteDataSource remoteDataSource) {
this.remoteDataSource = remoteDataSource;
}
}
IMPROVED โ Using ReentrantLock instead of synchronized
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class CacheService {
// ReentrantLock does NOT pin virtual threads to their carrier threads.
// When a virtual thread blocks on lock.lock(), it unmounts from the carrier,
// freeing the carrier to run other virtual threads. This is the key distinction.
private final ReentrantLock cacheLock = new ReentrantLock();
private final Map<String, String> localCache = new ConcurrentHashMap<>();
private final RemoteDataSource remoteDataSource;
public CacheService(RemoteDataSource remoteDataSource) {
this.remoteDataSource = remoteDataSource;
}
public String getOrLoad(String cacheKey) {
cacheLock.lock();
try {
if (localCache.containsKey(cacheKey)) {
return localCache.get(cacheKey);
}
// Virtual thread safely unmounts during this blocking I/O call
String fetchedValue = remoteDataSource.fetch(cacheKey);
localCache.put(cacheKey, fetchedValue);
return fetchedValue;
} finally {
cacheLock.unlock();
}
}
}
The JVM’s -Djdk.tracePinnedThreads=full flag is your friend here. Add it to your local startup and watch for pinning warnings before you find out the hard way in production.
Breaking Change #3 โ Spring Security Configuration Overhaul
This is where most teams hit the wall. Spring Security’s method-chaining approach for HttpSecurity configuration was soft-deprecated in Boot 3 and is completely removed in Boot 4. Only the lambda DSL โ introduced in Spring Security 5.2 โ is supported.
The exact compiler error you get when method chaining is still present looks like this:
SecurityConfig.java:18: error: cannot find symbol
.authorizeHttpRequests()
^
symbol: method authorizeHttpRequests()
location: variable httpSecurity of type HttpSecurity
SecurityConfig.java:22: error: cannot find symbol
.and()
^
symbol: method and()
location: class ExpressionInterceptUrlRegistry
2 errors
The .and() method is gone entirely โ not just deprecated. Every occurrence in your codebase is a compile blocker.
BAD โ Method chaining (does not compile in Boot 4)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
// This method-chaining style is REMOVED in Spring Boot 4 / Spring Security 7.
// It causes a compilation error โ not just a runtime warning.
httpSecurity
.authorizeHttpRequests()
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
.and()
.httpBasic()
.and()
.csrf().disable();
return httpSecurity.build();
}
}
IMPROVED โ Lambda DSL (Boot 4 compatible)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
// Lambda DSL is the only supported style in Spring Boot 4.
// Each customizer receives a typed config object โ no more .and() chaining.
// Benefit: each block configures exactly one concern, reducing scope confusion.
httpSecurity
.authorizeHttpRequests(authorizationConfig ->
authorizationConfig
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.httpBasic(httpBasicConfig ->
httpBasicConfig.realmName("MyApp API")
)
.csrf(csrfConfig ->
csrfConfig.disable() // Explicit disable โ you must now justify this intentionally
);
return httpSecurity.build();
}
}
Why is the lambda DSL better? It’s structurally cleaner. Each lambda gets a strongly typed configuration object, removing scope ambiguity.
Opinionated Takeaway: The lambda DSL is objectively superior. The old method chaining was a nesting nightmare that caused real security holes because developers lost track of which
.and()they were chained to.
Breaking Change #4 โ Modularization: The Starter Landscape Changed Under You
This is the biggest structural change in Boot 4. The monolithic spring-boot-autoconfigure jar was split into per-technology modules (it still exists, holding the auto-configuration infrastructure, but it shrank from 1,470 classes in Boot 3.5 to 258 in 4.1) โ and the starter POMs were reorganised to match. Most technologies now have a dedicated starter, and every starter gains a -test companion (e.g. spring-boot-starter-webmvc-test), which means your test dependency section needs as much review as your main one.
The renames you are most likely to hit: web MVC support is spring-boot-starter-webmvc, RestClient/RestTemplate support lives in spring-boot-starter-restclient, WebClient in spring-boot-starter-webclient, Kafka in spring-boot-starter-kafka, and observability splits into spring-boot-starter-micrometer-metrics, spring-boot-starter-opentelemetry, and spring-boot-starter-zipkin. Classic aggregate starters exist as a transition aid but are deprecated. The full mapping table is in the official Spring Boot 4.0 Migration Guide โ walk your dependency tree against it rather than guessing.
Two consequences follow. First, beans your code found transitively through the old fat autoconfigure jar may simply not be on the classpath anymore โ the failure is a NoSuchBeanDefinitionException or a silently missing auto-configuration at runtime, not a compile error. Second, if you maintain custom starters, review how they declare their auto-configurations and dependencies against the new module layout. (The spring.factories โ AutoConfiguration.imports switch, for the record, is old news: auto-configuration support for spring.factories was removed back in Boot 3.0. If a legacy internal library skipped that memo, it has been silently failing since your Boot 3 upgrade โ Boot 4’s stricter module layout is just where you finally notice.)
Boot 4 also renamed and removed a number of configuration properties. Add the spring-boot-properties-migrator dependency for your first runs: it prints diagnostics at startup and temporarily maps old property names to new ones while you clean up application.properties / application.yml. Remove it before shipping.
Opinionated Takeaway: Modularization is good engineering and a one-time toll. Pay it deliberately โ with the migration guide’s starter table open โ instead of whack-a-moling missing beans at runtime.
The other half of modularization: your Java imports moved too
Starter renames are the visible half, and they fail loudly. The half that catches people a week later is that the per-technology *Properties and auto-configuration classes changed package as well — out of org.springframework.boot.autoconfigure.<area> and into org.springframework.boot.<module>.autoconfigure. The swap is not mechanical, because the module name is not always the old package segment:
o.s.b.autoconfigure.security.SecurityProperties→o.s.b.security.autoconfigure.SecurityPropertieso.s.b.autoconfigure.web.ServerProperties→o.s.b.web.server.autoconfigure.ServerPropertieso.s.b.autoconfigure.web.servlet.WebMvcProperties→o.s.b.webmvc.autoconfigure.WebMvcProperties— note webmvc, notweb.servleto.s.b.autoconfigure.jackson.JacksonProperties→o.s.b.jackson.autoconfigure.JacksonProperties- Unchanged:
@SpringBootApplication,@EnableAutoConfiguration,@AutoConfigurationand the wholeo.s.b.autoconfigure.conditionpackage — the infrastructure stayed put.
The one that costs the most time is SecurityProperties, because it did not just move. This is a common Boot 3 idiom for ordering a chain behind everything else:
// Boot 3 - compiles
import org.springframework.boot.autoconfigure.security.SecurityProperties;
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception { ... }
In Boot 4 the import does not resolve. Repointing it at the new package does not fix it either, because the constants are not on the new class: SecurityProperties in Boot 4 has exactly one method, getUser(). The constants went somewhere else again — the old nested SecurityProperties.Filter class was promoted to a top-level SecurityFilterProperties, and took two of them with it:
BASIC_AUTH_ORDER(Integer.MAX_VALUE - 5) →o.s.b.security.autoconfigure.web.servlet.SecurityFilterPropertiesDEFAULT_FILTER_ORDER(-100) → same classIGNORED_ORDER(Integer.MIN_VALUE) → removed outright, with no replacement. It appears nowhere in the Boot 4 jars. UseOrdered.HIGHEST_PRECEDENCE, which is the same value.
The properties migrator will not save you here.
spring-boot-properties-migratormaps property names, and none of these property names changed:spring.security.filter.orderstill defaults to-100andspring.security.filter.dispatcher-typesstill defaults to all five dispatcher types. Only the Java classes moved. The compiler is the only tool that finds this class of breakage, which is an argument for doing the dependency bump and the compile in one sitting rather than deferring it.
Breaking Change #5 โ Observability: Split Modules and the Observation API
Boot 4 splits the observability stack into dedicated modules and starters (spring-boot-starter-micrometer-metrics, spring-boot-starter-opentelemetry, spring-boot-starter-zipkin) and enables Kubernetes liveness/readiness probes on the health endpoint by default. Code wiring legacy Sleuth-era hooks โ or depending on metrics/tracing classes that now live in a module you no longer pull in โ fails at startup. The instrumentation target remains a unified ObservationRegistry for metrics, tracing, and logging correlation.
Opinionated Takeaway: Stop building custom metrics wrappers. The new Observation API is finally good enough to use directly without hiding it behind an internal abstraction layer.
BAD โ Direct MeterRegistry usage for manual timers
@Service
public class OrderProcessingService {
// Directly injecting MeterRegistry works but bypasses the Observation abstraction.
// In Boot 4, tracing context is NOT automatically propagated โ
// your Zipkin/Jaeger spans won't include this timing data.
private final MeterRegistry meterRegistry;
public OrderProcessingService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
public Order processOrder(OrderRequest orderRequest) {
Timer.Sample timerSample = Timer.start(meterRegistry);
try {
Order processedOrder = doProcessing(orderRequest);
timerSample.stop(meterRegistry.timer("order.processing.time", "status", "success"));
return processedOrder;
} catch (Exception exception) {
timerSample.stop(meterRegistry.timer("order.processing.time", "status", "error"));
throw exception;
}
}
}
IMPROVED โ Observation API (unified metrics + tracing)
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
@Service
public class OrderProcessingService {
// ObservationRegistry is the unified instrumentation entry point.
// A single observation automatically creates:
// - A Micrometer timer (metrics)
// - A distributed trace span (if a Brave/OTel bridge is present)
// - Correlated log entries via MDC (if configured)
private final ObservationRegistry observationRegistry;
public OrderProcessingService(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
}
public Order processOrder(OrderRequest orderRequest) {
return Observation.createNotStarted("order.processing", observationRegistry)
.lowCardinalityKeyValue("order.type", orderRequest.getType())
.observe(() -> doProcessing(orderRequest));
// On error, the observation automatically records the exception
// and marks the span as failed โ no catch block needed for instrumentation.
}
}
Breaking Change #6 โ Jackson 3 Is the Default JSON Library
Boot 4 moves to Jackson 3, which renamed its group ID and packages from com.fasterxml.jackson to tools.jackson (annotations excepted) and made its exceptions unchecked (JacksonException extends RuntimeException โ your catch (IOException) blocks stop catching serialisation errors). On the Boot side: @JsonComponent is now @JacksonComponent, @JsonMixin is @JacksonMixin, Jackson2ObjectMapperBuilderCustomizer becomes JsonMapperBuilderCustomizer, and the spring.jackson.read.* / spring.jackson.write.* properties moved under spring.jackson.json.*. Jackson also now auto-registers every module found on the classpath โ disable with spring.jackson.find-and-add-modules=false if that surprises you.
Two escape hatches exist: spring.jackson.use-jackson2-defaults=true aligns the auto-configured mapper’s behaviour with Boot 3.x, and the deprecated spring-boot-jackson2 module keeps a Jackson 2 ObjectMapper available for libraries that still need one. Serialised output can change shape even when everything compiles โ diff JSON samples of your key DTOs as part of the upgrade test pass. The full walk-through is in the Jackson 2 to Jackson 3 migration guide.
Breaking Change #7 โ Your Test Suite Breaks in Four Specific Ways
The testing changes are easy to miss in planning and expensive to discover late. First, @MockBean and @SpyBean are removed โ migrate to @MockitoBean and @MockitoSpyBean. Second, @SpringBootTest no longer auto-configures MockMvc: add @AutoConfigureMockMvc explicitly. Third, TestRestTemplate and WebClient beans are no longer provided by @SpringBootTest either โ add @AutoConfigureTestRestTemplate plus a test dependency on spring-boot-resttestclient, or better, move to the new RestTestClient. Fourth, the deprecated MockitoTestExecutionListener is gone, so plain @Mock/@Captor fields silently stop being initialised โ use Mockito’s own MockitoExtension.
Note the pattern: three of these four fail silently or with confusing symptoms (null mocks, missing beans) rather than clean compile errors. Run the test suite immediately after the version bump, before touching production code, so every failure you see is attributable to the upgrade.
Smaller Changes That Still Bite
Worth a line each. Spring Batch now runs in-memory by default โ on upgrade it stops persisting job metadata to your database unless you switch to spring-boot-starter-batch-jdbc; a genuinely silent behaviour change if you rely on restartability. Spring Security 7 applies CSRF protection to API endpoints by default โ stateless REST APIs that never sent CSRF tokens start returning 403s until configured accordingly. HttpMessageConverters is deprecated, Spring Session Hazelcast and MongoDB support are removed, Spock integration is removed, and embedded executable-jar launch scripts are gone.
The same service, migrated: what each failure actually looks like
Everything above says “this breaks”. This section shows how it breaks, in the order you will meet it. The companion module has a small Boot 3.5.16 service (before/pom.xml: Web, Security, Actuator, JDBC with Flyway, an AOP aspect, a RestClient.Builder consumer, a Jackson serializer, three tests) that builds and runs, then scripts/run-all.sh changes things one step at a time and records what the toolchain says. The “before” sources are never part of a build in the repository; the script copies them to a scratch directory.
Step 0: the baseline works
On 3.5.16 the service builds, passes its three tests, and every endpoint answers. Note the two facts to compare later: no liveness health group (404) and the log rotates (five rolled files). Transcript: output/01-before-boot35.txt.
$ mvn package # before/ as written: Boot 3.5.16, JDK 25.0.5
CSRFPROBE status=403
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar migration-demo-1.0.0.jar # Boot 3.5.16
GET /summary
{"id":1,"title":"Hello","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
GET /greetings/1
{"id":1,"title":"Hello #1","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
GET /greetings/count
{"rows":3} -> HTTP 200
GET /aspect
{"interceptions":1} -> HTTP 200
GET /gateway
{"client":"DefaultRestClient"} -> HTTP 200
GET /actuator/metrics (first 60 chars)
{"names":["application.ready.time","application.started.time
GET /actuator/health (groups and status only)
groups=None status=UP components=['db', 'diskSpace', 'ping', 'ssl']
GET /actuator/health/liveness
-> HTTP 404
after 40 more requests, log directory holds: 1 app.log; 5 app.log.<date>.<n>.gz; (app.log is 342 bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)
Step 1: change only the parent version
Maven stops before the compiler: spring-boot-starter-aop has no managed version any more (it was renamed spring-boot-starter-aspectj). After that rename, the Maven compiler plugin 3.15.0 crashed inside javac on JDK 25 without listing anything, so the transcript asks javac directly. Twelve errors, and the first line is the one most teams hit first. Transcript: output/02-bump-only-compile-errors.txt.
$ mvn -DskipTests package # same sources, same pom, only <parent><version> 3.5.16 -> 4.1.2
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing. @ line 25, column 17
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing. @ line 25, column 17
# spring-boot-starter-aop has no managed version any more. Rename it to spring-boot-starter-aspectj and try again:
$ mvn compile
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project migration-demo: Fatal error compiling: Cannot load from object array because "this.hashes" is null
[INFO] BUILD FAILURE
# Maven's compiler plugin 3.15.0 on JDK 25 crashed inside javac instead of listing the errors, so ask javac directly:
$ javac -cp $(mvn dependency:build-classpath) src/main/java/**/*.java
SecurityConfig.java:3: error: package org.springframework.boot.autoconfigure.security does not exist
import org.springframework.boot.autoconfigure.security.SecurityProperties;
JsonConfig.java:5: error: package com.fasterxml.jackson.core does not exist
import com.fasterxml.jackson.core.JsonGenerator;
JsonConfig.java:6: error: package com.fasterxml.jackson.databind does not exist
import com.fasterxml.jackson.databind.JsonSerializer;
JsonConfig.java:7: error: package com.fasterxml.jackson.databind does not exist
import com.fasterxml.jackson.databind.SerializerProvider;
JsonConfig.java:8: error: cannot find symbol
import org.springframework.boot.jackson.JsonComponent;
JsonConfig.java:12: error: cannot find symbol
public class JsonConfig extends JsonSerializer<Greeting> {
JsonConfig.java:11: error: cannot find symbol
@JsonComponent
JsonConfig.java:14: error: cannot find symbol
public void serialize(Greeting g, JsonGenerator gen, SerializerProvider sp) throws IOException {
JsonConfig.java:14: error: cannot find symbol
public void serialize(Greeting g, JsonGenerator gen, SerializerProvider sp) throws IOException {
SecurityConfig.java:14: error: cannot find symbol
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityConfig.java:16: error: method authorizeHttpRequests in class HttpSecurity cannot be applied to given types;
http.authorizeHttpRequests()
JsonConfig.java:13: error: method does not override or implement a method from a supertype
@Override
javac error count: 12
Correction to the compiler error quoted in Breaking Change #3. The block there (cannot find symbol … method authorizeHttpRequests(),.and()missing) is an illustration I wrote from memory; I never captured it. Compiling the old method-chaining configuration against Boot 4.1.2 gives a different, single error: “method authorizeHttpRequests in class HttpSecurity cannot be applied to given types … required: Customizer<…> found: no arguments”. The no-argument method is gone, so the failure is an arity mismatch, not a missing symbol. Real text, repository file: output/10-security-dsl-compile-errors.txt.
The old chain with only its import repointed, compiled on 4.1.2. Transcript: output/10-security-dsl-compile-errors.txt.
$ javac SecurityConfig.java # Boot 3 file, import repointed to the 4.1.2 package; the old method-chaining DSL left as it was
SecurityConfig.java:16: error: method authorizeHttpRequests in class HttpSecurity cannot be applied to given types;
http.authorizeHttpRequests()
^
required: Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry>
found: no arguments
reason: actual and formal argument lists differ in length
1 error
The fixed versions of the two files that carry most of these errors, from the migrated module: SecurityConfig.java (lambda DSL, SecurityFilterProperties) and JsonConfig.java (@JacksonComponent, tools.jackson, no IOException). The dependency block of after/pom.xml is where the starter renames land:
<dependencies>
<!-- main code: one starter per technology -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-flyway</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aspectj</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-restclient</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<!-- test code: each starter has a -test companion -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security-test</artifactId><scope>test</scope></dependency>
</dependencies>
Step 2: it compiles, and it will not start
With the imports ported and the aspectj rename done, the build is green. Start-up is not: RestClient itself lives in spring-web, so the code compiles, but the auto-configured RestClient.Builder now comes from spring-boot-starter-restclient. Transcript: output/03-compiles-but-will-not-start.txt.
$ # s2 = before/ + Boot 4.1.2 + aop->aspectj + JsonConfig and SecurityConfig ported (imports only); every other dependency untouched
$ mvn -DskipTests package
[INFO] BUILD SUCCESS
$ java -jar migration-demo-1.0.0.jar
APPLICATION DID NOT START. The failure report:
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.ankurm.migration.Gateway required a bean of type 'org.springframework.web.client.RestClient$Builder' that could not be found.
Action:
Step 3: it starts, answers 200, and is quietly wrong
After adding the one starter the failure report asked for, the application starts and most endpoints answer. Three things are wrong and none of them is an error at start-up: /greetings/count returns 500 because Flyway never ran (no spring-boot-starter-flyway, so the migration table was never created, and the log has zero Flyway lines); the log file is 54,188 bytes against a 2 KB limit because logging.file.max-size is no longer read; and /summary returns its properties in a different order. Transcript: output/04-starts-but-silently-wrong.txt.
$ # s3 = s2 + spring-boot-starter-restclient (the only thing the start-up failure asked for). Flyway, properties and the rest still Boot 3 style.
$ mvn -DskipTests package
[INFO] BUILD SUCCESS
$ java -jar migration-demo-1.0.0.jar
GET /summary
{"author":"ankur","createdAt":"2026-01-02T03:04:05Z","id":1,"title":"Hello"} -> HTTP 200
GET /greetings/1
{"id":1,"title":"Hello #1","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
GET /greetings/count
{"timestamp":"2026-10-29T05:55:01.660Z","status":500,"error":"Internal Server Error","path":"/greetings/count"} -> HTTP 500
GET /aspect
{"interceptions":1} -> HTTP 200
GET /gateway
{"client":"DefaultRestClient"} -> HTTP 200
GET /actuator/metrics (first 60 chars)
{"names":["application.ready.time","application.started.time
GET /actuator/health (groups and status only)
groups=['liveness', 'readiness'] status=UP components=['db', 'diskSpace', 'livenessState', 'ping', 'readinessState', 'ssl']
GET /actuator/health/liveness
-> HTTP 200
root cause of that 500 in the application log:
Table "GREETING" not found
Flyway log lines at start-up: 0
after 40 more requests, log directory holds: 1 app.log; (app.log is 54188 bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)
Step 4: what the properties migrator does and does not tell you
Adding spring-boot-properties-migrator to that same build names the renamed logging keys and maps them temporarily. It says nothing about the missing Flyway starter, because no property is involved. Transcript: output/05-properties-migrator.txt.
$ # s6 = s3 + spring-boot-properties-migrator (runtime scope)
[INFO] BUILD SUCCESS
app started
$ grep -iE 'propert|logging.file|migrat' run.log # what the migrator printed at start-up
WARN 6819 --- [ main] o.s.b.c.p.m.PropertiesMigrationListener :
The use of configuration keys that have been renamed was found in the environment:
Property source 'Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'':
Key: logging.file.max-history
Replacement: logging.logback.rollingpolicy.max-history
Key: logging.file.max-size
Replacement: logging.logback.rollingpolicy.max-file-size
Each configuration key has been temporarily mapped to its replacement for your convenience. To silence this warning, please update your configuration to use the new keys.
(lines matching: 3)
Step 5: the migrated service
The after/ module with every item fixed: 8 tests pass (mvn verify), Flyway runs (3 rows), logs rotate again, and the health endpoint now exposes the liveness and readiness groups (/actuator/health/liveness is 200 where it was 404 on 3.5.16). Transcript: output/06-after-boot41.txt.
$ mvn verify # after/ : Boot 4.1.2, one starter per technology, -test companions, Jackson 3, new packages
CSRFPROBE status=403
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar migration-demo-1.0.0.jar
GET /summary
{"author":"ankur","createdAt":"2026-01-02T03:04:05Z","id":1,"title":"Hello"} -> HTTP 200
GET /greetings/1
{"id":1,"title":"Hello #1","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
GET /greetings/count
{"rows":3} -> HTTP 200
GET /aspect
{"interceptions":1} -> HTTP 200
GET /gateway
{"client":"DefaultRestClient"} -> HTTP 200
GET /actuator/metrics (first 60 chars)
{"names":["application.ready.time","application.started.time
GET /actuator/health (groups and status only)
groups=['liveness', 'readiness'] status=UP components=['db', 'diskSpace', 'livenessState', 'ping', 'readinessState', 'ssl']
GET /actuator/health/liveness
-> HTTP 200
after 40 more requests, log directory holds: 1 app.log; 5 app.log.<date>.<n>.gz; (app.log is 513 bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)
Step 6: the tests
A Boot 3 test class, unchanged, against the Boot 4 dependencies. These are compile errors, so they are the easy kind. Transcript: output/08-test-compile-errors.txt.
$ mvn test-compile # after/ pom and main code, but the Boot 3 GreetingControllerTest (@MockBean, old @AutoConfigureMockMvc package)
t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[10,63] package org.springframework.boot.test.autoconfigure.web.servlet does not exist
t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[12,50] package org.springframework.boot.test.mock.mockito does not exist
t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[16,2] cannot find symbol
t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[20,6] cannot find symbol
[INFO] BUILD FAILURE
The nastier kind compiles. A plain Mockito @Mock field inside a @SpringBootTest was initialised by a listener that Boot 4 removed, so the field is simply null and the failure surfaces as an assertion (or an NPE in your own test). Transcript: output/09-test-runtime-failure.txt.
$ mvn test # after/ pom and main code + the Boot 3 MockFieldTest unchanged (compiles) and CsrfProbeTest (one import fixed)
CSRFPROBE status=403
[ERROR] MockFieldTest.mockFieldIsInitialised:20 @Mock field was never initialised ==> expected: not <null>
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
Going deeper: the fixed test classes and the runtime facts test
The migrated tests are in the module: GreetingControllerTest.java (@MockitoBean, org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc), MockFieldTest.java (@ExtendWith(MockitoExtension.class)) and RuntimeFactsTest.java, which pins the Flyway, aspect, RestClient.Builder, metrics and serializer behaviour so a regression fails the build. Sibling posts cover the test stack in depth: Testing Spring Boot 4 Without Containers and RestTestClient in Spring Framework 7.
The “breaks at runtime” checklist: compiles, starts, and is still wrong
Compile errors are the cheap kind: the build stops and tells you where. The expensive kind compiles and either fails during start-up or, worse, starts and behaves differently. This is the list I would give a reviewer. The last column says how each item is backed: Ran means it is a transcript from the companion module; Not run here means I am relaying the official Boot 4.0 Migration Guide or Boot 4.1 Release Notes (Spring Boot project wiki) and have not reproduced it.
| What changed | What you see | Evidence |
|---|---|---|
| A library with no Boot 4 starter (Flyway, Liquibase, and any technology that used to be “just add the jar”) | App starts, health is UP, the first query against the missing table returns 500. No Flyway line in the log. | Ran: output/04-starts-but-silently-wrong.txt. The guide lists spring-boot-starter-flyway and -liquibase explicitly (Boot 4.0 Migration Guide). |
RestClient.Builder / WebClient.Builder beans without spring-boot-starter-restclient / -webclient | Code compiles (the classes are in spring-web); start-up fails with “required a bean of type RestClient$Builder”. | Ran: output/03-compiles-but-will-not-start.txt (RestClient case only; the WebClient case is by analogy with the guide’s starter table). |
Old logging.file.* rolling keys (max-size, max-history, total-size-cap, clean-history-on-start) and logging.pattern.rolling-file-name | Silently ignored. The log never rotates; disk fills. | Ran: 54,188-byte log against a 2 KB limit (04) versus 513 bytes with the new logging.logback.rollingpolicy.* keys (06). Removal in 4.1.0 per the 4.1.0 configuration changelog. |
| Jackson 3 property order for plain beans | Same fields, different order in the JSON. Breaks consumers that compare strings, signatures or golden files. | Ran: declaration order on 3.5.16, alphabetical on 4.1.2, declaration order again with spring.jackson.use-jackson2-defaults=true (07). |
| Actuator health groups | /actuator/health now lists groups: [liveness, readiness]; /actuator/health/liveness exists. Anything that scrapes or alerts on the old shape sees a change. | Ran: 404 on 3.5.16, 200 on 4.1.2 (01, 06). Disable with management.endpoint.health.probes.enabled=false (Boot 4.0 Migration Guide). |
Plain @Mock / @Captor fields in @SpringBootTest classes | Fields are null; failures look like NPEs in tests that never changed. | Ran: output/09-test-runtime-failure.txt. Cause per Boot 4.0 Migration Guide: MockitoTestExecutionListener removed. |
Spring Batch with the plain spring-boot-starter-batch | Job metadata is no longer written to your database; restartability is lost. | Not run here. Boot 4.0 Migration Guide; a runnable Batch 5 to 6 demo is in the repository’s batch-5-to-6 module. |
Renamed property namespaces: spring.data.mongodb.* → spring.mongodb.*, spring.session.redis.* → spring.session.data.redis.*, spring.dao.exceptiontranslation.enabled → spring.persistence.exceptiontranslation.enabled, Kafka retry backoff.random → backoff.jitter | The old keys stop binding; the application falls back to defaults or loses the behaviour. | Not run here. Boot 4.0 Migration Guide. The properties migrator catches the ones it knows (05 shows it on the logging keys). |
| MongoDB UUID and BigDecimal representations | No default any more; you must set spring.mongodb.representation.uuid. Data written by a different default is unreadable. | Not run here. Boot 4.0 Migration Guide. |
Custom HttpMessageConverter beans | Beans contributed to the context are no longer picked up; use ClientHttpMessageConvertersCustomizer / ServerHttpMessageConvertersCustomizer. | Not run here. Boot 4.0 Migration Guide. |
server.forward-headers-strategy in a WAR on an external container | No longer has any effect; redirects and generated URLs use the wrong scheme or host. | Not run here. Boot 4.0 Migration Guide. |
| DevTools live reload | Disabled by default; set spring.devtools.livereload.enabled=true. (Deprecated outright in 4.1.0.) | Not run here. Boot 4.0 Migration Guide and Boot 4.1 Release Notes. |
| Tomcat base directory (4.1) | The temporary directory is deleted when the context closes; anything stored there between restarts is gone. Set server.tomcat.basedir to keep it. | Not run here. Boot 4.1 Release Notes. |
-DskipTests and AOT (4.1) | No longer skips test AOT processing; only maven.test.skip does. CI builds get slower or fail where they used to skip. | Not run here. Boot 4.1 Release Notes. |
Spring Data JPA bootstrap-mode (4.1) | deferred without an AsyncTaskExecutor bean now fails; lazy no longer sets a bootstrap executor. | Not run here. Boot 4.1 Release Notes. |
Redis master/replica with spring.data.redis.database (4.2.0-RC2 notes) | The configured database is now applied; before it was silently ignored and database 0 was used. You start reading a different database. | Not run here, and RC2 is not on Maven Central as of this date. Wiki release notes for 4.2.0-RC2 only. |
Two things I checked that did not hold. (1) The section “Smaller Changes That Still Bite” above says Spring Security 7 applies CSRF protection to API endpoints by default. I tested a Basic-authenticatedPOSTwithout a CSRF token through MockMvc on both versions: it returns 403 on Boot 3.5.16 and 403 on Boot 4.1.2 (theCSRFPROBEline in 01 and 06). On this evidence it is not a Boot 4 change for a default filter chain. If a service of yours began returning 403s after upgrading, look at what else changed in its chain before blaming CSRF. (2) Metrics:/actuator/metricsstill lists metrics in my migrated service without declaringspring-boot-starter-micrometer-metricsmyself, becausespring-boot-starter-actuator4.1.2 depends on it (read from its POM). Declare the starter explicitly if you rely on it.
Going deeper: what 4.1 adds on top of 4.0 (from the official release notes)
- Everything deprecated in 4.0 is removed in 4.1.0; compile with deprecation warnings on 4.0.x first, as you should have on 3.5.x.
- The layertools jar mode is removed (use
tools). Derby support is deprecated. jOOQ 3.20 requires Java 21. ReactorClientHttpRequestFactoryBuilderdefaults changed (proxyWithSystemProperties()).- New Jackson properties
spring.jackson.read.*andspring.jackson.write.*cover formats common to JSON, CBOR and XML.
Source: Boot 4.1 Release Notes. Listing taken October 29, 2026; none of these items were run.
What Actually Surprised Me Most About This Migration
I expected the Security rewrite to be the hardest part. It wasn’t โ IntelliJ’s quick-fix for the lambda DSL conversion is actually good, and the new API is cleaner once you’ve written it once. What I genuinely did not anticipate was how many internal libraries and shared starters my team maintained were still pointing at spring.factories. Three of them. In three different Git repos. None of them raised a compile error โ they just silently stopped registering their auto-configuration beans, which showed up as a NoSuchBeanDefinitionException for a completely unrelated class that happened to depend on one of those beans. Chasing that failure took most of a day.
The second surprise: enabling virtual threads surfaced a pinning issue in a legacy encryption utility that had been in production for four years without incident. The bug was always there โ it just never mattered until carrier threads started getting starved under concurrent load. The lesson is that enabling virtual threads is not free validation of your existing concurrency model. It’s a stress test of assumptions you’ve never had to defend before.
What Most Tutorials Don’t Tell You
1. Deprecated API Cleanup Must Happen Before the Version Bump
Every tutorial says “upgrade to Boot 4.” None of them say: your Boot 4 build will fail to compile if you have un-addressed deprecated API usages from Boot 3.x. The Spring team is thorough โ deprecated methods are removed, not just suppressed. The correct migration path is:
- Upgrade to the latest Boot 3.x minor (e.g., 3.4.x).
- Enable deprecation-as-error in your build (
-Werrorin javac orfailOnWarning=truein Maven Compiler). - Fix every deprecation warning.
- Then bump to Boot 4.
Skipping steps 2 and 3 means you’ll hit a wall of compilation errors on Boot 4 with no clear map of what needs changing. Teams that follow this process report that the actual Boot 4 version bump becomes a one-day task rather than a two-week scramble.
2. ThreadLocal Usage Becomes a Silent Data Leak
With virtual threads enabled, ThreadLocal variables are still supported but behave dangerously in thread-pool scenarios. A ThreadLocal value set on a virtual thread may persist longer than expected if the framework reuses the underlying carrier thread. In production systems with request-scoped security context propagation, this can cause context bleed between requests.
# application.properties
# Spring Security uses ThreadLocal for SecurityContextHolder by default.
# With virtual threads, switch to InheritableThreadLocal mode OR
# configure explicit propagation. Boot 4 sets the correct default,
# but custom ThreadLocal usage in your own code is your responsibility.
# VirtualThread-safe strategy for SecurityContextHolder:
spring.security.strategy=MODE_INHERITABLETHREADLOCAL
# Longer term: replace ThreadLocal with ScopedValue (Java 21 preview / Java 23 standard).
3. Your Health Checks May Return Wrong Status
Boot 4’s Actuator health endpoint refactors how composite health indicators aggregate status. If you have custom HealthIndicator beans with specific ordering dependencies, the aggregation order is no longer guaranteed by registration order โ it’s now controlled by @Order annotations and a new HealthEndpointGroup configuration. In Kubernetes deployments, a misconfigured liveness probe that returns UP when the application is actually unhealthy can block pod restarts and cause silent downtime.
Under the Hood: How Auto-Configuration Actually Loads in Boot 4
When your Spring Boot 4 application starts, SpringApplication.run() triggers a chain of events that’s worth understanding if you want to debug startup failures quickly:
- SpringFactoriesLoader is invoked โ but in Boot 4 it no longer reads the
EnableAutoConfigurationkey fromspring.factories. It still reads other keys (likeApplicationListenerorEnvironmentPostProcessor), sospring.factoriesisn’t dead โ it just no longer loads auto-configuration classes. - AutoConfigurationImportSelector reads AutoConfiguration.imports โ this file is loaded from each JAR on the classpath. One malformed line (wrong class name, wrong package) causes a
ClassNotFoundExceptionat startup that can be hard to trace if you have many starters. - @Conditional evaluation runs โ each auto-configuration class is evaluated against its
@ConditionalOnClass,@ConditionalOnMissingBean, etc. In Boot 4, the AOT engine pre-computes these conditions at build time for GraalVM native images โ dynamic conditional logic that worked fine in JVM mode may silently not register beans in native mode. - Virtual thread executor is registered (when enabled) โ with
spring.threads.virtual.enabled=true, Boot registers a virtual-thread executor for Tomcat/Jetty before your application beans are initialized, so any bean that assumes platform-thread semantics at startup is already running on virtual threads. (No Undertow here โ its support was removed in Boot 4.)
To debug startup issues, use the --debug flag or set logging.level.org.springframework.boot.autoconfigure=DEBUG. Boot 4 prints a detailed auto-configuration report showing exactly which conditions passed or failed for each class.
Automating the mechanical part with OpenRewrite
The recipe the “Best Practices” list below calls the “Spring Boot Migration Assistant” is OpenRewrite’s org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0. I confirmed that id exists by unzipping the rewrite-spring 6.37.1 jar from Maven Central (the latest release on October 29, 2026) and reading META-INF/rewrite/spring-boot-40.yml: it chains UpgradeSpringBoot_3_5, the Spring Framework 7 and Spring Security 7 upgrades, Spring Batch 6, SpringBootProperties_4_0, ReplaceMockBeanAndSpyBean, RelocateWebServerClasses, Hibernate and Testcontainers 2 migrations, and springdoc 3. Recipe page: Migrate to Spring Boot 4.0 (the OpenRewrite docs path for this recipe, docs.openrewrite.org/recipes/java/spring/boot4/upgradespringboot_4_0, returned 404 when I checked); how to run any recipe: OpenRewrite: running recipes.
The id shown in the older copy of this guide, org.openrewrite.java.spring.boot3.UpgradeSpringBoot_4_0, does not exist: the boot3 package holds the 3.x recipes, and its page 404s. Two limits worth knowing before you rely on it: the recipe targets 4.0, so you bump from 4.0.x to 4.1.x by hand, and it edits code and build files but cannot know that a technology now needs a new starter at run time. A full worked run, including what it left behind, is in Automating javax → jakarta and Boot 3 → 4 Upgrades with OpenRewrite (module openrewrite).
Common Mistakes During Spring Boot 3 to 4 Migration
- Bumping the parent version without auditing transitive dependencies. Your application pom.xml may look clean, but a single outdated internal library using
spring.factoriessilently drops its auto-configuration. - Assuming tests cover concurrency behavior. Most unit and integration tests run single-threaded. Virtual thread bugs (pinning, ThreadLocal bleed) only surface under concurrent load. Add load tests to your migration validation pipeline.
- Forgetting to update Dockerfile/CI base images. Your code compiles on Java 21 locally, but the CI agent still runs JDK 17. The build passes in dev and fails in the pipeline.
- Leaving
@EnableSchedulingwithout reviewing task executor wiring. In Boot 4, scheduled tasks may now run on virtual threads depending on your executor configuration. Scheduled jobs with synchronized internals can exhibit the pinning issue described above. - Not testing Spring Data JPA behavior. Spring Data 4.x changes lazy loading behavior for certain proxy configurations. N+1 query issues that were hidden by eager loading in previous versions may now surface.
Best Practices for a Smooth Spring Boot 3 to 4 Migration
- Create a migration branch per service โ don’t attempt to migrate a multi-module monorepo in one PR. Smaller scope means faster review and easier rollback.
- Run the Spring Boot Migration Assistant โ the official OpenRewrite recipe (
org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0) automates a large portion of the mechanical changes. It won’t catch everything, but it eliminates the busywork. - Pin third-party library versions explicitly โ don’t rely on Boot 4’s BOM to resolve a compatible version of libraries your team doesn’t control. Verify compatibility manually and pin.
- Enable
-Djdk.tracePinnedThreads=fullin staging โ run your full regression suite with this JVM flag to identify everysynchronizedblock that will pin virtual threads. Fix before going to production. - Validate Actuator endpoints after migration โ hit
/actuator/health,/actuator/metrics, and/actuator/infoand verify the response structure. Boot 4 changes some payload shapes. - Keep Boot 3.x in production until Boot 4 completes a full staging soak test โ at minimum 72 hours of production-equivalent traffic through the new version before cutting over.
A Quick War Story: Migrating a Payment Gateway
To give you an idea of what this looks like in practice, here’s a recent migration of a payment gateway handling ~8,000 requests/second at peak (Spring Boot 3.1, Java 17, Spring Security for JWTs, and Micrometer + Zipkin for observability):
Week 1 โ Deprecation audit on Boot 3.4.x. The Maven compiler deprecation-as-error flag surfaced 23 deprecated usages. Eighteen were in the team’s own code (mostly old HttpSecurity chaining). Five were in an internal auth-library that hadn’t been updated in 18 months. The library was updated and re-published before the Boot 4 bump began.
Week 2 โ Boot 4 version bump and build failure resolution. The build itself failed once โ the internal metrics library still used spring.factories for its MetricsAutoConfiguration. After migrating it to AutoConfiguration.imports, the build succeeded. Startup time was actually faster: virtual threads reduced Tomcat’s startup cost by approximately 15% due to lower OS thread creation overhead.
Week 3 โ Staging load test. With -Djdk.tracePinnedThreads=full enabled, the team found two synchronized blocks in a legacy encryption utility being pinned under concurrent load. Replacing them with ReentrantLock eliminated the warnings. Throughput in staging improved from ~8,200 req/s to ~11,400 req/s โ a 39% gain attributable almost entirely to virtual threads removing platform-thread contention on I/O waits to the payment processor API.
Week 4 โ Production cutover. Zero incidents. The observability payloads from the split observability modules were richer (unified span + metric correlation), which actually improved the team’s on-call experience.
A Few LLM Prompts That Can Help
If you’re using an AI assistant (Claude, ChatGPT, Copilot, etc.), these prompts can save you a ton of time during the migration:
- “Scan this Spring Boot service’s pom.xml and list all dependencies that might not have Spring Boot 4 compatible versions yet.”
- “Convert this HttpSecurity method-chaining configuration to the Boot 4 lambda DSL. Preserve all existing rules and explain each change.”
- “Find all uses of synchronized blocks in this codebase that could cause virtual thread pinning, and suggest ReentrantLock replacements.”
- “Review this spring.factories file and generate the equivalent AutoConfiguration.imports content for Spring Boot 4.”
- “This code uses MeterRegistry directly. Refactor it to use the Micrometer ObservationRegistry API for Boot 4 compatibility.”
What to Actually Do This Week
Migrating from Spring Boot 3 to 4 isn’t just a “bump the version and fix the red squigglies” exercise. The changes touch your concurrency model, security wiring, observability stack, and configuration loading. But if you do it carefully, the payoff is huge โ virtual threads alone can massively improve throughput for I/O-bound services without you touching a single line of business logic.
Here are your actionable steps for this week:
- Upgrade your current service to the latest Boot 3.x and enable deprecation-as-error. Fix everything that surfaces.
- Audit all internal libraries and custom starters for
spring.factoriesusage and migrate them toAutoConfiguration.imports. - Search your codebase for
synchronizedblocks on I/O paths and plan replacements before the Boot 4 bump. - Verify your CI pipeline and production Docker images are JDK 21 compatible.
- Run the OpenRewrite Boot 4 recipe on a feature branch and review the diff before committing.
If you treat this migration as just another dependency update, you’re going to have a bad time. Treat it as a runtime model upgrade โ because that’s exactly what it is.
See Also
- Spring and Spring Boot Tutorials on ankurm.com
- Spring Boot 3.x Is EOL. Don’t Stop at 4.0. Target 4.1 Instead.
- Spring Framework 6 to 7 Migration Guide
- Spring Security 5 to 6 to 7 Migration Guide
- Spring Data JPA 3 to 4 Migration Guide
- JUnit 6 Testing Series
- Distributed Computing Tutorials
- boot4-migration companion module (runnable before/after demo with captured output)
- Spring Boot Versions: Release and Support Dates
- Automating javax to jakarta and Boot 3 to 4 Upgrades with OpenRewrite
- Spring Framework 7.1 and Spring Boot 4.2: What’s Coming and How to Prepare
- Spring Boot 4.1 Release Notes (official)
- Spring Boot support dates feed (official, checked October 29, 2026)
No Comments yet!