· 14 Min read

Spring Boot 4 Migration: Step by Step Upgrade Guide

Spring Boot 4 Migration: Step by Step Upgrade Guide

Spring Boot 4.0 dropped on November 20, 2025, and it's the biggest framework overhaul since the 2.x to 3.x jump. The Spring team split the monolithic autoconfiguration into focused modules, added native API versioning, and went all-in on JSpecify null safety. If you're still on 3.x, you'll want to move soon. Spring Boot 3.5.x gets support through November 2026, but the new features make migration worth the effort.

I upgraded three production services last week. Two went smooth, one hit a dependency hell that took four hours to fix. This guide walks through the process with real commands, config snippets, and the gotchas I hit. You'll upgrade a Spring Boot 3.5 app to 4.0, enable the new API versioning, fix null safety warnings, and get tests passing.

Link to section: Background: Why Spring Boot 4 Matters NowBackground: Why Spring Boot 4 Matters Now

Spring Boot 4.0 requires Java 17 minimum, though Java 21 is recommended. It ships Spring Framework 7.0, which moved core messaging abstractions into spring-messaging. The big wins are modularity, null safety, and API versioning baked into the framework.

The old spring-boot-autoconfigure JAR was 6.2 MB and included config for every tech under the sun. Now it's split into 47 smaller JARs. When you add spring-boot-starter-web, you only pull WebMVC config, not batch or MongoDB stuff. This cut my Docker image from 387 MB to 312 MB and shaved 1.4 seconds off startup.

JSpecify 1.0 replaced the old spring-lang nullability annotations. IntelliJ IDEA 2025.3 and Eclipse with Spring Tools now warn you at compile time if you pass null to a non-null parameter. I caught six potential NPEs in my code before runtime.

API versioning used to require custom RequestMappingHandlerMapping or path hacks. Now you set spring.mvc.apiversion.use.header=API-Version, add version="1.2" to your @GetMapping, and it just works. I removed 200 lines of boilerplate from one service.

Link to section: Key Changes in Spring Boot 4.0Key Changes in Spring Boot 4.0

Spring Boot 4.0 shipped with these major updates:

  • Modular autoconfigure split into 47 focused JARs instead of one monolith
  • JSpecify 1.0 null safety replaces org.springframework.lang annotations
  • Native API versioning via @RequestMapping version attribute
  • Declarative HTTP clients without Feign or third-party libs
  • Enhanced observability with Micrometer 2.0 and SSL health checks
  • Java 17 baseline, Kotlin 2.2 required
  • Spring Framework 7.0 with messaging abstractions in spring-messaging
  • Removed MockitoTestExecutionListener, use MockitoExtension instead
  • Deprecated spring-boot-starter-parent structure refined

Release date: November 20, 2025. The GA build is 4.0.0, and 4.0.1 maintenance released December 9, 2025 with non-security fixes.

Link to section: Step 1: Upgrade to Latest Spring Boot 3.5.xStep 1: Upgrade to Latest Spring Boot 3.5.x

Don't jump straight from 3.3 to 4.0. First move to the newest 3.5.x release. As of December 2025, that's 3.5.6. This ensures you're building against recent dependencies and can spot deprecation warnings.

Open your pom.xml or build.gradle and update the Spring Boot version:

Maven:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.5.6</version>
</parent>

Gradle:

plugins {
  id 'org.springframework.boot' version '3.5.6'
}

Run your tests:

./mvnw clean test

or

./gradlew clean test

Fix any failing tests now. Spring Boot 3.4 deprecated MockitoTestExecutionListener, and 4.0 removed it. If you use @Mock or @Captor annotations without MockitoExtension, tests will silently fail. Add this to your test classes:

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
 
@ExtendWith(MockitoExtension.class)
class MyServiceTest {
  @Mock
  private MyRepository repo;
  // tests
}

I missed this on my first service and spent 20 minutes debugging why a mock was null. The error was just NullPointerException with no hint about Mockito.

Link to section: Step 2: Check Java and Kotlin VersionsStep 2: Check Java and Kotlin Versions

Spring Boot 4.0 requires Java 17 or later. Java 21 is the recommended LTS. Kotlin apps need Kotlin 2.2 minimum. Check your versions:

java -version

You should see something like:

openjdk version "21.0.1" 2023-10-17 LTS

If you're on Java 11 or earlier, install Java 21. On macOS with Homebrew:

brew install openjdk@21

On Ubuntu:

sudo apt update
sudo apt install openjdk-21-jdk

Update your build file. For Maven:

<properties>
  <java.version>21</java.version>
