DEV Community

Cover image for Simplify Java and Spring Boot Migration with OpenRewrite (2026 Refresh)
Istvan Verhas
Istvan Verhas

Posted on Fully Autonomous

Simplify Java and Spring Boot Migration with OpenRewrite (2026 Refresh)

How This Post Was Created

There's a delightful irony at the heart of this article.

Back in March 2025, Ky Huynh wrote an excellent post, Simplify Java and SpringBoot migration with OpenRewrite, showing how to jump a legacy app from Java 8 / Spring Boot 2.x to Java 21 / Spring Boot 3.3. Huge kudos to Ky Huynh — the original article is genuinely great, and everything below stands on the shoulders of that work: the sample codebase, the structure, and the clear step-by-step walkthrough are all his. But it's now over a year and a half old, and in the fast-moving Java world that's practically a geological era. The target it recommended — Java 21 and Spring Boot 3.3 — is itself already legacy:

  • Java 25 is the current LTS (released September 2025). Java 21 stops receiving free public updates in September 2026.
  • Spring Boot 4.1 is out (June 2026), and the entire Spring Boot 3.x line left OSS support in June 2026.

In other words, the post that taught us how to escape end-of-life software has itself aged into end-of-life advice. OpenRewrite can rewrite your code, but there's no recipe to rewrite a blog post — so I asked an AI coding agent (Kiro CLI) to do it. It fetched the original article, checked the current versions of Java, Spring Boot, and every OpenRewrite module, discovered a brand-new distribution wrinkle (more on that below), and then — crucially — actually re-ran the whole migration on Ky's original sample project: cloning it, confirming the Spring Boot 2.7 / Java 8 baseline still builds, applying the new recipes to reach Java 25 / Spring Boot 4 / JUnit 6, and verifying the tests still pass. Every screenshot below is real output from that run, not a mock-up.

Consider this the "human-in-the-loop recipe" for keeping content current. Now, on to the actual migration.

Challenges of Migration

Older versions like Spring Boot 2.x and 3.x have reached end-of-life and no longer receive OSS support, so migrating to newer versions is essential for security, compatibility, and performance. The process still comes with the same familiar challenges:

1. Breaking Changes: Major upgrades introduce breaking changes. Spring Boot 3.x already required Java 17 and the move from javax.* to jakarta.*. Spring Boot 4.x raises the bar again, requiring Java 17+ (with Java 21/25 recommended) and building on Spring Framework 7.

2. Deprecated APIs: Commonly used APIs and patterns get deprecated and need replacements.

3. Manual Updates: Traditional migration means manually bumping dependencies, refactoring code, and fixing compatibility issues.

4. Time-Consuming: Large codebases can take weeks or months, increasing project cost and risk.

5. Testing Burden: Every change must be thoroughly tested to ensure functionality is intact.

So how do we simplify and accelerate this? This is where OpenRewrite comes in.

OpenRewrite

OpenRewrite is an open-source tool for automated, large-scale code refactoring that helps teams pay down technical debt. It provides prebuilt refactoring recipes for framework migrations, security fixes, and code styling, cutting effort from hours (or weeks) to minutes.

Plugins for Maven and Gradle make it easy to apply these changes across repositories. Originally focused on Java, the community keeps expanding support to more languages and frameworks.

Key Features

  • Automated Refactoring: Automatically updates code syntax, dependencies, and patterns
  • Recipe-based: Uses declarative recipes to define transformation rules
  • Style Preservation: Maintains original code formatting and comments
  • Large-Scale Changes: Processes entire codebases consistently
  • Extensible: Supports custom recipes for specific migration needs

How does it work?

  • OpenRewrite modifies Lossless Semantic Trees (LSTs), which represent your source code, then prints them back into source.
  • You review the changes and commit them as needed.
  • Modifications are made by Visitors, grouped into Recipes.
  • Recipes keep changes minimally invasive and preserve the original formatting.

⚠️ New in 2026: the Code Genome Project repository

One thing that has changed significantly since the original post: OpenRewrite recipe artifacts are moving from Maven Central to the Code Genome Project repository, which requires authentication.

The rewrite-maven-plugin itself still resolves normally, but if a recipe module doesn't resolve for you, add the Code Genome Project repository (with credentials) to your pom.xml or settings.xml. If you're pinning older recipe versions that are still on Maven Central, you may not hit this yet — but it's the single biggest "gotcha" for anyone following an older tutorial today.

Practice

In this post I'll demonstrate how to migrate a simple CRUD Spring Boot application built with Java 8, Spring Boot 2.x, and JUnit 4 to Java 25, Spring Boot 4, and JUnit 6 using OpenRewrite.

Codebase

1) 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>2.7.14</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot Migration</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <!-- dependencies: starter web, data-jpa, etc -->
    </dependencies>

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

2) UserController.java

