Spring Boot 3 EOL to Spring Boot 4: A Production Upgrade Playbook (Including Jackson 2 to 3)
Spring Boot 4 is not a routine version bump.
This migration touches platform baselines (Java/Jakarta/Servlet), dependency structure, test infrastructure, and JSON behavior — all at once.
If your services are still on Spring Boot 3.x, I wrote this as a practical, production-oriented playbook to move to Spring Boot 4 safely, with explicit attention to end-of-life (EOL) risk, CVE exposure, and Jackson 2 to Jackson 3 changes.
In this post, we’ll cover:
- What Spring Boot 3.x EOL means in practice and how CVE pressure changes
- A phased migration strategy from Spring Boot 3.5 to 4.0
- Hands-on code changes for starters, Jackson, tests, and security
- Companion migrations for Spring Modulith 2.0 and Testcontainers 2.x
- A production rollout checklist with CI/CD gates you can apply immediately
Why This Upgrade Is Time-Sensitive
Spring Boot support is minor-line based.
From the official support table, Spring Boot 3.5.x has OSS support through June 2026, while Spring Boot 4.0.x has OSS support through December 2026 (with longer enterprise support windows).
What does that actually mean in practice?
- EOL does not mean your app stops running on that date.
- EOL means you stop receiving community security and bug-fix patches for that line.
- New vulnerabilities continue to be discovered after EOL, but fixes are not backported to unsupported lines.
The real risk is not immediate outage — it’s rising security and compliance debt that compounds over time.
If your services also use Spring AI, the window is even tighter. Spring AI 1.x aligns to Spring Boot 3.5, while Spring AI 2.0 tracks Boot 4. That means platform migration and AI API migration timelines are coupled — plan for both.
CVEs, Risk, and What to Watch
A concrete example in 2026 came from Spring AI advisories:
- CVE-2026-22729 — JSONPath injection
- CVE-2026-22730 — SQL injection
Both affected Spring AI 1.0.x/1.1.x and were fixed in 1.0.4 and 1.1.3.
These are Spring AI vulnerabilities, not Spring Boot core CVEs. But for many teams, the platform decisions are coupled — Boot baseline plus AI stack move together.
This is exactly how lifecycle pressure appears in real systems: you can patch while your line is supported. After EOL, newly discovered issues may not receive OSS backports.
If your organization is still on 3.5 close to EOL, your risk model should assume more frequent scanner findings, harder remediation conversations, and tighter migration timelines under pressure.
Migration Strategy: Three Phases, Not One Version Bump
So how do we approach this? Not as “change one version and pray.”
A safer model is three phases:
- Stabilize on latest 3.5.x
- Migrate to 4.0 with compatibility bridges
- Converge to native 4.0 patterns and remove temporary bridges
This aligns with the official Spring Boot 4 migration guide.
End-to-end migration flow
Step 1: Baseline and Stabilize on Spring Boot 3.5.x
Before we touch major versions, let’s create a factual inventory and get on the latest 3.5.x patch.
Build your before-state inventory
What do we need to capture?
- Java runtime versions in CI/CD and production
- App server/container choices (Tomcat/Jetty/Undertow)
- External BOMs and explicitly pinned versions (Spring Cloud, Security, Data, Kafka, vendor libs)
- JSON customizations (custom serializers, modules, ObjectMapper wiring)
- Test stack assumptions (
@MockBean, MockMvc shortcuts, TestRestTemplate usage) - Build/package assumptions (fully executable jars, classic loader settings)
1
2
3
4
5
6
7
# Maven
mvn -q -DskipTests dependency:tree > dependency-tree.txt
mvn -q -DskipTests help:effective-pom > effective-pom.xml
# Gradle
./gradlew dependencies > dependency-tree.txt
./gradlew projects > projects.txt
Capture this as your before-state artifact. We’ll compare it against the after-state once we’re on Boot 4.
Move to latest 3.5.x
The official migration guide explicitly recommends moving to latest 3.5.x before upgrading to 4.0.
Maven:
1
2
3
4
5
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.x</version>
</parent>
Gradle:
1
2
3
plugins {
id("org.springframework.boot") version "3.5.x"
}
Why this matters:
- We get the latest fixes in the 3.5 line
- We reduce the delta size when jumping to 4.0
- We surface deprecations in a safer context
Remove 3.x deprecations aggressively
Everything deprecated across 3.x should be considered a 4.0 removal candidate. Turn deprecation warnings into a tracked backlog and fix high-frequency and startup-path deprecations first.
Step 2: Validate Platform Baselines for Boot 4
Before we change the Boot version, let’s make sure our infrastructure can actually run Boot 4. It raises the floor on several platform requirements:
- Java 17+
- Jakarta EE 11
- Servlet 6.1
- Spring Framework 7.x
- Kotlin 2.2+ (if applicable)
- GraalVM 25+ for native-image flows
Common pitfall: teams upgrade Boot but not infra assumptions — container images, app servers, plugin versions — then discover breakage late in pre-prod. Validate your CI/CD images and deployment targets against these baselines before changing the Boot version.
Step 3: Upgrade to Spring Boot 4
Now we get to the actual version bump. This step covers starter model changes and removed features. Each change includes before/after code so you can apply it directly.
Version bump and classic bridge
Let’s start by updating the parent/BOM and plugin to 4.0.x. If your codebase is large, add the classic starter bridge first to keep the app booting while you converge.
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-classic</artifactId>
</dependency>
This reduces “too many unknowns at once” failures. Remove the classic bridge once you have migrated to focused starters.
Rename starters to focused equivalents
Boot 4 introduces a new modular design and prefers explicit, technology-specific starters over generic ones. The deprecated starter names still work, but should be migrated.
| Before | After |
|---|---|
spring-boot-starter-web | spring-boot-starter-webmvc |
spring-boot-starter-web-services | spring-boot-starter-webservices |
spring-boot-starter-aop | spring-boot-starter-aspectj |
spring-boot-starter-json | spring-boot-starter-jackson |
spring-boot-starter-oauth2-client | spring-boot-starter-security-oauth2-client |
spring-boot-starter-oauth2-resource-server | spring-boot-starter-security-oauth2-resource-server |
spring-boot-starter-oauth2-authorization-server | spring-boot-starter-security-oauth2-authorization-server |
spring-security-test | spring-boot-starter-security-test |
| (implicit via classpath) | spring-boot-starter-restclient (now explicit) |
| (implicit via classpath) | spring-boot-starter-webclient (now explicit) |
| (implicit via classpath) | spring-boot-starter-flyway (now explicit) |
| (implicit via classpath) | spring-boot-starter-liquibase (now explicit) |
Every technology starter now also has a paired test starter (spring-boot-starter-<technology>-test) that brings spring-boot-starter-test transitively. You no longer need to declare spring-boot-starter-test directly — list the test starters for the technologies under test instead.
Before:
1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
After:
1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
If you use RestClient, WebClient, Flyway, or Liquibase, add those starters explicitly — Boot 4 no longer auto-configures them from classpath presence alone:
1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
For tests, replace the bare spring-security-test dependency with the new Boot-managed starter:
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- Before -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- After -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
Why does this matter? Fewer accidental transitive dependencies, cleaner module graph, and better control over attack surface and startup footprint.
One thing to watch: “it compiles” but some infra auto-config is missing at runtime because starter coverage changed. Test application context startup explicitly.
Replace Undertow with Tomcat or Jetty
Boot 4 dropped Undertow support because Undertow does not yet implement Servlet 6.1.
Before:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
After (Tomcat is the default, or switch to Jetty):
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- For Jetty instead of Tomcat:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
-->
Also remove any server.undertow.* properties from your configuration files.
Remove fully executable jar launch scripts
Boot 4 also removed embedded init.d-style launch scripts. If you’re using the <executable>true</executable> flag, that needs to go.
Before:
1
2
3
4
5
6
7
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>true</executable>
</configuration>
</plugin>
After:
1
2
3
4
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
Switch to systemd or container-based process management instead.
Package relocations
This one is subtle. Boot 4 relocated several framework classes to new packages. They compile fine on 3.5 but break silently after upgrading if you don’t update the imports.
| Class | Old package | New package |
|---|---|---|
EntityScan | org.springframework.boot.autoconfigure.domain | org.springframework.boot.persistence.autoconfigure |
BootstrapRegistry | org.springframework.boot | org.springframework.boot.bootstrap |
BootstrapContext | org.springframework.boot | org.springframework.boot.bootstrap |
EnvironmentPostProcessor | org.springframework.boot.env | org.springframework.boot |
1
2
3
4
grep -rn "import org.springframework.boot.autoconfigure.domain.EntityScan" src/
grep -rn "import org.springframework.boot.BootstrapRegistry" src/
grep -rn "import org.springframework.boot.BootstrapContext" src/
grep -rn "import org.springframework.boot.env.EnvironmentPostProcessor" src/
Configuration defaults that changed
Two defaults flipped in Boot 4 that can catch you off guard in production:
- Health probes are now enabled by default. Kubernetes liveness (
/livez) and readiness (/readyz) endpoints are active out of the box. If you relied on them being off, review your probe configuration. - DevTools live reload is now disabled by default. If your team uses DevTools live reload in development, you’ll need to enable it explicitly:
1
spring.devtools.livereload.enabled=true
A few other things were removed: Spock integration is gone from Boot (Spock doesn’t yet support Groovy 5), Pulsar Reactive auto-config is gone, and direct support for Spring Session Hazelcast and Spring Session MongoDB has moved to those projects’ own teams. Run these searches to catch any remaining references in your codebase:
1
2
3
4
grep -rn "undertow" build.gradle* pom.xml src/main/resources/
grep -rn "<executable>true</executable>" pom.xml
grep -rn "spring-boot-starter-web\"" build.gradle* pom.xml
grep -rn "spring-boot-starter-aop\"" build.gradle* pom.xml
Step 4: Migrate Jackson 2 to Jackson 3
In my experience, this is the highest-risk part of the upgrade. Spring Boot 4 defaults to Jackson 3, and the changes go well beyond import renames — serialization behavior and property namespaces changed too.
Let’s walk through each layer.
Jackson migration decision tree
Update imports
Jackson 3 moved most packages from com.fasterxml.jackson to tools.jackson. The exception is jackson-annotations, which stays at com.fasterxml.jackson.annotation.
Before:
1
2
3
4
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
After:
1
2
3
4
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.core.JsonProcessingException;
import tools.jackson.datatype.jsr310.JavaTimeModule;
Important: imports from com.fasterxml.jackson.annotation (like @JsonProperty, @JsonIgnore, @JsonCreator) do not change. Leave those as they are.
1
grep -rn "import com.fasterxml.jackson" src/ | grep -v "annotation"
Rename Boot’s Jackson integration classes
Beyond the Jackson library imports, Boot 4 also renamed its own integration classes. These are the ones we use when customizing ObjectMapper behavior or writing custom serializers in Spring.
| Before | After |
|---|---|
Jackson2ObjectMapperBuilderCustomizer | JsonMapperBuilderCustomizer |
@JsonComponent | @JacksonComponent |
@JsonMixin | @JacksonMixin |
JsonObjectSerializer | ObjectValueSerializer |
JsonObjectDeserializer | ObjectValueDeserializer |
The pattern is consistent: anywhere Boot’s Jackson integration used a Json prefix, it’s now Jackson (for annotations and supporting classes) or the type was renamed for clarity. Apply the same import-and-extends rename to all four classes — the example below shows one of them.
Before:
1
2
3
4
5
6
7
8
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.boot.jackson.JsonObjectSerializer;
@JsonComponent
public class MoneySerializer extends JsonObjectSerializer<Money> {
// ...
}
After:
1
2
3
4
5
6
7
8
import org.springframework.boot.jackson.JsonMapperBuilderCustomizer;
import org.springframework.boot.jackson.JacksonComponent;
import org.springframework.boot.jackson.ObjectValueSerializer;
@JacksonComponent
public class MoneySerializer extends ObjectValueSerializer<Money> {
// ...
}
1
grep -rn "Jackson2ObjectMapperBuilderCustomizer\|@JsonComponent\|JsonObjectSerializer\|JsonObjectDeserializer" src/
Update dependency coordinates
If you declare Jackson dependencies explicitly (outside Boot’s managed BOM), we need to update the group IDs too.
Before (Maven):
1
2
3
4
5
6
7
8
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
After (Maven):
1
2
3
4
5
6
7
8
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>tools.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
Before (Gradle):
1
2
implementation("com.fasterxml.jackson.core:jackson-databind")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
After (Gradle):
1
2
implementation("tools.jackson.core:jackson-databind")
implementation("tools.jackson.datatype:jackson-datatype-jsr310")
If Boot BOM manages these for you, remove explicit coordinates and let the BOM resolve them.
Migrate property namespaces
Several application.properties / application.yml keys moved. The most important change: JSON-specific spring.jackson.read.* and spring.jackson.write.* properties moved beneath spring.jackson.json.read.* and spring.jackson.json.write.*.
spring.jackson.parser.* properties were also replaced with spring.jackson.json.read.* where a Jackson 2 JsonParser.Feature has an equivalent Jackson 3 JsonReadFeature. For features without a JsonReadFeature equivalent, use a JsonMapperBuilderCustomizer programmatically.
Before (application.properties):
1
2
3
spring.jackson.write.write-dates-as-timestamps=false
spring.jackson.read.fail-on-unknown-properties=true
spring.jackson.parser.allow-comments=true
After:
1
2
3
spring.jackson.json.write.write-dates-as-timestamps=false
spring.jackson.json.read.fail-on-unknown-properties=true
spring.jackson.json.read.allow-comments=true
The equivalent in application.yml:
1
2
3
4
5
6
7
8
spring:
jackson:
json:
write:
write-dates-as-timestamps: false
read:
fail-on-unknown-properties: true
allow-comments: true
Use the properties migrator to catch the rest
Boot 4 ships a runtime helper that analyzes your environment and reports any properties that need attention. Add it temporarily as a runtime dependency:
1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
<scope>runtime</scope>
</dependency>
Or Gradle:
1
runtimeOnly("org.springframework.boot:spring-boot-properties-migrator")
It will not only print diagnostics at startup, but temporarily migrate properties at runtime. Remove it once you’ve fixed every reported key.
1
grep -rn "spring.jackson\." src/main/resources/
Behavioral changes to expect
What about the actual JSON output? From the Jackson 3 release notes, key changes include:
- Major API renames/removals from deprecated 2.x APIs
- Different defaults for several serialization/deserialization features
- Java 17 baseline
- Built-in support for former Java 8 datatype modules in databind
Do not assume JSON payloads remain bit-identical by default. For more context, see the Spring team’s introduction to Jackson 3 support.
Compatibility levers
What if we can’t move everything at once? Boot 4 provides two migration aids.
Jackson-2-like defaults flag (the auto-configured JsonMapper will use defaults aligned with Jackson 2 in Boot 3.x):
1
spring.jackson.use-jackson2-defaults=true
For more invasive needs, the spring-boot-jackson2 module ships a Jackson 2 ObjectMapper you can use alongside Boot’s auto-configured Jackson 3 mapper. It’s deprecated on arrival — intended as a stop-gap, not a permanent setup:
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson2</artifactId>
</dependency>
When using spring-boot-jackson2, configure the Jackson 2 mapper under spring.jackson2.* — these keys are equivalent to the spring.jackson.* properties from Boot 3.5.
Module auto-registration
Boot 4 detects and registers all Jackson modules found on classpath by default. If this pulls in unexpected behavior:
1
spring.jackson.find-and-add-modules=false
Prefer explicit module hygiene before reaching for this flag.
Contract testing approach
This is where we protect ourselves. Use golden payload fixtures and compare old/new behavior for:
- date/time serialization format
- enum serialization/deserialization behavior
- unknown property handling
- polymorphic type resolution
- null/default handling
Practical rollout:
- Enable
spring.jackson.use-jackson2-defaults=true - Make tests pass and deploy safely
- Remove compatibility defaults incrementally
- Keep each behavior change behind tests and changelog notes
Step 5: Migrate Tests and Security Configuration
From the migration guide and Spring Framework 7 release notes, test behavior shifted in ways many teams miss until late in the migration. Let’s go through the ones that will affect most codebases.
Replace @MockBean and @SpyBean
Boot 4 removed @MockBean and @SpyBean from the framework’s test support. The replacement is straightforward — we switch to @MockitoBean and @MockitoSpyBean.
Before:
1
2
3
4
5
6
7
8
9
10
11
12
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
@SpringBootTest
class OrderServiceTest {
@MockBean
private PaymentGateway paymentGateway;
@SpyBean
private NotificationService notificationService;
}
After:
1
2
3
4
5
6
7
8
9
10
11
12
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
@SpringBootTest
class OrderServiceTest {
@MockitoBean
private PaymentGateway paymentGateway;
@MockitoSpyBean
private NotificationService notificationService;
}
1
grep -rn "@MockBean\|@SpyBean" src/test/
Update @SpringBootTest and test client usage
Here’s one that tripped me up: @SpringBootTest no longer auto-configures MockMvc. We need to add @AutoConfigureMockMvc explicitly.
Before:
1
2
3
4
5
6
@SpringBootTest
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
}
After:
1
2
3
4
5
6
7
8
9
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
}
TestRestTemplate is replaced by RestTestClient with its own auto-configuration annotation.
Before:
1
2
3
4
5
6
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiTest {
@Autowired
private TestRestTemplate restTemplate;
}
After:
1
2
3
4
5
6
7
8
9
10
import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureRestTestClient;
import org.springframework.boot.test.web.client.RestTestClient;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class OrderApiTest {
@Autowired
private RestTestClient restTestClient;
}
@WebMvcTest moved to a new package:
1
2
3
4
5
// Before
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// After
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
And if you use declarative HTTP clients, HttpServiceProxyFactory is replaced by the @ImportHttpServices annotation. The result? Much less boilerplate:
Before:
1
2
3
4
5
6
7
8
9
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Bean
InventoryClient inventoryClient(RestClient.Builder builder) {
var factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(builder.build()))
.build();
return factory.createClient(InventoryClient.class);
}
After:
1
2
3
4
5
6
import org.springframework.web.service.annotation.ImportHttpServices;
@Configuration
@ImportHttpServices(InventoryClient.class)
class ClientConfig {
}
1
grep -rn "TestRestTemplate\|HttpServiceProxyFactory\|import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest" src/test/
Update Security filter chain patterns
Spring Security 7 (shipped with Boot 4) finalized several deprecations. The most common code change we’ll see is in SecurityFilterChain configuration.
Before:
1
2
3
4
5
6
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"));
Common changes after upgrading:
requestMatchers()method signatures may require explicitHttpMethodparameters in some cases- CSRF token handling defaults changed — test CSRF-protected flows explicitly
- Session management defaults tightened
There is no single find-and-replace here. Run your security tests and review the Spring Security 7 migration guide for your specific patterns.
Strengthen contract tests before rollout
Before we promote to production, this is a good time to add or strengthen:
- API payload contract tests
- Serialization/deserialization fixture tests
- Security behavior tests (authz/authn boundaries)
- Canary smoke tests against production-like data and config
Step 6: Production Rollout and CI/CD Gates
We’ve made it through the code changes. Now let’s talk about getting this into production safely. The key principle: progressive delivery, not big-bang cutover.
Rollout sequence
- Pre-prod full integration run with production-like config
- Canary deployment with explicit error budget guardrails
- Monitor serialization errors, 4xx/5xx deltas, auth failures, and DB anomalies
- Rollback criteria defined before promotion
Track at least:
- startup time
- memory footprint
- p95/p99 latency on key endpoints
- error rates by endpoint
- security scanner deltas before/after
Gate A: Dependency and lifecycle drift
Fail fast when unsupported or unexpected versions appear.
1
2
3
4
5
# Maven
mvn -q -DskipTests dependency:tree -DoutputFile=dependency-tree.txt
# Gradle
./gradlew dependencies > dependency-tree.txt
Enforce: no unresolved dependency versions, no unexpected downgrades after merge, no dependencies from blocked version ranges.
Gate B: JSON contract regression
This is where we catch Jackson 2 to 3 behavior drift before canary.
1
2
3
mvn -q -Dtest='*ContractTest' test
# or
./gradlew test --tests '*ContractTest'
Recommended fixtures: date/time payloads, enum values and unknown enum inputs, null/default handling, polymorphic payloads.
Gate C: Startup and smoke in production-like profile
This gate catches missing starters and auto-config early.
1
2
3
mvn -q -Dspring.profiles.active=staging verify
# or
./gradlew test -Dspring.profiles.active=staging
Include: application context startup test, one authenticated endpoint smoke test, one DB-backed flow smoke test.
Gate D: Security advisory monitoring
We want the pipeline to fail when known high/critical advisories affect runtime deps.
Options: OWASP Dependency-Check, Snyk, or GitHub Dependabot with code scanning policies. Block release on critical vulnerabilities without an approved exception. Time-bound exceptions with owner and remediation due date.
Gate E: Canary promotion guardrails
Track for the canary window:
- 5xx rate delta vs baseline
- p95/p99 latency delta vs baseline
- Auth failure anomalies
- Serialization/deserialization exception spikes
Promote only if all SLO deltas stay within thresholds for the full canary window. Auto-rollback if error budget burn exceeds your policy.
Common Pitfalls
Here are the mistakes I’ve seen teams make most often:
- Upgrading Boot but leaving non-Boot managed dependencies on incompatible versions
- Keeping old starter coordinates and relying on transitive luck
- Missing the new explicit starters for RestClient, WebClient, and Flyway
- Forgetting Boot’s own Jackson class renames (
@JsonComponent,Jackson2ObjectMapperBuilderCustomizer) - Underestimating test annotation and test client migration work
- Assuming Jackson migration is only import renaming
- Not adding
@AutoConfigureMockMvcafter upgrading and wondering why MockMvc is null - Running with compatibility flags forever and never converging
- Treating CVE remediation as a one-time task instead of continuous practice
- Upgrading Spring Modulith to 2.0 without running the event publication schema migration first
Migration Notes for Spring AI Teams
If your services use Spring AI 1.x, plan for a dual-track migration.
Spring AI 1.x aligns to Spring Boot 3.5, while Spring AI 2.0 tracks Spring Boot 4. The 2026 CVEs (CVE-2026-22729, CVE-2026-22730) showed that active patching cadence matters — both affected Spring AI 1.0.x/1.1.x.
Recommended upgrade track:
- Move all services to latest Spring AI 1.0.x/1.1.x patch line immediately.
- Add explicit security gates for AI-related dependencies (including vector store connectors).
- Validate model, embedding, and vector-store integration behavior in production-like tests before moving to Spring AI 2.0.
- Run dual-track planning: Boot 4 platform migration and AI API migration, with separate risk checklists.
- Schedule a short convergence phase to remove temporary compatibility settings once AI integration tests are stable on Boot 4.
Watch-list during migration:
- Filter/query expression handling in vector-store integrations
- Metadata-based authorization assumptions
- SQL/JSON query generation paths in adapters
- Serialization contracts for prompt/request/response payloads
Companion Migration: Spring Modulith 2.0
If you use Spring Modulith, there’s an important dependency: Modulith 2.0 requires Boot 4 as its baseline. The migration order matters — finish Boot 4 first, then upgrade Modulith.
The critical breaking change is the event publication table schema. Modulith 2.0 overhauled the event publication lifecycle and requires three new columns:
1
2
3
4
5
6
ALTER TABLE event_publication
ADD COLUMN IF NOT EXISTS status VARCHAR(20),
ADD COLUMN IF NOT EXISTS completion_attempts INT DEFAULT 0,
ADD COLUMN IF NOT EXISTS last_resubmission_date TIMESTAMP WITH TIME ZONE;
UPDATE event_publication SET status = 'PUBLISHED' WHERE status IS NULL;
Without this migration, your application will fail to start on Modulith 2.0. This affects JDBC, JPA, MongoDB, and Neo4j event stores.
Once the schema is in place, update the version:
1
2
3
<properties>
<spring-modulith.version>2.0.0</spring-modulith.version>
</properties>
All event-related configuration properties default to false in 2.0 — no surprise behavior changes unless you opt in. See the Spring Modulith reference for the complete schema.
Companion Migration: Testcontainers 2.x
Testcontainers 2.x is a separate migration from Boot 4 but most teams do it concurrently. There are three categories of changes to watch for.
Artifact renames. All modules gain a testcontainers- prefix:
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- Before -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- After -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
Package relocations. Container classes moved to module-specific packages and generic types were removed:
Before:
1
2
3
import org.testcontainers.containers.PostgreSQLContainer;
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
After:
1
2
3
import org.testcontainers.postgresql.PostgreSQLContainer;
PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16");
LocalStack API changes. The Service enum and .withServices() were removed — services are now auto-detected:
Before:
1
2
3
4
5
import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3;
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.0"))
.withServices(S3);
s3Client.endpointOverride(container.getEndpointOverride(S3));
After:
1
2
new LocalStackContainer(DockerImageName.parse("localstack/localstack:latest"));
s3Client.endpointOverride(container.getEndpoint());
See the Testcontainers 2.0 migration guide for the full list of artifact and package name mappings.
Checklist
- Latest 3.5.x deployed and stable
- 3.x deprecations addressed
- Java/Jakarta/Servlet baseline validated for 4.0
- Removed features audited (Undertow, launch scripts, etc.)
- Starter graph reviewed — including new explicit starters (RestClient, WebClient, Flyway, security-test)
- Package relocations updated (EntityScan, BootstrapRegistry)
- Jackson migration strategy chosen (native vs bridge)
- Boot Jackson class renames applied (
@JacksonComponent,JsonMapperBuilderCustomizer) - Contract tests added for JSON and API behavior
@MockBean/@SpyBeanusage migrated@AutoConfigureMockMvcadded where neededTestRestTemplate→RestTestClientmigration done@WebMvcTestimport updated to new package- Health probe and DevTools defaults reviewed
- Spring Modulith event publication schema migrated (if applicable)
- Testcontainers artifacts and imports updated (if applicable)
- Canary and rollback plan validated
- Compatibility flags tracked with removal dates
Final Takeaway
If I had to summarize this entire playbook in one word, it would be explicitness:
- explicit dependencies
- explicit JSON behavior
- explicit rollout controls
If we run this as a platform migration, not a version bump, the move is predictable and safe.
If you’re still on Spring Boot 3.5 close to EOL, start now while you still have room to test and iterate without emergency pressure.
References
- Spring Boot 4 migration guide
- Spring Boot supported versions
- Spring Boot support timeline
- Spring Framework 7 release notes
- Spring Security 7 migration guide
- Jackson 3 release notes
- Introducing Jackson 3 support in Spring
- Spring AI 2.0 milestone
- Spring Modulith 2.0 reference
- Testcontainers 2.0 migration guide