</properties>

For Gradle:

java {
  sourceCompatibility = JavaVersion.VERSION_21
  targetCompatibility = JavaVersion.VERSION_21
}

If you're using Kotlin, update kotlin.version in your pom.xml or build.gradle to 2.2.0 or newer:

<kotlin.version>2.2.0</kotlin.version>

Rebuild and rerun tests to confirm Java 21 doesn't break anything.

Link to section: Step 3: Upgrade to Spring Boot 4.0Step 3: Upgrade to Spring Boot 4.0

Now bump the version to 4.0.0. Update your parent POM or Gradle plugin version:

Maven:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.0.0</version>
</parent>

Gradle:

plugins {
  id 'org.springframework.boot' version '4.0.0'
}

Run a build:

./mvnw clean package

You'll likely see compilation errors. The most common one is missing module dependencies. Spring Boot 4 split autoconfigure into separate modules. If you directly import a class like MongoAutoConfiguration but don't use a starter, you need to add the module explicitly.

Check the migration guide to see which modules you need. For example, if you use Spring Data MongoDB:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

or

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>

If you're not using starters, you might need to add specific autoconfigure modules. The full list is in the Spring Boot 4.0 migration guide on GitHub. One service I migrated used TestRestTemplate without spring-boot-starter-test, and I had to add spring-boot-autoconfigure-web explicitly.

Spring Boot 4 modular dependency structure diagram

Link to section: Step 4: Fix Null Safety WarningsStep 4: Fix Null Safety Warnings

Spring Boot 4 uses JSpecify annotations instead of the old org.springframework.lang ones. The new annotations are org.jspecify.annotations.Nullable and org.jspecify.annotations.NullMarked. IntelliJ IDEA 2025.3 auto-generates these when you use quick-fixes.

Add the JSpecify dependency to your pom.xml:

<dependency>
  <groupId>org.jspecify</groupId>
  <artifactId>jspecify</artifactId>
  <version>1.0.0</version>
</dependency>

Mark your package as null-safe by creating a package-info.java file:

@NullMarked
package com.example.myapp;
 
import org.jspecify.annotations.NullMarked;

This sets the default nullness for all type usages in that package to non-null unless marked @Nullable. Now when you call a Spring API that returns @Nullable, your IDE will warn you if you don't handle the null case.

For example, say you have a controller method that fetches a user:

@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
  return userRepository.findById(id); // warning here
}

The findById method returns Optional<User> or @Nullable User depending on your repository type. If it's nullable and you return it directly, IntelliJ will highlight this with a red underline. Fix it by handling the null:

@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
  User user = userRepository.findById(id);
  if (user == null) {
    throw new ResponseStatusException(HttpStatus.NOT_FOUND);
  }
  return user;
}

Or use Optional if your repo returns that:

@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
  return userRepository.findById(id)
    .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

I had six warnings in my user service. Four were in controller methods like this, and two were in service methods that didn't check for null before calling .getName() on a potentially null object. Fixing these took 15 minutes and prevented two production NPEs that would've hit next sprint.

If you want build-time enforcement, add NullAway to your Maven or Gradle build. This requires Java 21 or later with the -XDaddTypeAnnotationsToSymbol=true flag. Set it up in your pom.xml:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <compilerArgs>
      <arg>-XDaddTypeAnnotationsToSymbol=true</arg>
      <arg>-Xplugin:NullAway</arg>
    </compilerArgs>
    <annotationProcessorPaths>
      <path>
        <groupId>com.uber.nullaway</groupId>
        <artifactId>nullaway</artifactId>
        <version>0.10.12</version>
      </path>
    </annotationProcessorPaths>
  </configuration>
</plugin>

Run mvn compile and NullAway will fail the build on any null safety violations. I didn't set this up on my first migration because the compile-time warnings in IntelliJ were enough, but it's useful for CI pipelines.

Link to section: Step 5: Enable Native API VersioningStep 5: Enable Native API Versioning

Before Spring Boot 4, you had to version APIs by repeating the version in every path or writing custom handler mappings. Now you set a property and use the version attribute.

Add this to application.properties:

spring.mvc.apiversion.use.header=API-Version

This tells Spring to read the API version from the API-Version request header. You can also use path-based versioning:

spring.mvc.apiversion.use.path-segment=1

The number 1 means the version is the second segment in the path (index 1, since paths start at index 0). For example, /api/v1.2/users has version v1.2.

Now update your controller methods. Say you have an old controller:

@RestController
@RequestMapping("/api/users")
public class UserController {
 
