Enterprise Java

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

Based on community migration reports and issue trackers — relative frequency.

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=true to 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. @SpringBootTest with 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

#FailureVisible error?DetectionFix
1Jackson 3 silent deserialization✘ SilentSnapshot / contract testsuse-jackson2-defaults=true then migrate
2Undertow hard-stops at startup✔ Hard failureStartup crashSwitch to Tomcat or Jetty before migrating
3JUnit 4 classloading (zero tests run)✘ SilentCI coverage drops to 0Migrate to JUnit 5, replace @MockBean → @MockitoBean
4Spring Security CSRF 403s~ 403 with no clear causeREST API integration testsExplicit csrf().disable() in SecurityFilterChain
5Jackson module auto-registration✘ SilentJSON shape comparison testsfind-and-add-modules=false or migrate properties
6Modularised starters, missing beans~ Runtime NoSuchBeanDefinitionApplication context load testAdd explicit Boot 4 starters for each technology
7Java 17 baseline confusion✘ SilentRuntime version checkUpdate 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:

  1. Upgrade to the latest Spring Boot 3.5.x and fix every deprecation warning — @MockBeanWebSecurityConfigurerAdapter, and Boot-specific configuration properties all have 3.5 replacements.
  2. Switch from Undertow to Tomcat or Jetty while still on 3.5, and validate that behaviour is identical.
  3. Migrate your test suite to JUnit 5 on 3.5 — this is safer than doing it simultaneously with the Boot 4 bump.
  4. Add the spring-boot-properties-migrator dependency to catch renamed properties automatically at startup.
  5. Bump to Spring Boot 4.0 with spring.jackson.use-jackson2-defaults=true temporarily to isolate Jackson 3 issues from everything else.
  6. Update Docker base images and CI to Java 21+.
  7. 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.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button