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.factories → AutoConfiguration.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.x | Spring Boot 3.x |
|---|---|
spring-boot-starter-data-elasticsearch | Updated; check compatibility |
| Spring Cloud Sleuth | Removed — replaced by Micrometer Tracing |
spring-security-oauth2 (legacy) | spring-security-oauth2-resource-server |
spring-cloud-starter-netflix-ribbon | Removed — use spring-cloud-starter-loadbalancer |
spring-cloud-starter-netflix-zuul | Removed — 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:
- Removing explicitly deprecated dependencies
- Updating
spring.factoriesfiles if you have custom starters - 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.
| Library | Spring Boot 2.7.x | Spring Boot 3.5.x | Spring Boot 4.0.x |
|---|---|---|---|
| Spring Framework | 5.3.x | 6.2.x | 7.x |
| Java minimum | 8 | 17 | 17 (25 recommended) |
| Jakarta EE | EE 8 (javax.*) | EE 10 (jakarta.*) | EE 11 (jakarta.*) |
| Hibernate | 5.6.x | 6.4.x | 6.6.x |
| Spring Security | 5.8.x | 6.3.x | 7.x |
| Tomcat | 9.x | 10.1.x | 11.x |
| Spring Data | 2022 | 2024 | 2025 |
| JUnit | 4 or 5 | 5 (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.0UpgradeSpringBoot_3_2— 3.0 to 3.2UpgradeSpringBoot_3_3through3_5— incrementalJUnit4to5Migration— test codeSpringSecurity_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_0recipe - Verify all
javax.*imports changed tojakarta.* - Update Spring Security config to lambda DSL
- Replace
antMatchers()withrequestMatchers() - Replace
authorizeRequests()withauthorizeHttpRequests() - Check Hibernate 6 UUID and
@Lobtype 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.factoriestoAutoConfiguration.importsfor custom starters - Run full integration test suite against real databases
3.0 → 3.5 (incremental)
- Replace
@DynamicPropertySourcewith@ServiceConnectionin 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-retrydependency - Remove Undertow dependency; switch to Jetty if needed
- Audit Jackson exception handling (
JsonProcessingExceptionis nowRuntimeException) - Run JUnit 4 → 5 migration recipe
- Update
authorizeRequests()toauthorizeHttpRequests()(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.
