Skip to content

How to Migrate from Spring Boot 3.x to 4.0.0: A Practical Guide

Purpose

I migrated several Spring Boot applications from 3.x to 4.0.0 last month. This guide shows the exact steps I followed, the problems I encountered, and how I solved them.

Spring Boot 4.0.0 brings Jakarta EE 10 support, Java 21 baseline, and better observability. But it’s not just a version change. I found breaking changes in auto-configuration, dependency updates, and testing patterns that required careful handling.

This post walks through the complete migration process I used, from pre-migration assessment to production deployment.

Environment Context

Before starting, I verified my environment:

  • Current version: Spring Boot 3.2.x
  • Target version: Spring Boot 4.0.0
  • Java version: Java 17 → upgrading to Java 21
  • Build tool: Maven 3.9+ (Gradle 8.x also works)
  • Application type: REST API with JPA, Actuator, and security

Your setup might differ, but the core migration steps remain the same.

Pre-Migration Checklist

I learned the hard way that preparation matters. Before touching any dependencies, I ran these checks:

Terminal window
# Check current Spring Boot version
./mvnw dependency:tree | grep spring-boot
# Identify custom auto-configurations
find src -name "*AutoConfiguration*"
# Find deprecated property usage
grep -r "spring." src/ | grep -E "(deprecated|removed)"

I created a dedicated migration branch:

Terminal window
git checkout -b feature/spring-boot-4.0-migration

Then I backed up critical files:

  • application.properties and application.yml
  • pom.xml (or build.gradle for Gradle users)
  • Custom auto-configuration classes
  • Any custom health indicators

This saved me when I needed to reference original configurations.

Step 1: Update Parent POM and Java Version

I started by updating the parent POM version in pom.xml:

Maven:

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
</properties>

Gradle:

plugins {
id 'org.springframework.boot' version '4.0.0'
id 'io.spring.dependency-management' version '1.1.6'
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}

Alternative: Dependency Management Without Parent POM

If you can’t use the parent POM, import dependency management:

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>4.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

I also updated my CI/CD pipeline to use Java 21:

# GitHub Actions example
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

Step 2: Add Property Migrator

This was the most helpful tool I found. I added the property migrator temporarily:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
<scope>runtime</scope>
</dependency>

The migrator does two things:

  1. Prints diagnostics for renamed/removed properties at startup
  2. Temporarily maps old properties to new ones

When I started the application, it showed warnings like:

WARNING : The property 'spring.old.property' is deprecated.
Use 'spring.new.property' instead.

I went through each warning and updated my configuration files. Once all warnings were resolved, I removed the migrator dependency.

Step 3: Update Dependencies

After changing the parent POM, most starter dependencies updated automatically. But I verified these critical dependencies:

No version numbers needed - the parent POM manages them:

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</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-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

For third-party libraries, I added the Maven enforcer plugin to catch version conflicts:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>enforce-versions</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<dependencyConvergence/>
</rules>
</configuration>
</execution>
</executions>
</plugin>

This revealed conflicts I had to resolve with exclusions:

<dependency>
<groupId>com.example</groupId>
<artifactId>library</artifactId>
<exclusions>
<exclusion>
<groupId>org.old.dependency</groupId>
<artifactId>old-lib</artifactId>
</exclusion>
</exclusions>
</dependency>

Step 4: Migrate Test Code

This is where I hit the most issues. Spring Boot 4.0.0 fully deprecated JUnit 4, so I had to update tests.

OutputCapture migration:

Old (JUnit 4):

import org.junit.Rule;
import org.springframework.boot.test.system.OutputCaptureRule;
public class MyTest {
@Rule
public OutputCaptureRule outputCapture = new OutputCaptureRule();
@Test
public void testOutput() {
System.out.println("test");
assertThat(outputCapture.toString()).contains("test");
}
}

New (JUnit 5):

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.test.system.CapturedOutput;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(OutputCaptureExtension.class)
class MyTest {
@Test
void testOutput(CapturedOutput output) {
System.out.println("test");
assertThat(output).contains("test");
}
}

I also adopted the new MockMvcTester pattern for cleaner assertions:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
@WebMvcTest(UserVehicleController.class)
class MyControllerTests {
@Autowired
private MockMvcTester mvc;
@MockitoBean
private UserVehicleService userVehicleService;
@Test
void testExample() {
given(this.userVehicleService.getVehicleDetails("sboot"))
.willReturn(new VehicleDetails("Honda", "Civic"));
assertThat(this.mvc.get().uri("/sboot/vehicle")
.accept(MediaType.TEXT_PLAIN))
.hasStatusOk()
.hasBodyTextEqualTo("Honda Civic");
}
}

The AssertJ-style assertions are more readable and type-safe.

Step 5: Configuration Changes

I updated several configuration properties based on the migrator output:

Observability:

# RestClient observation enabled by default in 4.0.0
management.observation.rest-client.enabled=true
# Health indicators
management.health.defaults.enabled=true

Database:

