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.
- Author
- Abubakar Saifullah
- Published
- Published
- Updated
- Updated
- Reading time
- 17 minute read

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.
| Area | Spring Boot 4 change | What to verify |
|---|---|---|
| Platform | Java 17 minimum, Spring Framework 7, Jakarta EE 11, Servlet 6.1 | Runtime image, application server, agents, build plugins, bytecode tools |
| Dependencies | Smaller modules and more focused starters | Every feature has the correct main and test starter |
| JSON | Jackson 3 is the preferred mapper; most packages move from com.fasterxml.jackson to tools.jackson | Custom modules, mapper beans, annotations, snapshots, clients |
| Deprecated APIs | Boot 3 deprecations are removed | Compile with deprecation warnings before the major upgrade |
| Configuration | Some properties moved or were removed | Run the properties migrator temporarily and update configuration files |
| Testing | Technology-specific test starters and changed test utilities | Slice tests, security tests, MockMvc, RestTestClient, Testcontainers |
| Embedded server | Servlet 6.1 baseline; Undertow support was removed for Boot 4.0 | Tomcat or Jetty compatibility, external container version |
| Operations | Dependency and observability upgrades | Health 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.
- Record the current behaviour and production dependency graph.
- Upgrade to the latest Spring Boot 3.5.x release and remove deprecations.
- Move the build and runtime to Java 21 while still on Boot 3.5.
- Update third-party libraries that declare Boot or Framework compatibility.
- Change to Spring Boot 4.1 and adopt the focused starters.
- Fix compilation and test infrastructure before changing application behaviour.
- Migrate Jackson customisation and configuration properties.
- Run data, contract, security, and performance verification.
- Roll out behind measurable health gates with a tested rollback path.
Prerequisites
- JDK 21 selected by
JAVA_HOMEand confirmed withjava -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.
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 -DskipTestsAlso store representative API responses. Avoid snapshots containing volatile fields such as timestamps; compare stable contract fields instead.
GET /api/orders/2fcb0e8a-8351-4d63-a620-b743542e75c0 HTTP/1.1
Host: localhost:8080
Accept: application/jsonFor 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.
<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.
<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.
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_0Run 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-classicand 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.
<?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.
<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-webtospring-boot-starter-webmvc.- Plain
flyway-coretospring-boot-starter-flywaywhen Boot auto-configuration is required. - Plain
liquibase-coretospring-boot-starter-liquibase. spring-boot-starter-kafkapaired withspring-boot-starter-kafka-test.spring-boot-starter-securitypaired withspring-boot-starter-security-test.spring-boot-starter-webmvcpaired withspring-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.
mvn dependency:tree -DoutputFile=dependency-tree-after.txt
diff -u dependency-tree-before.txt dependency-tree-after.txt || trueMigrate 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:
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.
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.
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);
}
}package com.example.orders.domain;
public enum OrderStatus {
RECEIVED,
PROCESSING,
COMPLETED
}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;
}
}package com.example.orders.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface OrderRepository extends JpaRepository<CustomerOrder, UUID> {
}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) {
}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());
}
}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());
}
}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
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
);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: trueUse 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.
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.
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
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:runCreate and retrieve an order.
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/healthBuild an OCI image with Boot's Maven plugin and inspect the Java runtime reported by the image.
mvn spring-boot:build-image -Dspring-boot.build-image.imageName=order-service:4.1
docker run --rm --entrypoint java order-service:4.1 -versionContract 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.
- Keep schema changes backward compatible while Boot 3 and Boot 4 instances can coexist.
- Deploy the Boot 4 image to an isolated environment with production-like data volume.
- Run smoke, contract, security, and performance gates.
- Send a small percentage of traffic to Boot 4.
- Compare error rate, p95/p99 latency, JVM memory, connection-pool use, database load, and business outcomes.
- Increase traffic only while the gates remain healthy.
- Keep the previous image and configuration deployable until the observation window closes.
- 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
| Symptom | Likely cause | Resolution |
|---|---|---|
| Auto-configuration no longer activates | A capability was previously pulled in transitively | Add the focused Boot 4 starter for the technology |
ClassNotFoundException in tests | Test support moved to a technology-specific module | Add the matching spring-boot-starter-*-test artifact and update imports |
| Jackson imports fail | Code or a library targets Jackson 2 packages | Migrate to Jackson 3, upgrade the library, or use the temporary compatibility module with an exit plan |
| JSON snapshots change | Mapper defaults or module discovery changed | Configure the mapper intentionally and update only verified contract changes |
| Properties migrator reports keys | Renamed or removed configuration | Change source configuration, verify every profile, then remove the migrator |
| Embedded server fails | Container does not meet Servlet 6.1 baseline or Undertow was used | Move to a compatible Tomcat/Jetty setup and update deployment descriptors |
| Security tests stop working | Old test annotations or missing security test starter | Use @MockitoBean/@MockitoSpyBean and the Boot 4 security test starter |
| Runtime image starts on the wrong JDK | CI or buildpack configuration still selects an older JVM | Pin 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.


