Posts

How to Migrate a Spring Boot 3 Project to Spring Boot 4

Catherine Edelveis

18

TLDR

  • Spring Boot 3.5 reached open source end of life on June 30, 2026. The final free patch update was 3.5.16.
  • The most surprising migration outcome was when pom.xml compiled and ran on Boot 4, although most starters in it were already deprecated.
  • A Jackson rename went together with an API change. Fixing the import and the class name wasn’t enough as the method I was calling didn't exist anymore.

The migration has to happen

Spring Boot 3.5 reached the open-source end of life (EOL) on June 30, 2026. The last community release was 3.5.16. If you use 3.5, no next open source patch is coming, so the migration will have to happen.

But I believe you already have the objection: why bother studying what changed? My agent will migrate the project for me.

Fair enough. Manual migration in 2026 feels obsolete, and agents are good at this kind of work. OpenRewrite, for example, offers Boot 4 recipes that do most of the migration.

And yet, I’m afraid I have to disappoint you: you still need to know what changed.

There are four reasons for that. Sometimes nothing fails and you still have to change the code, because the dependency you are using has been marked for removal. Sometimes a rename comes with an API change. Sometimes behavior changes, but an agent facing a failing assertion will rewrite it to match the new output instead of asking whether that output is correct. And sometimes the code is too custom. An agent knows Spring, but it doesn’t know why your code was written that particular way.

Without further ado, let's see what Spring Boot 4 brough to us.

What changed in Spring Boot 4

Boot 4 is the new bedrock for the Spring ecosystem: Spring Framework 7, Jakarta EE 11, and a new generation of dependencies. We’re not going to look at new exciting features here because we have to deal with the migration before enjoying them.

Modularization and starter restructuring

Boot 4 breaks the bigger jars into smaller modules, each focused on one technology. spring-boot-starter-web becomes spring-boot-starter-webmvc, Flyway gets its own starter, and test support splits into per-technology starters. This change affects the build file, imports, and test dependencies.

Spring Framework 7 and everything under it

Boot 4.1 rests upon Spring Framework 7, which brings a new generation of various technologies: Jakarta EE 11, Servlet 6.1, JPA 3.2, Hibernate ORM 7.x, Bean Validation 3.1, JUnit 6, Kotlin 2.3, GraalVM 25 for native images. Java stays at 17 minimum and is supported up to 26. The extent of the impact depends on how deep into those APIs your code goes.

Jackson 2 becomes Jackson 3

Jackson 3 is the preferred JSON library in Boot 4. Packages move from com.fasterxml.jackson.* to tools.jackson.*, several APIs are renamed, configuration properties are moved, and mappers like JsonMapper and XmlMapper are configured differently. For a REST application with custom serialization this might be the most painful item on the list.

Removed deprecations and renamed properties

Everything deprecated in Boot 3 is gone in Boot 4: classes, methods, and configuration properties. This is why Spring tells you to get to 3.5 first and deal with deprecations while the compiler still “gently” warns you. And 4.1 did the same to everything deprecated in 4.0.

Testing infrastructure

Test support is based on the new modules, with some removals. @MockBean and @SpyBean are replaced by Spring Framework's @MockitoBean and @MockitoSpyBean. @SpringBootTest no longer configures MockMvc, WebClient, or TestRestTemplate for you. Expect your test sources to break hard.

Technology-specific changes

There are also several individual changes that only matter if they are relevant to your codebase. For example, Undertow support is removed. spring-boot-starter-batch defaults to in-memory batch metadata. MongoDB properties move, and UUID and BigDecimal representations may need explicit configuration. Kafka and AMQP retry integrations changed, and Derby is deprecated. Read the migration guide section for every technology you use.

JSpecify nullability

Boot 4 adopts JSpecify nullability annotations. If you code in Kotlin or use a static null checker, this can cause compilation errors. On the bright side, everyone else just gets better IDE warnings.

Preparing the grounds with Spring Boot 3.5 demo

Let's look at the demo. NeonArchive handles data fragments captured by relay stations in 2084 and charges credits for access. Well, you know my obsession with cyberpunk, so bear with me.

The project runs on Java 25, so if you need a JDK, Liberica JDK 25 is Spring's recommended distribution. Boot 4.1 itself supports the Java versions from 17 to 26.

I wrote this demo specifically to stumble across as many refactoring tasks as possible because if I would just show you the Boot version number change, there would be no point in this post at all.