@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private UserService userService;

    @RequestMapping(method = RequestMethod.GET)
    public List<User> getAllUsers() {
        return userRepository.findAll();
    }

    @RequestMapping(method = RequestMethod.POST)
    public ResponseEntity<?> createUser(@Valid @RequestBody User user) {
        User savedUser = userRepository.save(user);
        return ResponseEntity.ok().build();
    }

    @RequestMapping(value = "/{id}", method = RequestMethod.GET)
    public ResponseEntity<User> getUserById(@PathVariable("id") Long id) {
        User user = userRepository.findById(id).orElse(null);
        return user != null ? ResponseEntity.ok(user) : ResponseEntity.notFound().build();
    }

    @RequestMapping(value = "/username")
    public ResponseEntity<User> getUserByUsername(@RequestParam String username) {
        User user = userService.findByUsername(username);
        return user != null ? ResponseEntity.ok(user) : ResponseEntity.notFound().build();
    }

}
Enter fullscreen mode Exit fullscreen mode

3) UserService.java

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public User findByUsername(String username) {
        return userRepository.findByUsernameNative(username);
    }
}
Enter fullscreen mode Exit fullscreen mode

4) UserRepository.java

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    @Query(value = "SELECT * FROM users WHERE username = ?1", nativeQuery = true)
    User findByUsernameNative(String username);

}
Enter fullscreen mode Exit fullscreen mode

5) User.java

import javax.persistence.*;
import javax.validation.constraints.NotNull;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @NotNull
    @Column(nullable = false)
    private String username;

    @Column
    private String email;

    // setter, getter
}
Enter fullscreen mode Exit fullscreen mode

6) UserControllerTest.java

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private UserRepository userRepository;

    @Before
    public void setup() {
        userRepository.deleteAll();
    }

    @Test
    public void testCreateUser() throws Exception {
        String userJson = "{\"username\":\"testuser\",\"email\":\"test@example.com\"}";

        mockMvc.perform(MockMvcRequestBuilders.post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(userJson))
                .andExpect(MockMvcResultMatchers.status().isOk());
    }

    @Test
    public void testGetUser() throws Exception {
        User user = new User();
        user.setUsername("testuser");
        user.setEmail("test@example.com");
        userRepository.save(user);

        mockMvc.perform(MockMvcRequestBuilders.get("/api/users/" + user.getId()))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.username").value("testuser"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Manual migration

Before reaching for OpenRewrite, let's see what happens if we manually jump straight to Java 25 and Spring Boot 4.

First, update pom.xml:

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

<properties>
    <java.version>25</java.version>
</properties>
Enter fullscreen mode Exit fullscreen mode

Next, clean and build the project with Maven:

mvn clean install
Enter fullscreen mode Exit fullscreen mode

You'll be greeted by a wall of compilation errors — javax.* imports that no longer exist, deprecated APIs, JUnit 4 annotations that don't compile against JUnit 5, and Spring configuration properties that were renamed or removed across two major versions. Doing this by hand across a real codebase is exactly the slog OpenRewrite was built to eliminate.

Here's the actual output from doing exactly that on the sample project — bumping the parent to Spring Boot 4 and java.version to 25, then running mvn clean compile on JDK 25:

Manual migration compile failure: 12 errors — javax.persistence / javax.validation packages do not exist, and @Entity, @Table, @Id, @GeneratedValue, @Column, @NotNull, @Valid cannot be found

Twelve compilation errors before you've fixed a single line by hand. Okay, let's revert those changes and migrate with OpenRewrite instead.

Migration with OpenRewrite

Add the OpenRewrite plugin

In pom.xml, add the OpenRewrite Maven plugin (Gradle users can add the Gradle plugin). Note the version bump — the original post used 6.3.2; the plugin used for this walkthrough is 6.46.1 (the current line is 6.4x):

<build>
    <plugins>
        <plugin>
            <groupId>org.openrewrite.maven</groupId>
            <artifactId>rewrite-maven-plugin</artifactId>
            <version>6.46.1</version>
        </plugin>
    </plugins>
</build>
Enter fullscreen mode Exit fullscreen mode

Choose the recipes for migration

To discover all the available recipes, check the Recipe catalog, which lists everything for Java, Spring Boot, Hibernate, Quarkus, and more.

For a full modernization, I'll chain four recipes: Java 8 → 25, JUnit 4 → 5, JUnit 5 → 6, and Spring Boot → 4.0. A nice detail here: the Spring Boot 4 recipe internally runs the intermediate upgrades (2.x → 3.x → 4.x) for you, so you don't have to stage them manually.

Add the recipes and their recipe modules to pom.xml. The versions below are the ones actually used for this walkthrough; recipe modules move quickly (roughly a release every 2–4 weeks), so always cross-check the latest versions of every OpenRewrite module:

<plugin>
    <groupId>org.openrewrite.maven</groupId>
    <artifactId>rewrite-maven-plugin</artifactId>
    <version>6.46.1</version>
    <configuration>
        <exportDatatables>true</exportDatatables>
        <activeRecipes>
            <recipe>org.openrewrite.java.migrate.UpgradeToJava25</recipe>
            <recipe>org.openrewrite.java.spring.boot2.SpringBoot2JUnit4to5Migration</recipe>
            <recipe>org.openrewrite.java.testing.junit6.JUnit5to6Migration</recipe>
            <recipe>org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0</recipe>
        </activeRecipes>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>org.openrewrite.recipe</groupId>
            <artifactId>rewrite-migrate-java</artifactId>
            <version>3.42.1</version>
        </dependency>
        <dependency>
            <groupId>org.openrewrite.recipe</groupId>
            <artifactId>rewrite-spring</artifactId>
            <version>6.37.1</version>
        </dependency>
        <dependency>
            <groupId>org.openrewrite.recipe</groupId>
            <artifactId>rewrite-testing-frameworks</artifactId>
            <version>3.44.0</version>
        </dependency>
    </dependencies>
</plugin>
Enter fullscreen mode Exit fullscreen mode

Note on Java 25: the rewrite-migrate-java module ships incremental recipes — UpgradeToJava17, UpgradeToJava21, and now UpgradeToJava25. The UpgradeToJava25 recipe transparently chains the earlier steps (25 → 21 → 17 → 11), so you can target the current LTS directly, as this walkthrough does. The Spring Boot 4 upgrade also independently ensures your build targets a Java version compatible with Spring Boot 4.

Note on the Code Genome Project: at the time of writing, the recipe module versions above still resolved fine from Maven Central — but OpenRewrite is actively migrating recipe distribution to the Code Genome Project repository, which requires authentication. If a newer recipe module fails to resolve, add that repository (with credentials) to your pom.xml or settings.xml. This distribution change is the biggest thing to watch for since the 2025 version of this tutorial.

Already partway there? The same config still works

You might already have done part of this journey — say you migrated to Java 17, Spring Boot 3, and JUnit 5 a while ago and only now want to reach Java 25 / Spring Boot 4 / JUnit 6. The good news: you can use the exact same configuration above. OpenRewrite recipes are effectively idempotent — they operate on the Lossless Semantic Tree and only make a change where one is actually needed. Code that has already been upgraded simply won't be touched.

So the UpgradeToJava25 recipe leaves your already-Java-17+ code alone (it only applies the steps you still need), SpringBoot2JUnit4to5Migration does nothing if there's no JUnit 4 left, and the Spring Boot 4 recipe skips the 2.x → 3.x steps you've already completed and picks up only from where you are. There's no need to build a different, "resume-from-here" configuration for partially-migrated projects — point the same recipes at the codebase, run dryRun to confirm the scope, and only the genuinely outstanding changes show up in the diff.

Now install the plugin and its recipes:

mvn clean install
Enter fullscreen mode Exit fullscreen mode

Preview the migration

OpenRewrite provides a dryRun mode so you can preview changes before applying them:

mvn rewrite:dryRun
Enter fullscreen mode Exit fullscreen mode

This writes a patch file (by default under target/rewrite/rewrite.patch) that you can inspect with git diff or any diff viewer — no source files are touched. The console prints the full recipe tree it would apply, so you can see exactly how the high-level recipes decompose into the intermediate steps:

mvn rewrite:dryRun output showing the recipe tree — UpgradeToJava25 chaining down to Java 21/17/11, UpgradeSpringBoot_4_0 chaining down through 3.5 → 3.0 → Spring Framework 6.0 → Jakarta EE 10, and the JUnit 4 → 5 migration — ending in BUILD SUCCESS with

Notice how UpgradeSpringBoot_4_0 transparently runs the whole 3.5 → 3.4 → … → 3.0 chain, then Spring Framework 6.0, then the Jakarta EE 10 javax.* → jakarta.* migration. You didn't have to stage any of that. OpenRewrite even prints an "Estimate time saved: 2h 34m" — for a five-file toy project.

Apply the migration

When you're happy with the preview, run:

mvn rewrite:run
Enter fullscreen mode Exit fullscreen mode

Then use your IDE or a diff checker to review the changes. Here's what actually happened on the sample project (Spring Boot 2.7.14 / Java 8 / JUnit 4 → Spring Boot 4.0.8 / Java 25 / JUnit 6):

1. Updated pom.xml — the Spring Boot parent jumps from 2.7.14 to 4.0.8, java.version from 1.8 to 25, spring-boot-starter-web becomes the new Boot 4 modular spring-boot-starter-webmvc, and the old JUnit 4 dependency is replaced with spring-boot-starter-webmvc-test:

pom.xml diff: parent 2.7.14 → 4.0.8, java.version 1.8 → 25, spring-boot-starter-web → spring-boot-starter-webmvc, and the JUnit 4 dependency replaced by spring-boot-starter-webmvc-test

2. Updated Controller — migrates javax.validation.Valid to jakarta.validation.Valid, and swaps every @RequestMapping(method = ...) for the dedicated @GetMapping / @PostMapping annotations:

UserController.java diff: javax.validation.Valid → jakarta.validation.Valid; @RequestMapping(method = RequestMethod.GET) → @GetMapping; @RequestMapping(method = RequestMethod.POST) → @PostMapping; @RequestMapping(value =

3. Updated Entity and Repository — the JPA/validation imports on the User entity move from javax.* to jakarta.*, and — a nice bonus — the repository's @Query(..., nativeQuery = true) is rewritten to the newer dedicated @NativeQuery annotation:

User.java and UserRepository.java diff: javax.persistence.* / javax.validation.constraints.NotNull → jakarta.*, and @Query(nativeQuery = true) → @NativeQuery

4. Updated Unit Test — replaces JUnit 4's @RunWith(SpringRunner.class) and @Before with JUnit Jupiter's @BeforeEach (dropping the now-unnecessary SpringRunner entirely), and updates AutoConfigureMockMvc to its new Spring Boot 4 spring-boot-webmvc-test package:

UserControllerTest.java diff: org.junit imports and @RunWith(SpringRunner.class) removed; @Before → @BeforeEach with org.junit.jupiter.api imports; AutoConfigureMockMvc moved to org.springframework.boot.webmvc.test.autoconfigure

Because the Spring Boot 4 recipe runs the intermediate 2.x → 3.x → 4.x steps, you also get the Spring Framework 6/7 property renames and configuration-key migrations handled along the way.

Verify it actually works

The real test of any migration isn't the diff — it's whether the result compiles and the tests still pass. On JDK 25, with the migrated Spring Boot 4.0.8 / JUnit 6 code:

mvn clean test on JDK 25 — Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS

Green across the board. The same two tests that passed on Spring Boot 2.7 / JUnit 4 now pass on Spring Boot 4 / JUnit 6, without a single line changed by hand.

Limitations

OpenRewrite successfully carries the app to Java 25 and Spring Boot 4, but the limitations from the original post still hold — and are worth restating:

Because OpenRewrite relies on predefined recipes, it supports many common frameworks but not all of them. If you depend on a third-party library that lacks a recipe (the classic example being something like Ehcache2 → Ehcache3), you'll need to either write a custom recipe or handle that part manually.

If you write a custom recipe, consider contributing it back to the OpenRewrite community to help others facing the same migration.

A newer, practical limitation: with recipes moving to the Code Genome Project, first-time setup now involves configuring an authenticated repository. Budget a little extra time for that in CI environments.

Summary

OpenRewrite still dramatically simplifies Java and Spring Boot migration by:

  • Automating repetitive code changes
  • Reducing migration time and effort
  • Minimizing human error
  • Standardizing the migration approach

What has changed in the year and a half since the original post is the destination: the sensible target is now Java 25, Spring Boot 4, and JUnit 6, applied with a much newer plugin (6.46.1) and recipe modules that are migrating from Maven Central to the Code Genome Project. The workflow — add plugin, pick recipes, dryRun, run, review — is reassuringly the same.

OpenRewrite still doesn't eliminate the need for testing and validation, but it removes most of the mechanical toil so you can focus on the genuinely tricky parts: business logic and the corners no recipe covers yet.

And if you take one meta-lesson from this refresh: content ages just like code. The difference is that code has OpenRewrite — content still needs a human (or a helpful agent) to keep it current.

Credits

All the real credit for the approach goes to Ky Huynh. This is a 2026 refresh of his original post, Simplify Java and SpringBoot migration with OpenRewrite — the walkthrough structure and the sample codebase (hgky95/TIL) are his, so please go read (and give some love to) the original.

Unlike the versions and recipe numbers, the screenshots in this post are all freshly captured — the migration was actually run on Ky's sample project (Spring Boot 2.7.14 / Java 8 / JUnit 4) against Java 25, Spring Boot 4.0.8, and JUnit 6, and every diff, the dryRun recipe tree, the manual-failure output, and the passing test run are the genuine console/git diff output from that run, rendered for readability. In other words: the versions were verified, the migration was executed end-to-end, the tests pass, and the images show exactly what happened — drafted with an AI coding agent and reviewed by a human.

References

  1. Original post — Ky Huynh (Mar 2025)
  2. OpenRewrite docs
  3. Migrate to Spring Boot 4.0 recipe
  4. OpenRewrite Maven plugin configuration
  5. Latest versions of every OpenRewrite module
  6. Spring Boot end-of-life dates

Top comments (0)