Spring Boot 3 → 4 Migration: The 7 Failures Nobody Warns You About
Our recent piece on what changed in Spring Boot 4 covered the headline features: modularised JARs, JSpecify null-safety, API versioning, and Jackson 3. That article answered the “what’s new?” question. This one answers the harder question — “what’s going to silently break in my application?” — and gives you the exact before/after fix for each scenario.
Spring Boot 4.0 shipped in November 2025, built on Spring Framework 7 and Jakarta EE 11. Since then, community migration reports have been remarkably consistent about which failures actually cost teams time. Most of them produce no compilation error and no obvious stack trace. They just make your application behave wrong, quietly, in production. Let’s go through each one.
Before anything else: the official Spring team guidance is to migrate to the latest Spring Boot 3.5.x first, eliminate every deprecation warning, and then bump to 4.0. Jumping directly from 3.2 or 3.3 to 4.0 amplifies every one of the failures below.
Most Common Failure Categories During Boot 3 → 4 Migration
Failure 1Jackson 3 Silent Deserialization Breaks
Of all the failures in this list, this one causes the most production incidents on teams that don’t read the fine print. Spring Boot 4 uses Jackson 3 as its JSON library, and the package names changed: com.fasterxml.jackson became tools.jackson. That part is visible and easy to find. What’s not obvious is the two default behaviour changes that ship silently.
Default 1 — Property ordering flipped. Jackson 2 serialised object fields in insertion order. Jackson 3 sorts them alphabetically by default. If any downstream client parses your JSON by field position, or if your snapshot tests assert on specific key ordering, they will start failing without any compile error.
Boot 3 — Jackson 2 output
{ "id": 42, "name": "Alice", "email": "alice@example.com" }
Boot 4 — Jackson 3 default output (alphabetical)
{ "email": "alice@example.com", "id": 42, "name": "Alice" }
Default 2 — Exception hierarchy change. In Jackson 2, JsonProcessingException extended IOException. In Jackson 3, JacksonException extends RuntimeException. Any catch (IOException e) block that was quietly catching Jackson serialisation errors will no longer catch them. The exception propagates unchecked. In production, a serialisation failure that previously returned a handled error response now becomes an unhandled exception.
Boot 3 — silently caught
try {
String json = objectMapper.writeValueAsString(payload);
} catch (IOException e) {
// Jackson 2: JsonProcessingException extends IOException
// This block fires on serialisation failure
return ResponseEntity.status(500).body("Serialisation error");
}
Boot 4 — must catch JacksonException
try {
String json = objectMapper.writeValueAsString(payload);
} catch (tools.jackson.core.JacksonException e) {
// Jackson 3: JacksonException extends RuntimeException
// IOException no longer catches this
return ResponseEntity.status(500).body("Serialisation error");
}
Quick escape hatch: set
spring.jackson.use-jackson2-defaults=trueto restore Jackson 2 behaviour temporarily. It’s a deprecated bridge, not a permanent solution — but it buys you time for a clean migration.
Failure 2Undertow Hard-Stops at Startup
If your application uses spring-boot-starter-undertow, it will simply refuse to start. Spring Boot 4 requires Servlet 6.1, and Undertow does not yet implement Servlet 6.1. There is no compatibility shim. There is no workaround. The fix is to switch to Tomcat or Jetty before migrating.
Boot 3 pom.xml — remove this
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
Boot 4 pom.xml — use Tomcat (default) or Jetty
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- Tomcat is the default — no extra config needed -->
</dependency>
Also, remove any server.undertow.* properties from your application.yml. They have direct equivalents under server.tomcat.*.
Boot 3 application.yml
server:
undertow:
threads:
worker: 200
io: 8
Boot 4 application.yml
server:
tomcat:
threads:
max: 200
Failure 3JUnit 4 Classloading Surprises at Runtime
Spring Boot 4 has removed JUnit 4 support entirely. The testing stack now aligns with JUnit 5 (Jupiter) exclusively. The failure mode is especially confusing because JUnit 4 tests often compile fine — the annotations exist on the classpath from transitive dependencies — but they silently do nothing at runtime. No failure, no output, just zero test coverage from any JUnit 4 class.
Additionally, @MockBean and @SpyBean were deprecated in 3.4 and removed in 4.0. If your test classes still use them, you’ll get a compilation error — which is at least obvious. The replacement, introduced in 3.5, is Mockito’s own @MockitoBean and @MockitoSpyBean.
Boot 3 — JUnit 4 style (silently ignored in Boot 4)
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
public class OrderServiceTest {
@MockBean
private PaymentGateway paymentGateway;
@Test
public void shouldProcessOrder() {
// This test runs under Boot 3, silent no-op under Boot 4
}
}
Boot 4 — JUnit 5 + @MockitoBean
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@SpringBootTest
class OrderServiceTest {
@MockitoBean
private PaymentGateway paymentGateway;
@Test
void shouldProcessOrder() {
// Correct — runs under Boot 4
}
}
Watch for this: If your CI pipeline reports “0 tests found” on a suite that used to pass, JUnit 4 classloading is almost certainly the reason. A quick grep for
import org.junit.Test;(without.jupiter) in your test sources will surface every affected class.
Failure 4. Spring Security CSRF Silently Breaks REST APIs
This failure is particularly insidious because it shows up as a legitimate-looking HTTP 403 — one that looks just like an authorisation problem rather than a configuration one. Spring Boot 4 enables CSRF protection for API endpoints by default. In Boot 3, CSRF only applied to form-based applications. REST APIs that don’t send a CSRF token will start returning 403 after migration, and nothing in the logs will tell you why.
Furthermore, the old WebSecurityConfigurerAdapter — already deprecated in Spring Security 5.7 — is completely removed. If you have any security configuration extending it, the application will not compile.
Boot 3 — WebSecurityConfigurerAdapter (removed in Boot 4)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and()
.csrf().disable();
}
}
Boot 4 — SecurityFilterChain bean + explicit CSRF disable for REST
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.csrf(csrf -> csrf.disable()); // explicit for stateless REST APIs
return http.build();
}
}
Failure 5Jackson Module Auto-Registration Changes Your JSON Shape
In Spring Boot 3, Jackson only registered “well-known” modules automatically (the JavaTimeModule, for example). In Spring Boot 4, Jackson detects all modules present on the classpath and registers them automatically. This sounds like a convenience — and it is — but it can subtly alter the shape of your serialised JSON if modules you didn’t know were on your classpath start applying their transformations.
The most common symptom: date/time fields that previously serialised as epoch timestamps start serialising as ISO-8601 strings, or vice versa, depending on which modules get picked up. Integration tests comparing exact JSON output start failing even though the core logic is unchanged.
Boot 3 application.yml — these property names are gone
spring:
jackson:
parser:
allow-comments: true
generator:
auto-close-target: true
Boot 4 application.yml — new property namespace
spring:
jackson:
json:
read:
allow-comments: true
# Disable auto-detection if you need Boot 3 module behaviour:
find-and-add-modules: false
The spring.jackson.find-and-add-modules=false property is your escape hatch if auto-detection is causing problems. Add the spring-boot-properties-migrator dependency temporarily during migration — it will print diagnostics at startup for every renamed or removed property.
Failure 6Modularised Starters Break Transitive Auto-Config
Spring Boot 4 split its monolithic spring-boot-autoconfigure JAR into 70+ focused modules. The practical effect: several things that were auto-configured “for free” via transitive classpath presence now require an explicit starter. RestClient, WebClient, Flyway, and Liquibase are common examples — Boot 4 no longer auto-configures them from classpath presence alone.
The failure mode is subtle: the application compiles, starts, and then fails at runtime when it tries to inject a bean that simply doesn’t exist in the application context. No compile error, just a NoSuchBeanDefinitionException.
Boot 3 pom.xml — Flyway just worked via web starter
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Auto-config picked it up automatically in Boot 3 -->
Boot 4 pom.xml — explicit Boot starter required
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
Pro tip: After migrating, write a test that explicitly loads the full application context and asserts the existence of your critical beans.
@SpringBootTestwith a context load check catches missing auto-configuration long before production.
Failure 7Java Baseline Confusion — It’s 21, Not 17
The Spring Boot 4 documentation says Java 17 is the minimum. That’s technically accurate, but it creates a false sense of safety. Jakarta EE 11 features depend on Java 21 APIs, and Spring Boot 4’s Virtual Thread support — which is one of the headline performance features — is also a Java 21 feature. Running on Java 17 means you miss the most important runtime improvements that make the migration worthwhile in the first place.
Additionally, Docker base images and CI/CD pipelines that haven’t been updated will silently pull older JDK images. The application starts, runs, and you have no idea you’re not benefiting from Virtual Threads or the improved GC behaviour. Java 25 is the production-recommended target in 2026.
Dockerfile — stale after Boot 4 migration
FROM eclipse-temurin:17-jre-alpine COPY target/app.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar"]
Dockerfile — Boot 4 with Virtual Threads on Java 21+
FROM eclipse-temurin:21-jre-alpine COPY target/app.jar app.jar ENTRYPOINT ["java", \ "-XX:+UseZGC", \ "-Dspring.threads.virtual.enabled=true", \ "-jar", "app.jar"]
All 7 Failures at a Glance
| # | Failure | Visible error? | Detection | Fix |
|---|---|---|---|---|
| 1 | Jackson 3 silent deserialization | ✘ Silent | Snapshot / contract tests | use-jackson2-defaults=true then migrate |
| 2 | Undertow hard-stops at startup | ✔ Hard failure | Startup crash | Switch to Tomcat or Jetty before migrating |
| 3 | JUnit 4 classloading (zero tests run) | ✘ Silent | CI coverage drops to 0 | Migrate to JUnit 5, replace @MockBean → @MockitoBean |
| 4 | Spring Security CSRF 403s | ~ 403 with no clear cause | REST API integration tests | Explicit csrf().disable() in SecurityFilterChain |
| 5 | Jackson module auto-registration | ✘ Silent | JSON shape comparison tests | find-and-add-modules=false or migrate properties |
| 6 | Modularised starters, missing beans | ~ Runtime NoSuchBeanDefinition | Application context load test | Add explicit Boot 4 starters for each technology |
| 7 | Java 17 baseline confusion | ✘ Silent | Runtime version check | Update Docker images and CI to Java 21+ (25 recommended) |
The Recommended Migration Order
Given all of the above, the safest way to approach this migration is staged rather than atomic. Jumping straight from Boot 3.2 to 4.0 compounds every failure simultaneously, which makes debugging significantly harder. Instead, work through these steps in order:
- Upgrade to the latest Spring Boot 3.5.x and fix every deprecation warning —
@MockBean,WebSecurityConfigurerAdapter, and Boot-specific configuration properties all have 3.5 replacements. - Switch from Undertow to Tomcat or Jetty while still on 3.5, and validate that behaviour is identical.
- Migrate your test suite to JUnit 5 on 3.5 — this is safer than doing it simultaneously with the Boot 4 bump.
- Add the
spring-boot-properties-migratordependency to catch renamed properties automatically at startup. - Bump to Spring Boot 4.0 with
spring.jackson.use-jackson2-defaults=truetemporarily to isolate Jackson 3 issues from everything else. - Update Docker base images and CI to Java 21+.
- Progressively remove Jackson 2 compatibility shims and migrate to native Jackson 3 APIs.
OpenRewrite saves hours: OpenRewrite’s Boot 4 recipe set handles property renames, deprecated API replacements, and package migration automatically. It doesn’t catch everything (Jackson behaviour defaults and contract assumptions require manual review), but it handles enough of the boilerplate to be worth running before any manual work begins.
What We Learned
Spring Boot 4 is a genuine platform migration, not a version bump — and its most dangerous failures are the silent ones. Jackson 3’s property ordering flip and exception hierarchy change break client contracts without a single compile error. JUnit 4 tests vanish from your CI report without a failure. Spring Security’s CSRF default change produces 403s that look like authorisation bugs. Undertow is the only hard crash, and ironically the easiest to fix. The pattern across all seven failures is the same: the application starts, looks healthy, and behaves wrong. The antidote is a staged migration, a comprehensive test suite, and the Jackson 2 defaults shim as a safety net until you’re ready for the full cut-over.