# Verify connection pool settings still work
spring.datasource.hikari.maximum-pool-size=10
# JPA properties - mostly unchanged
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=false

To review auto-configuration changes, I enabled debug mode temporarily:

debug: true
# Or specific for auto-config
logging.level.org.springframework.boot.autoconfigure=DEBUG

The startup log showed which auto-configurations matched and which didn’t. I found that:

  • RestClient is now auto-configured with observation
  • Health indicators restructured (non-sealed interface)
  • Task executor builders updated

Step 6: Code Changes - RestClient Observation

One improvement I leveraged was RestClient auto-instrumentation. In Spring Boot 3.x, I had to manually instrument RestClient. In 4.0.0, it’s automatic:

@Service
public class ExternalApiService {
private final RestClient restClient;
public ExternalApiService(RestClient.Builder builder) {
this.restClient = builder
.baseUrl("https://api.example.com")
.build();
}
public ExternalData fetchData() {
// Automatically observed with Micrometer
return restClient.get()
.uri("/data")
.retrieve()
.body(ExternalData.class);
}
}

Now I get metrics automatically:

  • http.client.requests (timer)
  • Outgoing request tracking
  • Response status codes
  • Exception tracking

No manual instrumentation needed.

Step 7: Testing Strategy

I followed a layered testing approach:

1. Unit Tests

Terminal window
./mvnw clean test
./mvnw clean test jacoco:report # with coverage

2. Integration Tests I verified database migrations:

Terminal window
./mvnw flyway:migrate # or liquibase:update

3. Smoke Tests I created a basic smoke test class:

@SpringBootTest
@AutoConfigureMockMvc
class SmokeTests {
@Autowired
private MockMvc mvc;
@Test
void applicationContextLoads() {
// Basic sanity check
}
@Test
void actuatorHealthEndpointResponds() throws Exception {
mvc.perform(get("/actuator/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"));
}
}

4. Performance Baseline Before migration, I recorded:

  • Startup time
  • Response times for critical endpoints
  • Memory usage
  • Throughput

After migration, I compared and found no degradation. Java 21 actually improved performance in some cases.

Common Issues and Solutions

Here are the problems I encountered and how I fixed them:

Issue 1: Dependency Convergence Errors

Error:

[WARNING] Rule 0: org.apache.maven.plugins.enforcer.DependencyConvergenceRule failed

Solution:

Terminal window
# Find the conflict
./mvnw dependency:tree -Dverbose
# Exclude the transitive dependency

Issue 2: NoSuchBeanDefinitionException

Error:

No qualifying bean of type 'com.example.Repository' available

Solution:

  • Check auto-configuration report (debug: true)
  • Enable DEBUG logging for auto-config
  • Verify component scanning paths

Issue 3: @MockBean Not Working

Error:

Cannot use @MockBean on final classes

Solution: Switch to the new @MockitoBean annotation:

// Old
@MockBean
private MyService service;
// New (4.0.0)
@MockitoBean
private MyService service;

Issue 4: Deprecated Property Warnings

Error:

The property 'spring.old.property' is deprecated

Solution:

  • Let the property migrator show you the new property name
  • Update application.properties
  • Remove the migrator dependency after fixing all warnings

Post-Migration Checklist

Before considering the migration complete, I verified:

  • All tests pass (unit, integration, e2e)
  • Build succeeds without warnings
  • Application starts successfully
  • Actuator endpoints respond correctly
  • No deprecated property warnings in logs
  • Health checks pass
  • Performance benchmarks acceptable
  • Security scan passes

I also updated documentation:

  • README with Spring Boot 4.0.0
  • CI/CD pipeline configurations
  • Deployment documentation

Finally, I removed the property migrator:

<!-- Remove after migration complete -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
<scope>runtime</scope>
</dependency>

Deployment Strategy

I used a blue-green deployment approach:

  1. Deploy 4.0.0 to green environment
  2. Run smoke tests against green
  3. Switch traffic gradually (10%, 50%, 100%)
  4. Monitor metrics and error rates
  5. Rollback if issues detected

For database changes, I versioned migrations:

-- V4.0.0__add_new_column.sql
ALTER TABLE users ADD COLUMN preferences TEXT;

With rollback scripts ready:

-- U4.0.0__remove_new_column.sql
ALTER TABLE users DROP COLUMN preferences;

Summary

Migrating to Spring Boot 4.0.0 isn’t trivial, but it’s manageable with a systematic approach:

  1. Use the property migrator - it saves hours of debugging
  2. Update to Java 21 first - get that out of the way
  3. Migrate tests to JUnit 5 and MockMvcTester - cleaner, more maintainable
  4. Test at each layer - unit, integration, smoke, performance
  5. Deploy with blue-green strategy - safe rollback path

The key improvements I gained:

  • Java 21 performance benefits
  • Better observability with RestClient auto-instrumentation
  • Cleaner test code with MockMvcTester
  • Future-proof with Jakarta EE 10

Start with non-production environments, monitor closely, and plan your production rollout during a low-traffic window.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments