Spring Boot 2.x → 3.x → 4.x Migration: The Definitive Checklist

Many teams are still running Spring Boot 2.7.x. Spring Boot 2.x reached end of life in November 2023, which means no more security patches. The jump to 4.0 is two generations, and the breaking changes are real — but they are also well-documented and mostly automatable.

This guide walks through the migration in stages: 2.x → 3.0 first, then 3.x incremental updates, then 4.0. Each section lists what breaks and how to fix it.


The Migration Strategy: Do It in Stages

Do not attempt to jump directly from 2.x to 4.0 in one commit. The two-stage approach is significantly less painful:

flowchart LR
    A[Spring Boot 2.7.x\nLast 2.x release] --> B[Spring Boot 3.0\nJakarta EE migration]
    B --> C[Spring Boot 3.3/3.4\nFix deprecations]
    C --> D[Spring Boot 3.5\nFinal 3.x minor]
    D --> E[Spring Boot 4.0\nModularisation + Security 7]

Get to a passing 2.7.x test suite. Upgrade to 3.0. Fix the Jakarta compile errors. Run the tests. Then upgrade through 3.x minors, fixing deprecations along the way. Arriving at 4.0 from a clean 3.5 is much easier than jumping from 2.x.


Stage 1: Spring Boot 2.x → 3.0

Prerequisite: Java 17

Spring Boot 3.0 requires Java 17 as a minimum. If you are on Java 8 or 11, upgrade first.

# Check your current Java version
java -version

# Update your pom.xml Java version
<properties>
    <java.version>17</java.version>
</properties>

The Big One: javax → jakarta

Every javax.* import changes to jakarta.*. This affects your entire codebase — controllers, entities, validators, servlet filters, everything.

// Before (Spring Boot 2.x)
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.validation.constraints.NotNull;
import javax.servlet.http.HttpServletRequest;
import javax.annotation.PostConstruct;

// After (Spring Boot 3.x+)
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotNull;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.annotation.PostConstruct;

Automate this with OpenRewrite:

<!-- pom.xml — add temporarily -->
<plugin>
    <groupId>org.openrewrite.maven</groupId>
    <artifactId>rewrite-maven-plugin</artifactId>
    <version>5.44.0</version>
    <dependencies>
        <dependency>
            <groupId>org.openrewrite.recipe</groupId>
            <artifactId>rewrite-spring</artifactId>
            <version>5.22.0</version>
        </dependency>
    </dependencies>
</plugin>
./mvnw rewrite:run -Drewrite.activeRecipes=\
  org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0

This recipe handles: javax → jakarta imports, Spring Security config changes, deprecated API migrations, and more. Run it on a clean branch and review the diff.

Spring Security 6: Lambda DSL Is Now Mandatory

Spring Security 6 removes the chained configuration style in favour of the lambda DSL:

// Before (Spring Security 5.x — removed in 6)
http
    .authorizeRequests()
        .antMatchers("/public/**").permitAll()
        .antMatchers("/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
    .and()
    .formLogin()
        .loginPage("/login").permitAll()
    .and()
    .logout().permitAll();

// After (Spring Security 6+)
http
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/public/**").permitAll()
        .requestMatchers("/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
    )
    .formLogin(form -> form
        .loginPage("/login").permitAll()
    )
    .logout(logout -> logout.permitAll());
return http.build();

Note: antMatchers() is also removed — use requestMatchers() instead.

Hibernate 6: Type Mapping Changes

Hibernate 6 changes how several types are mapped to database columns by default.

UUIDs:

// Hibernate 5 (Boot 2.x): UUID mapped as BINARY(16) or VARCHAR(36) depending on DB
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;

// Hibernate 6 (Boot 3.x+): UUID mapped as native UUID type in PostgreSQL
// This can break existing data if your columns are VARCHAR or BINARY
// Force the old behaviour with:
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@JdbcTypeCode(SqlTypes.VARCHAR)
private UUID id;

@Lob on Strings:

// Hibernate 5: @Lob on String mapped to CLOB
@Lob
private String content;

// Hibernate 6: @Lob on String now maps to TEXT or LONGVARCHAR — different SQL type
// Check your schema if this matters

Run your full integration test suite against the real database after upgrading. Schema differences often only surface against the actual DB.

spring.factoriesAutoConfiguration.imports

If you maintain custom auto-configuration, the registration file changed:

# Old location (still works in Boot 3 but deprecated):
META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.MyAutoConfiguration

# New location (required from Boot 4):
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.MyAutoConfiguration

Dependencies That Changed Names

Spring Boot 2.xSpring Boot 3.x
spring-boot-starter-data-elasticsearchUpdated; check compatibility
Spring Cloud SleuthRemoved — replaced by Micrometer Tracing
spring-security-oauth2 (legacy)spring-security-oauth2-resource-server
spring-cloud-starter-netflix-ribbonRemoved — use spring-cloud-starter-loadbalancer
spring-cloud-starter-netflix-zuulRemoved — use spring-cloud-gateway

Stage 2: Spring Boot 3.x Incremental Updates

Once on 3.0, these are the notable additions in each 3.x minor. Fix deprecations as you go — they become errors in 4.0.

Spring Boot 3.1: Testcontainers @ServiceConnection

The old way of wiring Testcontainers into Spring properties required @DynamicPropertySource:

// Boot 3.0 way — verbose
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}

Spring Boot 3.1 makes this a one-liner with @ServiceConnection:

// Boot 3.1+ way — clean
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");

// That's it. Spring Boot reads the connection details from the container automatically.

Spring Boot 3.2: Virtual Threads

Enable virtual threads in one property:

spring:
  threads:
    virtual:
      enabled: true

This switches Tomcat to use a virtual-thread-per-request model instead of a pooled thread model. Requires Java 21+. For I/O-bound applications, this often matches WebFlux throughput with much simpler code.

Spring Boot 3.3: CDS (Class Data Sharing)

CDS improves JVM startup time by sharing pre-processed class metadata across JVM instances:

# Generate the CDS archive
java -XX:ArchiveClassesAtExit=application.jsa -jar app.jar

# Start with CDS
java -XX:SharedArchiveFile=application.jsa -jar app.jar
# Result: 20-40% faster JVM startup in typical apps

Spring Boot 3.3 integrates CDS into the spring-boot-maven-plugin:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <cdsMode>enabled</cdsMode>
    </configuration>
</plugin>

Spring Boot 3.4: Structured Logging

Structured JSON logs without any third-party logging library:

# application.yml
logging:
  structured:
    format:
      console: logstash   # or: ecs (Elastic Common Schema), gelf (Graylog)

Output changes from:

2026-05-03 10:15:32.123  INFO 12345 --- [main] c.example.UserService : User created: john@example.com

To:

{"@timestamp":"2026-05-03T10:15:32.123Z","log.level":"INFO","message":"User created: john@example.com","service.name":"my-app","process.pid":12345,"log.logger":"c.example.UserService"}

This is what log aggregation tools (Grafana Loki, Elasticsearch, Datadog) expect. Previously required the Logstash Encoder dependency and manual Logback configuration.


Stage 3: Spring Boot 3.5 → 4.0

Arriving at 4.0 from a clean 3.5 is mostly about:

  1. Removing explicitly deprecated dependencies
  2. Updating spring.factories files if you have custom starters
  3. Addressing the breaking changes described in the Boot 4 article

Remove spring-retry Dependency

<!-- Remove this from pom.xml — spring-retry is now in framework core -->
<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

Spring Framework 7 includes @Retryable and related annotations natively.

Remove Undertow If Used

<!-- Remove this -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

<!-- Add Jetty instead -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jetty</artifactId>
</dependency>

Fix Jackson Exception Handling

// Audit any code that catches JsonProcessingException as a checked exception
// In Boot 4 / Jackson 3, it is a RuntimeException

// This pattern is now redundant (method no longer needs to declare throws):
public String serialize(Object obj) throws JsonProcessingException {  // remove throws
    return objectMapper.writeValueAsString(obj);
}

// This try-catch still works but is now catching a RuntimeException:
try {
    return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
    // Still caught, but now it is a RuntimeException subtype
    log.error("Serialization failed", e);
    throw new RuntimeException(e);
}

Update Spring Security Config

// Remove this (removed in Security 7):
http.authorizeRequests(...)

// Use this:
http.authorizeHttpRequests(...)

// If your REST API is stateless (JWT-based), explicitly disable CSRF:
http.csrf(csrf -> csrf.disable());

Migrate JUnit 4 Tests

./mvnw rewrite:run -Drewrite.activeRecipes=\
  org.openrewrite.java.testing.junit5.JUnit4to5Migration

Dependency Version Reference

Spring Boot manages all these versions. When you set your Boot version, these align automatically.

LibrarySpring Boot 2.7.xSpring Boot 3.5.xSpring Boot 4.0.x
Spring Framework5.3.x6.2.x7.x
Java minimum81717 (25 recommended)
Jakarta EEEE 8 (javax.*)EE 10 (jakarta.*)EE 11 (jakarta.*)
Hibernate5.6.x6.4.x6.6.x
Spring Security5.8.x6.3.x7.x
Tomcat9.x10.1.x11.x
Spring Data202220242025
JUnit4 or 55 (JUnit 4 deprecated)5 (JUnit 4 removed)

Using OpenRewrite for Automated Migration

OpenRewrite is an automated refactoring tool with recipes for Spring Boot migrations. For large codebases, it handles the mechanical changes (imports, API renames) so you can focus on the logic-level changes.

<!-- pom.xml -->
<build>
    <plugins>
        <plugin>
            <groupId>org.openrewrite.maven</groupId>
            <artifactId>rewrite-maven-plugin</artifactId>
            <version>5.44.0</version>
            <dependencies>
                <dependency>
                    <groupId>org.openrewrite.recipe</groupId>
                    <artifactId>rewrite-spring</artifactId>
                    <version>5.22.0</version>
                </dependency>
                <dependency>
                    <groupId>org.openrewrite.recipe</groupId>
                    <artifactId>rewrite-testing-frameworks</artifactId>
                    <version>2.22.0</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>
# Preview what OpenRewrite would change (dry run)
./mvnw rewrite:dryRun -Drewrite.activeRecipes=\
  org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0

# Apply the changes
./mvnw rewrite:run -Drewrite.activeRecipes=\
  org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0

Recipes available:

  • UpgradeSpringBoot_3_0 — 2.x to 3.0
  • UpgradeSpringBoot_3_2 — 3.0 to 3.2
  • UpgradeSpringBoot_3_3 through 3_5 — incremental
  • JUnit4to5Migration — test code
  • SpringSecurity_6_0 — Security config

After running OpenRewrite, review all changes. It handles imports and simple API renames well but cannot handle logic changes (like updating your data access layer for Hibernate 6 type mapping differences).


Migration Checklist

2.x → 3.0

  • Upgrade to Java 17+
  • Run OpenRewrite UpgradeSpringBoot_3_0 recipe
  • Verify all javax.* imports changed to jakarta.*
  • Update Spring Security config to lambda DSL
  • Replace antMatchers() with requestMatchers()
  • Replace authorizeRequests() with authorizeHttpRequests()
  • Check Hibernate 6 UUID and @Lob type mappings against your schema
  • Remove Spring Cloud Sleuth; add Micrometer Tracing
  • Replace Ribbon with spring-cloud-starter-loadbalancer
  • Replace Zuul with Spring Cloud Gateway
  • Update spring.factories to AutoConfiguration.imports for custom starters
  • Run full integration test suite against real databases

3.0 → 3.5 (incremental)

  • Replace @DynamicPropertySource with @ServiceConnection in tests
  • Enable virtual threads if on Java 21+ (spring.threads.virtual.enabled=true)
  • Enable structured logging (logging.structured.format.console=logstash)
  • Enable CDS for faster JVM startup
  • Fix all remaining deprecation warnings

3.5 → 4.0

  • Remove spring-retry dependency
  • Remove Undertow dependency; switch to Jetty if needed
  • Audit Jackson exception handling (JsonProcessingException is now RuntimeException)
  • Run JUnit 4 → 5 migration recipe
  • Update authorizeRequests() to authorizeHttpRequests() (if not done in 3.x)
  • Disable CSRF explicitly if your API is stateless JWT-based
  • Update custom auto-configuration to reference modular AutoConfiguration.imports
  • Review IDE warnings from JSpecify null annotations
  • Run full test suite

The migration is real work but it is well-structured. Most of the mechanical changes (import renames, API renames) are automatable with OpenRewrite. The logic-level changes (Security config rewrites, Hibernate type mapping, Jackson exception handling) are what need careful review. Do them in stages, test at each stage, and the upgrade becomes manageable.

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.