The application is nothing fancy. It uses Spring Data JPA and an in-memory H2 database for one entity, DataFragment, with repository and service layers in place. Spring Web exposes GET and POST. Flyway creates the table and seeds three rows through a direct flyway-core dependency. Dependencies are one trap, and the rest of the traps are:

  • ArchiveResponseHeaders builds response metadata and returns it as MultiValueMap<String, String>.
  • CreditSerializer formats BigDecimal values as "24.90 cr", registered through a Jackson2ObjectMapperBuilderCustomizer in JacksonConfig.
  • The controller test is a @WebMvcTest mocking the service with @MockBean.
  • The repository test uses @DataJpaTest to check that Flyway's rows are visible through JPA.
  • A @SpringBootTest class with TestRestTemplate posts a fragment, reads it back, and checks the JSON end to end in one go. This is for the demo's sake, do not ever build your test suite this way.

Step zero: clean up deprecations and run tests

Spring recommends upgrading to 3.5 before moving on to 4. At this stage, you should find every deprecated API you use. With Maven:

./mvnw clean test -Dmaven.compiler.showDeprecation=true

With Gradle, switch on the compiler's deprecation lint in the build:

tasks.withType(JavaCompile).configureEach {
    options.compilerArgs += '-Xlint:deprecation'
}

and then:

./gradlew clean test

Either way you get source locations, which facilitates the task.

Cross-check what you find against Spring's list of APIs marked for removal in 4.0.0.

In a real project you would fix all of it before upgrading to Boot 4. But I will skip this step on purpose, because I want you to see what a version bump does to a project nobody cleaned up first.

What you should also do before the migration is to run the tests and verify that your application behaves as intended. Or else you might blame the migration for something that was already broken.

Bump the version and see what breaks

Here we go! Change the Boot version in the parent. I use 4.1.0, the latest at the moment of writing the article:

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

Then compile the application with mvn clean compile. You will immediately get a list of errors that stop the project from building:

package com.fasterxml.jackson.core does not exist
cannot find symbol: class StdSerializer
cannot find symbol: class JsonGenerator
cannot find symbol: class SerializerProvider
package org.springframework.boot.autoconfigure.jackson does not exist
cannot find symbol: class Jackson2ObjectMapperBuilderCustomizer
incompatible types: org.springframework.http.HttpHeaders cannot be converted to
    org.springframework.util.MultiValueMap<java.lang.String,java.lang.String>

These are mostly Jackson-related errors and one Framework 7 change. Rolling up the sleeves: let's move from top to bottom.

Jackson 3 changes

CreditSerializer needs three changes.

First, the packages move from com.fasterxml.jackson to tools.jackson, so we need to fix the imports:

import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ser.std.StdSerializer;

Next, SerializerProvider became SerializationContext. Finally, Jackson 3 removed the checked exception model, so we need to delete throws IOException along with the java.io.IOException import. Failures are registered as unchecked JacksonException now.

Here’s the class after all three fixes:

public class CreditSerializer extends StdSerializer<BigDecimal> {

    public CreditSerializer() {
        super(BigDecimal.class);
    }

    @Override
    public void serialize(
            BigDecimal value,
            JsonGenerator generator,
            SerializationContext context) {

        String formatted =
                value.setScale(2, RoundingMode.HALF_UP)
                        .toPlainString();

        generator.writeString(formatted + " cr");
    }
}

Now, let’s move on to JacksonConfig. Jackson2ObjectMapperBuilderCustomizer becomes JsonMapperBuilderCustomizer, and it was moved to another package as well, from org.springframework.boot.autoconfigure.jackson to org.springframework.boot.jackson.autoconfigure.

After fixing the import and the type name, I tried to compile the application again and failed miserably.

The rename was the easy part. But the thing is, the builder passed to the customizer is a different object in Boot 4, and serializerByType(...) was not a Jackson method. It was a convenience on Spring's Jackson2ObjectMapperBuilder, the class that was removed. Registration goes through Jackson's own module API now: build a SimpleModule, add the serializer, and install the module.

import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.math.BigDecimal;

@Configuration(proxyBeanMethods = false)
public class JacksonConfig {

    @Bean
    Jackson2ObjectMapperBuilderCustomizer creditSerializerCustomizer() {

        return builder ->
                builder.serializerByType(
                        BigDecimal.class,
                        new CreditSerializer()
                );
    }
}

HttpHeaders is not a MultiValueMap anymore

The last compile error is the Framework 7 one, the only source-level API migration in our demo hiding in ArchiveResponseHeaders:

incompatible types: org.springframework.http.HttpHeaders cannot be converted to
    org.springframework.util.MultiValueMap<java.lang.String,java.lang.String>

In Spring Framework 7, HttpHeaders no longer implements MultiValueMap. Headers are handled through the header-specific API instead. Truth to be told, HTTP headers were never really a generic multi-valued map, and treating them as one might not be the best architectural decision.

So, the fix is not complicated because the method body — both of forFragment() and forCreatedFragment() — was already building an HttpHeaders and calling setETag on it. We only need to change the signature:

public HttpHeaders forFragment(DataFragment fragment)
public HttpHeaders forCreatedFragment(DataFragment fragment,
            URI location)

Get rid of the MultiValueMap import, and mvn clean compile is now successful.

Notice what the compiler caught: types no longer match, but that’s it. From the compiler output, we won’t learn that the design intent had changed, or that the right approach is to stop handing headers as generic maps. That’s what documentation is for, and either you or your agent should take a look at it from time to time.

The pom.xml that looks healthy, but is not

The application compiles, but it doesn’t mean our job is done. Before running the app there is the build file. This is where things get interesting.

Look at pom.xml at this point. It looks like it is in perfect health. spring-boot-starter-web, flyway-core, spring-boot-starter-test, all resolving, project compiles. This is strange, because I started this post telling you that modularization is a big deal. Who is lying?

Nobody is. Boot 4 keeps the old starters for compatibility, so nothing fails. The migration guide marks them deprecated, so they are on their way out, but this is not the problem the compiler will complain about. There is no error to fix here. But still, you need to make a decision.

For NeonArchive, spring-boot-starter-web becomes spring-boot-starter-webmvc, and Flyway moves from org.flywaydb:flyway-core to Spring's own spring-boot-starter-flyway. And spring-boot-starter-test, which used to cover everything, comes apart. This project needs three pieces in its place, data-jpa-test, webmvc-test, and restclient:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-restclient</artifactId>
    <scope>test</scope>
</dependency>

It doesn’t mean that all three are obligatory. In this case, each is for one kind of test in the demo: the @DataJpaTest, the @WebMvcTest, and the TestRestTemplate in the integration test.

The only way to get this right is to study the migration guide, find the dependencies you use, and change them. Nothing in your toolchain will do it for you, because from its point of view nothing is wrong.

Fixing the tests

Run the app and the full build. The application starts, but the test sources don’t compile.

@MockBean and @SpyBean are removed in Boot 4 in favor of Spring Framework's bean-override support, so the controller test moves to @MockitoBean from org.springframework.test.context.bean.override.mockito.

@MockitoBean
private DataFragmentService service;

@WebMvcTest(DataFragmentController.class) works as before, but its package changes, because Boot 4 keeps MVC test support in the dedicated spring-boot-webmvc-test module:

import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;

@DataJpaTest moved too:

import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;

TestRestTemplate went to org.springframework.boot.resttestclient, which is why we added spring-boot-restclient to the pom.

The final fix is not about imports. You see, @SpringBootTest does not pre-configure TestRestTemplate by default, so you need to ask for auto-configuration explicitly:

@SpringBootTest(
        webEnvironment =
                SpringBootTest.WebEnvironment.RANDOM_PORT
)
@AutoConfigureTestRestTemplate
class NeonArchiveIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

That’s it. We introduced four changes across three test files. Three of them are about imports, and one is for behavior change. If we didn’t add @AutoConfigureTestRestTemplate, the context would not be able to get the dependency.

Properties migration

A small bonus lifehack for those who made it up to here. Some configuration properties were renamed and removed in Boot 4. So that you don’t hunt them down manually, Spring provides a Properties Migrator dependency that finds out which of your properties are affected:

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

After you add it and start the app, the migrator analyzes your environment, prints diagnostics at startup, and even temporarily remaps renamed properties so the app keeps working while you fix them. NeonArchive's properties are conventional enough so the migrator didn’t find anything. But on a real project with loads of configs, this helper could be incredibly helpful.

Just don’t forget to remove it once the migration is done!

That's all for the migration of this tiny project. Tests are green, application is running and behaves as intended.

Try it yourself

The full project is on GitHub. The main branch is the Spring Boot 3.5.16 version, upgrades is the Boot 4.1 version. Clone it and diff the branches.

Then run step zero on your own project: start the app, run the tests, verify that everything is alright, and do one build with deprecation warnings on. After that, you can embark on the migration journey and embrace the goodies new Spring Boot brings!

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading