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>
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();
}
}
3) UserService.java
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User findByUsername(String username) {
return userRepository.findByUsernameNative(username);
}
}
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);
}
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
}
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"));
}
}
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>
Next, clean and build the project with Maven:
mvn clean install
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:
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>
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>
Note on Java 25: the
rewrite-migrate-javamodule ships incremental recipes —UpgradeToJava17,UpgradeToJava21, and nowUpgradeToJava25. TheUpgradeToJava25recipe 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.xmlorsettings.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
Preview the migration
OpenRewrite provides a dryRun mode so you can preview changes before applying them:
mvn rewrite:dryRun
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:
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
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:
2. Updated Controller — migrates javax.validation.Valid to jakarta.validation.Valid, and swaps every @RequestMapping(method = ...) for the dedicated @GetMapping / @PostMapping annotations:
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:
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:
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:
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.







Top comments (0)