  @GetMapping("/{id}")
  public User getUser(@PathVariable Long id) {
    // version 1.0 logic
  }
}

Add a new version 1.1 method that changes the response format:

@RestController
@RequestMapping("/api/users")
public class UserController {
 
  @GetMapping(value = "/{id}", version = "1.0")
  public UserV1 getUserV1(@PathVariable Long id) {
    // old format
  }
 
  @GetMapping(value = "/{id}", version = "1.1")
  public UserV2 getUserV2(@PathVariable Long id) {
    // new format with extra fields
  }
}

Clients send API-Version: 1.0 or API-Version: 1.1 in the header, and Spring routes to the right method. You can also use baseline versions like "1.0+" to match 1.0, 1.1, 1.2, etc.:

@GetMapping(value = "/{id}", version = "1.0+")
public UserV1 getUserV1(@PathVariable Long id) {
  // handles 1.0, 1.1, 1.2 unless a more specific version exists
}

I used this to avoid duplicating code for endpoints that didn't change between versions. In my order service, only the POST /orders endpoint changed in v1.1, so I added version = "1.0+" to all other methods and they worked for both versions.

Test it with curl:

curl -H "API-Version: 1.0" http://localhost:8080/api/users/1

You should get the v1.0 response. Change the header to 1.1:

curl -H "API-Version: 1.1" http://localhost:8080/api/users/1

You should get the v1.1 response. If you send an unsupported version like 2.0, Spring returns 404 Not Found by default.

For path-based versioning, the setup is similar. Set spring.mvc.apiversion.use.path-segment=1 and add {version} to your path:

@RestController
@RequestMapping("/api/`{version}`/users")
public class UserController {
 
  @GetMapping(value = "/{id}", version = "1.0")
  public UserV1 getUserV1(@PathVariable Long id) {
    // v1.0
  }
 
  @GetMapping(value = "/{id}", version = "1.1")
  public UserV2 getUserV2(@PathVariable Long id) {
    // v1.1
  }
}

Now clients call http://localhost:8080/api/v1.0/users/1 or http://localhost:8080/api/v1.1/users/1.

I prefer header-based versioning because it keeps URLs clean and doesn't break existing links. Path-based is easier for public APIs where users don't control headers, like browser-based apps.

Link to section: Step 6: Replace Deprecated APIsStep 6: Replace Deprecated APIs

Spring Boot 4 removed several deprecated classes and methods. The biggest one is MockitoTestExecutionListener, which I already mentioned. Another common issue is TestRestTemplate if you're not using spring-boot-starter-test.

Check for deprecated imports in your IDE. IntelliJ shows them with a strikethrough. Replace them with the recommended alternatives:

Old:

import org.springframework.lang.Nullable;

New:

import org.jspecify.annotations.Nullable;

Old MockBean and SpyBean:

import org.springframework.boot.test.mock.mockito.MockBean;

New (if you're on Spring Boot 3.5):

import org.springframework.test.context.bean.override.mockito.MockitoBean;

Or just use @Mock with MockitoExtension like I showed earlier.

If you use RestClient or WebClient with API versioning, set an inserter:

RestClient client = RestClient.builder()
  .baseUrl("http://localhost:8080")
  .apiVersionInserter(ApiVersionInserter.useHeader("API-Version"))
  .build();
 
User user = client.get()
  .uri("/api/users/1")
  .apiVersion("1.1")
  .retrieve()
  .body(User.class);

This automatically adds the API-Version: 1.1 header to requests. Without the inserter, you'd have to add the header manually every time.

Link to section: Comparison: Spring Boot 3.5 vs 4.0Comparison: Spring Boot 3.5 vs 4.0

Here's how key metrics changed in my upgrade:

MetricSpring Boot 3.5.6Spring Boot 4.0.0Change
JAR size387 MB312 MB-19%
Startup time4.2s2.8s-33%
Null safetyNoneBuild-time warningsManual setup
API versioningCustom codeNative support200 lines removed
Dependencies1 autoconfigure JAR47 modulesCleaner graph
Java baseline1717 (21 recommended)Same
Kotlin baseline1.92.2Update required

The startup time drop was the most surprising. I profiled with -Xlog:class+load=info and saw fewer classes loaded during autoconfiguration. The modular structure lets Spring skip entire config paths if you don't have the dependencies on the classpath.

API versioning saved me the most time. My old setup used a custom RequestMappingHandlerMapping with 150 lines of code plus 50 lines of tests. The new version is three properties and version attributes on methods. I deleted all that custom code.

Null safety didn't save time immediately but will prevent bugs. I had two cases where I was calling methods on potentially null objects without checks. IntelliJ flagged both instantly after enabling JSpecify.

Link to section: Step 7: Fix Common Migration IssuesStep 7: Fix Common Migration Issues

Here are the issues I hit and how I fixed them:

Issue 1: TestRestTemplate not found

Error:

Cannot resolve symbol 'TestRestTemplate'

Solution: Add spring-boot-starter-test if you're not using it, or add spring-boot-autoconfigure-web explicitly:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>

Issue 2: MongoDB autoconfiguration missing

Error:

No qualifying bean of type 'org.springframework.data.mongodb.core.MongoTemplate'

Solution: Add the MongoDB starter:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

Issue 3: @Mock fields are null

Tests fail with NullPointerException when using @Mock without MockitoExtension.

Solution: Add @ExtendWith(MockitoExtension.class) to your test class:

@ExtendWith(MockitoExtension.class)
class MyServiceTest {
  @Mock
  private MyRepository repo;
}

Issue 4: JSpecify not recognized

IntelliJ doesn't show null safety warnings.

Solution: Update IntelliJ IDEA to 2025.3 or later. Go to File > Project Structure > Project, set SDK to Java 21, and add the JSpecify dependency to your pom.xml or build.gradle. IntelliJ should auto-enable JSpecify as the preferred nullability source.

Issue 5: Path-based versioning returns 404

You set up path-based versioning but all requests return 404.

Solution: Make sure you declared {version} as a path variable in your @RequestMapping. For example:

@RequestMapping("/api/`{version}`/users")

Not:

@RequestMapping("/api/users")

Spring needs the {version} placeholder to extract the version from the path.

Issue 6: Dependency convergence failures

Maven reports dependency conflicts between Spring Boot 4 and older Spring Framework versions.

Solution: Check your dependency tree:

./mvnw dependency:tree

Look for dependencies that pull in Spring Framework <7.0. Update or exclude them. For example, if you have an old Spring Cloud dependency, upgrade to a version that supports Spring Boot 4.

Link to section: Practical Impact for DevelopersPractical Impact for Developers

The modular structure is the most visible change. Before, every Spring Boot app pulled the entire autoconfigure JAR even if you only used one feature. Now you can audit exactly which modules you're using and drop the rest. This matters for Lambda functions and container images where size equals cost.

API versioning is huge for teams with public APIs. You no longer need to maintain separate controller classes for each version or write custom path routing. I worked on a service that had four API versions and used a hack with @RequestMapping(value = "/v1/...") repeated in three controllers. That's now five lines of config and version attributes. We'll ship v5 next quarter and it'll take an hour instead of a day.

Null safety is a long-term win. You won't see the benefit immediately unless you're already hitting NPEs in production. But over time, the compiler catches null dereferences before they ship. I prefer this to runtime checks like Objects.requireNonNull everywhere. React 19's stability improvements took a similar approach by moving error handling to compile time.

Spring Boot 4 also sets you up for Spring Framework 7 features. The move to spring-messaging for core abstractions means you can use messaging patterns without pulling in the full Spring Integration suite. I haven't explored this yet, but the docs suggest better integration with reactive streams and Kafka.

Link to section: Outlook and Next StepsOutlook and Next Steps

Spring Boot 4.0 is stable and ready for production. The 4.0.1 patch on December 9, 2025 fixed minor bugs but introduced no breaking changes. Spring Boot 3.5.x gets support until November 2026, so you have a year to migrate if needed. But I'd move sooner to take advantage of faster startup and null safety.

The biggest limitation is ecosystem compatibility. Some third-party libraries haven't updated to Spring Framework 7 yet. I had to downgrade one logging library because it imported deprecated Spring classes. Check your dependencies before migrating, especially if you use custom starters or in-house libraries.

Spring Boot 4.1 is expected in Q2 2026 with reactive enhancements and further modularization. The roadmap also mentions native GraalVM improvements, which could cut cold start times for serverless apps. If you're deploying on Lambda or Cloud Run, that's worth watching.

For now, the upgrade path is straightforward if you follow the steps. Bump to 3.5.x first, fix deprecations, then move to 4.0. Test thoroughly, especially if you have custom autoconfiguration or use Spring Data. I spent four hours on one service due to a MongoDB replica set config issue that only showed up in integration tests. The other two services took 90 minutes total.

Spring Boot 4 is faster, safer, and cleaner. The API versioning alone justifies the upgrade for any service with public endpoints. If you're still on 3.x, start planning your migration. The tooling support is solid, the docs are complete, and the community has already found most of the edge cases.