Java & Spring

Migrating a Spring Boot 3 Application to Spring Boot 4.1 and Java 21

A practical migration path for a small REST service, covering dependency inventory, Spring Boot 4 modular starters, Jackson 3, configuration changes, tests, observability, containers, and rollback planning.

Published
Published
Updated
Updated
Reading time
17 minute read
Spring Boot 4Java 21Spring Framework 7MigrationMavenJackson 3
Three-stage upgrade path from Spring Boot 3.5 through Java 21 to Spring Boot 4.1, with a narrowing classpath and a tested rollback path

A Spring Boot 3 to 4 migration is safest when it is treated as a controlled compatibility project, not as a version-number edit. Spring Boot 4 changes the Java platform baseline, adopts Spring Framework 7 and Jakarta EE 11, reorganises many Boot modules, moves to Jackson 3 by default, and removes APIs that were deprecated during the 3.x generation.

This guide migrates a small order API from the latest Spring Boot 3.5 maintenance line to Spring Boot 4.1.1 on Java 21. The final project uses Maven, Spring MVC, validation, JPA, Flyway, PostgreSQL, Actuator, and focused Boot 4 test starters. The same sequence scales to larger services because it separates discovery, compilation, runtime behaviour, data compatibility, and deployment risk.

Every code change below was compiled against Spring Boot 4.1.1 on JDK 21 before publication, including the breaking changes that only surface at compile time.

What changes in Spring Boot 4

The most visible changes are not necessarily the riskiest ones. A useful migration inventory groups changes by the part of the system they can affect.

Migration areas to review before changing the parent version
AreaSpring Boot 4 changeWhat to verify
PlatformJava 17 minimum, Spring Framework 7, Jakarta EE 11, Servlet 6.1Runtime image, application server, agents, build plugins, bytecode tools
DependenciesSmaller modules and more focused startersEvery feature has the correct main and test starter
JSONJackson 3 is the preferred mapper; most packages move from com.fasterxml.jackson to tools.jacksonCustom modules, mapper beans, annotations, snapshots, clients
Deprecated APIsBoot 3 deprecations are removedCompile with deprecation warnings before the major upgrade
ConfigurationSome properties moved or were removedRun the properties migrator temporarily and update configuration files
TestingTechnology-specific test starters and changed test utilitiesSlice tests, security tests, MockMvc, RestTestClient, Testcontainers
Embedded serverServlet 6.1 baseline; Undertow support was removed for Boot 4.0Tomcat or Jetty compatibility, external container version
OperationsDependency and observability upgradesHealth groups, metrics names, tracing, dashboards, alert rules

The official migration guide recommends upgrading to the latest 3.5.x first. That intermediate step matters: it exposes deprecation warnings and dependency changes while the application is still on the familiar major line.

Migration flow

Use a sequence that keeps failures attributable to one class of change.

  1. Record the current behaviour and production dependency graph.
  2. Upgrade to the latest Spring Boot 3.5.x release and remove deprecations.
  3. Move the build and runtime to Java 21 while still on Boot 3.5.
  4. Update third-party libraries that declare Boot or Framework compatibility.
  5. Change to Spring Boot 4.1 and adopt the focused starters.
  6. Fix compilation and test infrastructure before changing application behaviour.
  7. Migrate Jackson customisation and configuration properties.
  8. Run data, contract, security, and performance verification.
  9. Roll out behind measurable health gates with a tested rollback path.

Prerequisites

  • JDK 21 selected by JAVA_HOME and confirmed with java -version.
  • Maven 3.6.3 or later, or a checked-in Maven Wrapper.
  • A branch with reproducible unit and integration tests.
  • A copy of the production dependency tree and effective POM.
  • Contract examples for important HTTP requests and JSON responses.
  • A local PostgreSQL instance or Docker-compatible runtime for the sample.
  • Access to deployment manifests, container images, health checks, dashboards, and rollback instructions.

Capture the Spring Boot 3.5 baseline

Before changing dependencies, make the current state observable.

Baseline commands
mvn -version
mvn clean verify
mvn dependency:tree -DoutputFile=dependency-tree-before.txt
mvn help:effective-pom -Doutput=effective-pom-before.xml
mvn spring-boot:build-image -DskipTests

Also store representative API responses. Avoid snapshots containing volatile fields such as timestamps; compare stable contract fields instead.

Baseline API request
GET /api/orders/2fcb0e8a-8351-4d63-a620-b743542e75c0 HTTP/1.1
Host: localhost:8080
Accept: application/json

For a larger service, add a migration worksheet listing every explicit dependency version, Spring Cloud release train, database driver, observability agent, code generator, Maven plugin, and container base image. Dependencies outside Spring Boot's dependency management need their own compatibility decision.

Upgrade to Java 21 before Boot 4

Changing Java first isolates language and runtime issues from framework issues. On Boot 3.5, set the compiler target and update the build environment.

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

Java baseline while the application is still on Spring Boot 3.5

Run the complete suite on Java 21. Pay special attention to:

  • JVM agents and instrumentation libraries.
  • Reflection and illegal-access warnings.
  • Annotation processors and generated sources.
  • Native libraries.
  • Time-zone, locale, TLS, and certificate-store behaviour.
  • Container memory limits and JVM flags.

Do not enable new Java language features in the same pull request unless they are required. A migration is easier to review when it changes compatibility rather than style.

Audit deprecated APIs on Spring Boot 3.5

Compile with deprecation warnings and fail the migration branch when new warnings are introduced.

pom.xml
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <release>21</release>
        <showWarnings>true</showWarnings>
        <compilerArgs>
            <arg>-Xlint:deprecation</arg>
            <arg>-Xlint:unchecked</arg>
        </compilerArgs>
    </configuration>
</plugin>

Temporary compiler diagnostics

Remove application calls to Boot APIs already marked for removal. Also search for obsolete configuration keys and annotations. This work should be committed and verified before changing the Boot parent.

Can OpenRewrite do the Spring Boot 3 to 4 migration for you?

Partly, and it is worth running — but it is a starting point rather than the migration.

OpenRewrite publishes recipes for the Spring Boot upgrade path. They apply the mechanical edits well: bumping the parent version, rewriting com.fasterxml.jackson imports to tools.jackson, replacing @MockBean with @MockitoBean, and swapping removed APIs for their successors.

Run the upgrade recipe without committing to it
mvn -U org.openrewrite.maven:rewrite-maven-plugin:runNoFork \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:RELEASE \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0

Run it on a scratch branch, read the diff, and keep only the edits you understand. Recipe names and coordinates change between releases, so check the current recipe catalogue rather than trusting the line above indefinitely.

What the recipes do not decide for you is everything that actually carries risk:

  • Whether a third-party library on your classpath supports Boot 4 at all.
  • Which focused starters your application needs, as opposed to reaching for spring-boot-starter-classic and moving on.
  • Whether your JSON contract still serialises identically for consumers.
  • Whether database, security, and observability behaviour survived the upgrade.
  • The order of the rollout and the rollback plan.

Check for new null-safety failures

Spring Framework 7 annotates its API with JSpecify (opens in a new tab) nullability annotations, and Spring Boot 4 follows. Nothing changes for a plain Java build with default settings. Two situations do change:

  • Kotlin callers. Framework types that were previously platform types now carry explicit nullability, so a Kotlin file that assumed non-null can stop compiling.
  • Null-checked Java builds. Projects running NullAway, the Checker Framework, or IDE-level null analysis in a failing configuration will surface new warnings or errors where the framework now declares a parameter or return value nullable.

Neither is a runtime behaviour change; both are compile-time signals that the framework is describing itself more precisely than before. Treat them as useful findings rather than as migration noise, and fix the call site instead of suppressing the check. If the volume is large, relax the analysis to warnings for the migration commit and restore the failing configuration in a follow-up.

Final Spring Boot 4.1 Maven build

The final sample uses focused Boot 4 starters. In particular, Spring MVC is supplied by spring-boot-starter-webmvc, Flyway by spring-boot-starter-flyway, and each technology under test has its matching test starter.

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

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

    <groupId>com.example</groupId>
    <artifactId>order-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>order-service</name>

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

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</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-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </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-starter-data-jpa-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-postgresql</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Add the properties migrator temporarily

Spring Boot's properties migrator reports renamed and removed keys at startup and can temporarily translate supported keys. Add it only while migrating.

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

Temporary migration dependency

Start every profile that matters, including test, staging, and production-like configuration. Update the source properties rather than relying on runtime translation, then remove the migrator before release.

Migrate to focused starters deliberately

Do not assume a transitive dependency still activates a feature. Boot 4's modular design makes feature ownership clearer, but applications that previously relied on incidental dependencies may fail at runtime.

Common replacements include:

  • spring-boot-starter-web to spring-boot-starter-webmvc.
  • Plain flyway-core to spring-boot-starter-flyway when Boot auto-configuration is required.
  • Plain liquibase-core to spring-boot-starter-liquibase.
  • spring-boot-starter-kafka paired with spring-boot-starter-kafka-test.
  • spring-boot-starter-security paired with spring-boot-starter-security-test.
  • spring-boot-starter-webmvc paired with spring-boot-starter-webmvc-test.

Generate a new dependency tree and compare it with the baseline. A smaller tree is expected; an absent capability is not.

Dependency comparison
mvn dependency:tree -DoutputFile=dependency-tree-after.txt
diff -u dependency-tree-before.txt dependency-tree-after.txt || true

Migrate Jackson 2 customisation to Jackson 3

Spring Boot 4 prefers Jackson 3. Most Jackson packages move from com.fasterxml.jackson to tools.jackson. The annotations artifact is an exception: common annotations remain under com.fasterxml.jackson.annotation.

A Boot 3 customiser may look like this:

Boot 3 customiser — replace during migration
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;

@Bean
Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
    return builder -> builder.featuresToDisable(
            SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}

The Boot 4 form customises Jackson 3's JsonMapper builder. Note that the import is not a straight package rename: WRITE_DATES_AS_TIMESTAMPS also changed which enum it belongs to.

src/main/java/com/example/orders/config/JsonConfiguration.java
package com.example.orders.config;

import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.cfg.DateTimeFeature;

@Configuration(proxyBeanMethods = false)
public class JsonConfiguration {

    @Bean
    JsonMapperBuilderCustomizer jsonMapperBuilderCustomizer() {
        return builder -> builder.disable(
                DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS);
    }
}

Verify the exact package imports against the Boot 4.1 API used by your build; custom Jackson modules and third-party libraries may still target Jackson 2. Do not force a transitive Jackson major version under a library that has not declared compatibility.

Where a temporary compatibility period is unavoidable, Boot 4 offers a deprecated spring-boot-jackson2 module. Treat it as a migration bridge, not the target architecture, because the official guide states it will be removed in a future release.

Configuration keys also changed. For example, JSON-specific read and write features now live under spring.jackson.json.read and spring.jackson.json.write. Run contract tests rather than assuming identical serialisation from successful startup.

Jackson 3 reaches further than your REST controllers. If the service publishes or consumes messages, the Kafka serializers changed with it — the Spring Boot 4 and Kafka example covers the Jackson 3 based JacksonJsonSerializer and the deprecated types it replaces. The same applies to any Spring AI code that maps model responses onto Java records, as in the Spring AI RAG example.

Final application implementation

The migrated service is intentionally small so each file can be copied into one Maven project.

src/main/java/com/example/orders/OrderServiceApplication.java
package com.example.orders;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OrderServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}
src/main/java/com/example/orders/domain/OrderStatus.java
package com.example.orders.domain;

public enum OrderStatus {
    RECEIVED,
    PROCESSING,
    COMPLETED
}
src/main/java/com/example/orders/domain/CustomerOrder.java
package com.example.orders.domain;

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "customer_order")
public class CustomerOrder {

    @Id
    private UUID id;
    private String customerReference;
    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private Instant createdAt;

    protected CustomerOrder() {
    }

    public CustomerOrder(
            UUID id,
            String customerReference,
            BigDecimal amount,
            OrderStatus status,
            Instant createdAt) {
        this.id = id;
        this.customerReference = customerReference;
        this.amount = amount;
        this.status = status;
        this.createdAt = createdAt;
    }

    public UUID getId() {
        return id;
    }

    public String getCustomerReference() {
        return customerReference;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public OrderStatus getStatus() {
        return status;
    }

    public Instant getCreatedAt() {
        return createdAt;
    }
}
src/main/java/com/example/orders/domain/OrderRepository.java
package com.example.orders.domain;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.UUID;

public interface OrderRepository extends JpaRepository<CustomerOrder, UUID> {
}
src/main/java/com/example/orders/web/CreateOrderRequest.java
package com.example.orders.web;

import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import java.math.BigDecimal;

public record CreateOrderRequest(
        @NotBlank @Size(max = 80) String customerReference,
        @NotNull @DecimalMin("0.01") BigDecimal amount) {
}
src/main/java/com/example/orders/web/OrderResponse.java
package com.example.orders.web;

import com.example.orders.domain.CustomerOrder;
import com.example.orders.domain.OrderStatus;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

public record OrderResponse(
        UUID id,
        String customerReference,
        BigDecimal amount,
        OrderStatus status,
        Instant createdAt) {

    public static OrderResponse from(CustomerOrder order) {
        return new OrderResponse(
                order.getId(),
                order.getCustomerReference(),
                order.getAmount(),
                order.getStatus(),
                order.getCreatedAt());
    }
}
src/main/java/com/example/orders/web/OrderController.java
package com.example.orders.web;

import com.example.orders.domain.CustomerOrder;
import com.example.orders.domain.OrderRepository;
import com.example.orders.domain.OrderStatus;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import java.time.Clock;
import java.time.Instant;
import java.util.UUID;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderRepository repository;
    private final Clock clock;

    public OrderController(OrderRepository repository, Clock clock) {
        this.repository = repository;
        this.clock = clock;
    }

    @PostMapping
    ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
        var order = repository.save(new CustomerOrder(
                UUID.randomUUID(),
                request.customerReference().strip(),
                request.amount(),
                OrderStatus.RECEIVED,
                Instant.now(clock)));
        var location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(order.getId())
                .toUri();
        return ResponseEntity.created(location).body(OrderResponse.from(order));
    }

    @GetMapping("/{id}")
    ResponseEntity<OrderResponse> find(@PathVariable UUID id) {
        return repository.findById(id)
                .map(OrderResponse::from)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }
}
src/main/java/com/example/orders/config/ApplicationConfiguration.java
package com.example.orders.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Clock;

@Configuration(proxyBeanMethods = false)
public class ApplicationConfiguration {

    @Bean
    Clock clock() {
        return Clock.systemUTC();
    }
}

Database migration and configuration

src/main/resources/db/migration/V1__create_customer_order.sql
CREATE TABLE customer_order (
    id UUID PRIMARY KEY,
    customer_reference VARCHAR(80) NOT NULL,
    amount NUMERIC(19, 2) NOT NULL CHECK (amount > 0),
    status VARCHAR(24) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
src/main/resources/application.yml
spring:
  application:
    name: order-service
  datasource:
    url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/orders}
    username: ${DATABASE_USERNAME:orders}
    password: ${DATABASE_PASSWORD:orders}
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate
  jackson:
    time-zone: UTC

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      probes:
        enabled: true

Use Flyway or Liquibase as the single schema owner. Do not combine automatic Hibernate schema mutation with migration scripts in production.

Update tests for Boot 4

The focused Boot 4 test starters make test requirements explicit. For an MVC slice, use the MVC test starter and mock the repository with Spring Framework's @MockitoBean.

src/test/java/com/example/orders/web/OrderControllerTest.java
package com.example.orders.web;

import com.example.orders.domain.CustomerOrder;
import com.example.orders.domain.OrderRepository;
import com.example.orders.domain.OrderStatus;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mvc;

    @MockitoBean
    OrderRepository repository;

    @MockitoBean
    java.time.Clock clock;

    @Test
    void createsAnOrder() throws Exception {
        UUID id = UUID.fromString("2fcb0e8a-8351-4d63-a620-b743542e75c0");
        when(clock.instant()).thenReturn(Instant.parse("2026-08-15T12:00:00Z"));
        when(clock.getZone()).thenReturn(java.time.ZoneOffset.UTC);
        when(repository.save(any(CustomerOrder.class))).thenReturn(new CustomerOrder(
                id,
                "customer-42",
                new BigDecimal("19.95"),
                OrderStatus.RECEIVED,
                Instant.parse("2026-08-15T12:00:00Z")));

        mvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "customerReference": "customer-42",
                                  "amount": 19.95
                                }
                                """))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "http://localhost/api/orders/" + id))
                .andExpect(jsonPath("$.id").value(id.toString()))
                .andExpect(jsonPath("$.status").value("RECEIVED"));
    }
}

The package names of Boot 4 test annotations changed with module organisation. Let the compiler and IDE resolve imports from the exact 4.1 artifacts rather than carrying Boot 3 imports forward.

For database verification, use a real PostgreSQL container so Flyway, SQL types, and Hibernate mappings are tested together.

src/test/java/com/example/orders/OrderServiceApplicationIT.java
package com.example.orders;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Testcontainers
class OrderServiceApplicationIT {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:17-alpine");

    @Test
    void contextStartsAndFlywayMigrates() {
    }
}

@MockBean and @SpyBean were removed in favour of Spring Framework's bean-override annotations such as @MockitoBean and @MockitoSpyBean. Plain Mockito fields should use MockitoExtension directly. Do not mechanically replace annotations inside reusable @Configuration classes without reviewing the new restrictions described in the migration guide.

Build and run the migrated service

Run PostgreSQL and the service
docker run --rm --name orders-postgres \
  -e POSTGRES_DB=orders \
  -e POSTGRES_USER=orders \
  -e POSTGRES_PASSWORD=orders \
  -p 5432:5432 \
  postgres:17-alpine

mvn clean verify
mvn spring-boot:run

Create and retrieve an order.

Verify the API
curl --fail-with-body \
  -H 'Content-Type: application/json' \
  -d '{"customerReference":"customer-42","amount":19.95}' \
  http://localhost:8080/api/orders

curl --fail-with-body http://localhost:8080/actuator/health

Build an OCI image with Boot's Maven plugin and inspect the Java runtime reported by the image.

Build and inspect the container image
mvn spring-boot:build-image -Dspring-boot.build-image.imageName=order-service:4.1

docker run --rm --entrypoint java order-service:4.1 -version

Contract and data compatibility checks

A successful build proves only that source code and tests compile. Add checks for externally visible behaviour:

  • Compare OpenAPI output and HTTP status codes.
  • Compare JSON field names, date formats, numeric precision, null handling, and enum values.
  • Verify database migrations against a recent anonymised schema copy.
  • Exercise message schemas when the service publishes Kafka or AMQP events.
  • Confirm authentication challenges, authorization decisions, CORS, and CSRF behaviour.
  • Compare health groups, metrics, traces, and log fields consumed by operations tooling.
  • Run load tests against the same JVM flags and container limits used in production.

Jackson 3 deserves specific contract tests because successful deserialisation does not guarantee byte-for-byte output compatibility.

Production rollout strategy

A controlled rollout should make rollback possible without reversing a database migration under pressure.

  1. Keep schema changes backward compatible while Boot 3 and Boot 4 instances can coexist.
  2. Deploy the Boot 4 image to an isolated environment with production-like data volume.
  3. Run smoke, contract, security, and performance gates.
  4. Send a small percentage of traffic to Boot 4.
  5. Compare error rate, p95/p99 latency, JVM memory, connection-pool use, database load, and business outcomes.
  6. Increase traffic only while the gates remain healthy.
  7. Keep the previous image and configuration deployable until the observation window closes.
  8. Remove transitional compatibility code in a later change.

Do not make an irreversible schema change in the same deployment as the framework upgrade. Use expand-and-contract migrations when database changes are necessary.

Security review

A major dependency update changes the security baseline but does not replace application security testing.

  • Review Spring Security migration notes and every custom filter or authorization manager.
  • Verify default headers, session settings, OAuth2/OIDC flows, JWT validation, and method security.
  • Re-run dependency scanning after the lock and build files settle.
  • Confirm the new container image receives operating-system patches.
  • Review deserialisation boundaries and polymorphic JSON configuration during the Jackson migration.
  • Restrict Actuator exposure and protect non-public endpoints.
  • Test SSRF controls in any outbound HTTP client. Boot 4.1 includes additional HTTP-client SSRF mitigation support, but applications still need destination allowlists and network policy.

Common migration problems and troubleshooting

Symptoms and likely causes during a Boot 4 migration
SymptomLikely causeResolution
Auto-configuration no longer activatesA capability was previously pulled in transitivelyAdd the focused Boot 4 starter for the technology
ClassNotFoundException in testsTest support moved to a technology-specific moduleAdd the matching spring-boot-starter-*-test artifact and update imports
Jackson imports failCode or a library targets Jackson 2 packagesMigrate to Jackson 3, upgrade the library, or use the temporary compatibility module with an exit plan
JSON snapshots changeMapper defaults or module discovery changedConfigure the mapper intentionally and update only verified contract changes
Properties migrator reports keysRenamed or removed configurationChange source configuration, verify every profile, then remove the migrator
Embedded server failsContainer does not meet Servlet 6.1 baseline or Undertow was usedMove to a compatible Tomcat/Jetty setup and update deployment descriptors
Security tests stop workingOld test annotations or missing security test starterUse @MockitoBean/@MockitoSpyBean and the Boot 4 security test starter
Runtime image starts on the wrong JDKCI or buildpack configuration still selects an older JVMPin and verify Java 21 in build, test, image, and deployment environments

Migration completion checklist

  • The application first ran cleanly on the latest 3.5.x release.
  • Java 21 was verified independently of the Boot 4 change.
  • Deprecated Boot 3 APIs and configuration were removed.
  • Every third-party Spring integration declares compatible versions.
  • Focused main and test starters are explicit.
  • Jackson 3 customisation and API contracts are verified.
  • The properties migrator has been removed.
  • Unit, slice, integration, contract, security, and performance tests pass.
  • Database changes are backward compatible.
  • Dashboards and alerts recognise the new runtime.
  • Canary and rollback procedures have been exercised.

Conclusion

A Spring Boot 4 migration is less risky when compatibility work is split into observable stages: latest Boot 3.5, Java 21, dependency readiness, Boot 4 modularisation, Jackson 3, tests, and rollout. The final code may look similar to the original service, but the important result is a verified dependency graph, stable external contracts, and a deployment path that can be reversed safely.

Continue with Spring Boot and Apache Kafka using Java 21 when the migrated service also needs event-driven integration, or use the event-driven Spring AI pipeline as a larger Boot 4 example.

Frequently asked questions

Must a Spring Boot 4 application use Java 21?

No. Spring Boot 4 requires Java 17 or later. This guide uses Java 21 because it is an LTS release and provides a consistent baseline for modern Java services.

Should I use `spring-boot-starter-classic`?

It can be a useful temporary migration bridge when many capabilities disappear from the classpath at once. The target state should normally use focused starters so dependencies and test support remain explicit.

Can Spring Boot 4 use Jackson 2?

Boot 4 provides a deprecated Jackson 2 compatibility module for transitional use. Prefer migrating to Jackson 3 and give any compatibility module a removal date.

Is changing the parent version enough?

No. A successful compile does not verify JSON contracts, database behaviour, security, observability, or deployment compatibility. Treat those as separate acceptance gates.

Should the framework and database schema migrate in one release?

Avoid combining the major framework change with irreversible schema changes. Use backward-compatible expand-and-contract migrations so Boot 3 and Boot 4 versions can coexist during rollout.

Can OpenRewrite handle the Spring Boot 3 to 4 migration automatically?

It handles the mechanical part well: the parent version, Jackson import rewrites, and removed-API replacements. It cannot judge third-party compatibility, choose your focused starters, or verify that JSON contracts and database behaviour are unchanged. Run the recipe on a scratch branch, review every line of the diff, then continue with staged verification.

Why does my Testcontainers dependency suddenly have no version?

Spring Boot 4.1 manages Testcontainers 2.x, which renamed every module artifact. Use testcontainers-postgresql and testcontainers-junit-jupiter instead of postgresql and junit-jupiter. Maven fails at dependency resolution rather than compilation, so the error names the missing version instead of the rename.

Why does my `@SpringBootTest` class fail to autowire MockMvc?

Boot 4 removed MockMvc auto-configuration from @SpringBootTest. Add @AutoConfigureMockMvc to those tests. @WebMvcTest slice tests still configure it themselves and need no change.

Should I enable virtual threads as part of the upgrade?

No. Keep spring.threads.virtual.enabled off until the migration is verified. Virtual threads change the concurrency model of every blocking call, and the resulting throughput differences are hard to distinguish from a framework regression if both land together.

Official references

Complete implementation

Run the matching repository

The repository contains the complete project, configuration examples, tests, and operating notes used by this guide.

View the complete project on GitHub

Portfolio context

Continue reading

Next step

Need help with Java backend development, architecture, or AI integration?

Send a short message about the role, project, stack, and timeline, and I will get back to you.

Open to senior Java backend roles in the Netherlands and the wider EU, including hybrid and remote, plus architecture reviews and backend-led AI integration work.