Featured image of post [Spring Boot Migration] From 2.7 to 4.0: A Phased Migration Strategy

[Spring Boot Migration] From 2.7 to 4.0: A Phased Migration Strategy

A practical guide to migrating Spring Boot from 2.7 to 4.0. Covers why skipping straight to 4.0 is a trap, how to navigate the Jakarta EE chasm, and why the best code is the code you get to delete.

Direct Answer & TL;DR

  1. Spring Boot 2.7 is EOL. If it’s still running in production, stop reading and start planning your migration. A server with no security patches is just an open door
  2. Do NOT jump straight from 2.7 to 4.0. Simultaneously handling a Java version jump, namespace rename, and Spring core overhaul will make debugging feel like defusing three bombs at once.
  3. The answer is a 2-phase Phased Migration: 2.7 → 3.5 (ecosystem transition) → 3.5 → 4.0 (architectural modernization). One problem at a time — that’s the entire strategy.

“Can’t We Just Go Straight to 4.0?”

Every Spring Boot migration discussion eventually produces this question. And honestly? It’s reasonable. Skipping a middle version feels like it should save time.

I used to think so too — until I witnessed a team attempt a two-major-version jump firsthand. Watching them try to triage a production incident where Java, Jakarta, and Spring Core all changed at once was… not fun. When too many things change simultaneously, nothing is under control.

This post breaks down the path from Spring Boot 2.7 to 4.0 through version-by-version spec comparisons and a practical two-phase migration strategy.


Version Comparison: Know Your Terrain

Migration complexity is determined by the distance between your starting point and your destination. Let’s be honest about where each version stands.

Spring Boot 2.7 (Legacy) Spring Boot 3.5 (Midpoint) Spring Boot 4.0 (Latest)
Released May 2022 May 2025 Nov 2025
Support Status EOL (Nov 2023) Active Latest GA
Minimum Java Java 8 / 11 Java 17+ Java 17 / 21 recommended
Spring Framework 5.x 6.x 7.0
EE Namespace javax.* jakarta.* jakarta.*
Key Features Legacy enterprise standard Structured Logging, stable Native Image Modularity, HTTP Service Clients autoconfigure, API Versioning

[!WARNING] Spring Boot 2.7 stopped receiving OSS security patches in November 2023. Vulnerabilities discovered after that date will not be patched. Keeping 2.7 in production is not just technical debt — it’s an operational risk.

Notice that between 2.7 and 4.0, three fundamental things change simultaneously: Java version, Spring Framework major version, and EE package namespace. When those three collide in a single migration, the stack traces you’ll see will be… creative. “Creative” is a polite way to say “maddening”


The Migration Strategy: Why Two Phases?

The Spring Boot team — and tools like OpenRewrite — don’t recommend skipping major versions. The tooling itself is built around sequential recipes for a reason.

[Current]              [Phase 1]               [Phase 2]
Spring Boot 2.7  →→→  Spring Boot 3.5  →→→  Spring Boot 4.0
    Java 8/11              Java 17+               Java 21+
    javax.*                jakarta.*              jakarta.*
    Framework 5.x          Framework 6.x          Framework 7.0

The two phases have fundamentally different characters. Keep them separate.


Phase 1: 2.7 → 3.5 — The Jakarta Chasm

This is the riskiest leg of the journey. It earns its dramatic name: you’re crossing the Jakarta Chasm — a namespace break that touches virtually every corner of the application.

① Upgrade to Java 17+

2.7 supports Java 8. 3.5 does not. Beyond swapping the JDK, watch for:

  • Removed APIs: Direct sun.* package access, old-style Date APIs
  • JPMS Encapsulation: The module system blocks access to internal JDK APIs that some older libraries relied on
  • GC Changes: G1GC is the default from Java 17; verify your tuning parameters still apply

[!TIP] Run jdeprscan against your codebase before migrating to get a preview of deprecated API usage. And compile with --release 17 to surface issues early — better to be surprised in dev than in prod.

② Jakarta EE Namespace Migration

This is the centerpiece of Phase 1 — and the most tedious. Every javax.* import has to become jakarta.*.

// Before (Spring Boot 2.7 / javax)
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.NotNull;

// After (Spring Boot 3.5 / jakarta)
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotNull;

The imports are mechanical. The real work is auditing third-party libraries for Jakarta EE compatibility. QueryDSL, MapStruct, ORM extensions — every dependency needs a version check.

[!CAUTION] QueryDSL deserves special attention. Official QueryDSL 5.x was slow to support Jakarta EE, pushing teams toward community forks like querydsl-jakarta. Check the latest release status and have a fallback plan ready.

③ Hibernate 6.x Compatibility

Spring Boot 3.5 ships with Hibernate 6.x. The jump from 5.x introduces subtle behavioral changes in the JPQL parser, type mapping, and batch operations.

Area What Changed
@Formula Expression parsing behavior changed in some cases
Native Queries Tuple mapping approach differs
Criteria API Some CriteriaBuilder method signatures changed
Batch INSERT Verify hibernate.jdbc.batch_size behavior

[!NOTE] Phase 1 is where your test suite earns its keep. Manually validating every JPQL query is not realistic. If you don’t have integration tests covering the Repository layer, write them before you start the migration — not after.


Phase 2: 3.5 → 4.0 — Architectural Modernization

Once 3.5 is stable in production, Phase 2 introduces architectural improvements rather than ecosystem compatibility breaks. It’s less traumatic — but the changes run deeper in design.

① Own Your Dependency Management

Spring Boot 4.0 changes how spring-boot-parent publishes its BOM. If you’ve been letting spring-boot-starter-parent manage all your versions automatically, you may need to take explicit ownership:

<!-- Explicit dependency management for Boot 4.0 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>4.0.x</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

② Replace RestTemplate with @HttpExchange

RestTemplate has been in maintenance mode since Spring 5.x. In Spring Boot 4.0, the @HttpExchange interface-based client is the idiomatic approach.

// Before: RestTemplate (legacy imperative style)
public UserDto getUser(Long id) {
    return restTemplate.getForObject(
        "https://api.example.com/users/{id}",
        UserDto.class,
        id
    );
}

// After: @HttpExchange (declarative interface)
@HttpExchange("https://api.example.com")
public interface UserClient {
    @GetExchange("/users/{id}")
    UserDto getUser(@PathVariable Long id);
}

The interface-based approach improves testability and reduces coupling — your service layer stops caring about how HTTP calls are executed.

③ Standardize API Versioning

Scattered API versioning logic — URL params, custom headers, path segments — can be unified under spring.mvc.apiversion.* autoconfiguration:

spring:
  mvc:
    apiversion:
      default-version: "1.0"
      supported-versions: ["1.0", "2.0"]

This delegates versioning to the framework and lets you delete the custom filter or interceptor you wrote years ago.


“But What If We Just Go Straight to 4.0?” — Revisited

The counterargument: “Skip 3.x to save time and engineering effort.”

Here’s why that backfires every time:

  1. Debugging surface explodes: When Java version, EE namespace, and Spring Core all change at once, a runtime failure becomes nearly impossible to triage. Was it the namespace? A Hibernate change? A JVM behavioral difference? Good luck narrowing it down.

  2. Even the tools say no: OpenRewrite’s migration recipes don’t support version-skipping. They’re explicitly designed as sequential steps: 2.7 → 3.0 first, then 3.x → 4.0. The tooling enforces phased migration because experience shows it works.

  3. Rollback scope becomes unmanageable: Phased migration gives you stable checkpoints. A Big-bang migration means your rollback unit is the entire upgrade — at which point, you’re essentially starting over.

What looks like a shortcut tends to become the longer road. I’ve seen teams pivot mid-migration — starting Big-bang, then realizing they needed to break it into phases anyway. If only they’d done that from the start By the time they finished, they’d taken longer than teams who chose the phased approach upfront.


Insight: The More You Delegate, the Clearer Your Code Gets

Migration is usually framed as “paying down technical debt.” That’s true — but it undersells the real opportunity. Spring Boot 4.0’s modularity and enhanced autoconfiguration let you hand off infrastructure concerns to the framework. And when you do that, the code you keep becomes sharper. When the framework handles the plumbing, your code has one job: expressing business logic.

Here’s what that looks like in practice:

What You Had (Custom Code) What Replaces It (Framework) Net Effect
RestClient wrapper (~50 lines) @HttpExchange interface (~5 lines) Implementation hidden, intent visible
Custom API versioning filter spring.mvc.apiversion.* config (3 lines) Declarative, not imperative
Hand-rolled structured logging utility 3.x Structured Logging (built-in) 0 lines of code

Less code — but more readable. A @HttpExchange interface tells you immediately what the client does. A 50-line RestClient wrapper tells you how it does it — which is rarely what you need to know.

[!TIP] For every custom utility you encounter during migration, ask: “Does the framework now handle this?” If yes, delegate it. If it’s genuine business logic, keep it. That distinction, applied consistently, is how codebases get healthier over time.

Editor’s note: If your commit history after migration shows more remove: entries than feat: entries — you did it right. May your legacy javax code transition to jakarta, and then, ideally, transition to nothing at all. ☕


🔗 References

Experience · Understanding · Insight · Contact