From painful to predictable migrations
Building predictable, repeatable technical evolution

Technical migration is the art of leaving the cozy comfort of Java 11 to go hunting for Java 21 Virtual Threads, tripping over every Java 17 record along the way, all while knowing perfectly well that Java 25 will be released before you’re even done.
It’s an epic journey where Spring Boot suddenly informs you that your javax imports are no longer welcome—almost shameful.
But the true master of sadism is JUnit : moving from v4 to v5 serves mainly to remind you of your place. You lose your @Rules, rename @Before to @BeforeEach, and eventually enable the Vintage engine in tears, admitting that your code — like you — fundamentally refuses to grow up.
But what if all of this belonged to the past?
What if future technical migrations were simple, repeatable, and above all… predictable?
What if we stopped improvising migrations?
For years, technical migrations have been treated as exceptional events. They’re prepared late, executed fast, and everyone hopes not to deal with them again for a long time. Each version upgrade becomes a risky expedition, dependent on the team’s collective memory and a few developers who “still remember how it used to work.”
The problem isn’t the migration itself.
The problem is that it’s neither tooled nor capitalized.
Every time, we start over:
- the same research,
- the same replacements,
- the same subtle adjustments that only the compiler eventually reveals.
And once the migration is done, all that knowledge disappears with the Git branch.
OpenRewrite: turning migration into know-how
OpenRewrite offers a radically different approach: treating migration not as a one-off project, but as a set of formalized transformations, versioned and replayable.
This is an important point: OpenRewrite is an open-source tool, distributed under the Apache 2.0 license.
The engine, existing recipes, and extension APIs are publicly accessible. This means migration rules are not a black box: they can be read, understood, adapted, and enriched to meet the real needs of a project.
https://github.com/openrewrite
We’re not talking about simple scripts or search & replace. OpenRewrite operates at a semantic level:
- it understands Java code,
- it applies rules aware of the language, frameworks, and their evolution.
A migration then becomes:
- predictable, because transformations are explicit;
- repeatable, since a recipe can be replayed identically;
- capitalizable, as migration code itself becomes a project artifact, preserved just like the rest of the source code.
Preparing today for tomorrow’s migrations
In the rest of this article, we’ll see how this approach applies concretely to major evolutions:
- upgrading Spring Boot 3 to 4,
- moving from Java 21 to Java 25,
- evolving JUnit 5 to JUnit 6.
The goal isn’t just to get a green build, but to show how these migrations can be prepared, automated, and passed on, so future evolutions are no longer disruptive, but the natural continuation of a living project.
But before that…
How does OpenRewrite work?
OpenRewrite is neither a code generator nor a “magic” migration tool. It’s a source code transformation engine, designed to apply structured, safe, and reproducible changes.
Its operation relies on a simple principle:
we don’t modify text, we transform structure.
Code analysis
When OpenRewrite runs, it starts by:
- parsing the source code,
- building an AST (Abstract Syntax Tree),
- enriching that tree with type, import, and dependency information.
At this stage, the code is not yet modified. OpenRewrite first seeks to understand what the application actually does.
Recipes: the heart of the system
A recipe is a formal description of a change to apply to the code.
It always answers the same question:
if I encounter this structure in the code, what should I do with it?
A recipe can:
- replace an import,
- rename a class or method,
- modify an annotation,
- rewrite a test,
- or orchestrate several smaller transformations.
There are two main categories:
Atomic recipes
Simple, targeted transformations:
- replacing @Before with @BeforeEach,
- migrating a javax package to jakarta,
- adapting a method signature.
Composite recipes
Higher-level recipes that chain multiple transformations:
- a full migration from Spring Boot 3 to 4,
- a Java version upgrade,
- a major evolution of a testing framework.
These composite recipes are usually the ones applied directly in a project.
A recipe is code, not magic configuration
This is fundamental: a recipe is itself code.
It can be:
- provided by OpenRewrite,
- written by the community,
- or developed internally for a specific project.
This allows you to:
- version migrations alongside the rest of the project,
- test them,
- replay them as many times as needed,
- and, above all, evolve them over time.
Migration stops being a one-off event; it becomes a technical asset.
Execution: applying without breaking
During execution:
- OpenRewrite traverses the AST,
- applies the configured recipes,
- regenerates the modified source code.
Formatting is preserved as much as possible, and transformations remain localized and explicit.
Nothing is applied blindly.
And now, let’s move on to a concrete example.
Example
For the purposes of this example, I created a small project available here:
GitHub — ErwanLT/openrewrite-demo
This project contains an application with the following characteristics:
- Spring Boot 3.5.9
- Java 21
- tests in JUnit 5, with some still in JUnit 4
- methods and annotations marked as deprecated
In short, an ideal playground to demonstrate the tool’s usefulness.
Installing OpenRewrite
Integrating OpenRewrite into a Spring Boot project is done in a very traditional way, via the official Maven plugin. No external tools, no exotic scripts: OpenRewrite fits exactly where Java teams are used to working — at the heart of the build.
Installation consists of declaring the rewrite-maven-plugin and explicitly specifying which recipes should be applied. This configuration makes migrations visible, intentional, and versioned alongside the rest of the project.
<plugin>
<groupId>org.openrewrite.maven</groupId>
<artifactId>rewrite-maven-plugin</artifactId>
<version>6.26.0</version>
<configuration>
<exportDatatables>true</exportDatatables>
<activeRecipes>
<recipe>org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0</recipe>
<recipe>org.openrewrite.java.migrate.UpgradeToJava25</recipe>
<recipe>org.openrewrite.java.testing.junit6.JUnit5to6Migration</recipe>
</activeRecipes>
</configuration>
<dependencies>
<dependency>
<groupId>org.openrewrite.recipe</groupId>
<artifactId>rewrite-spring</artifactId>
<version>6.21.0</version>
</dependency>
<dependency>
<groupId>org.openrewrite.recipe</groupId>
<artifactId>rewrite-migrate-java</artifactId>
<version>3.24.0</version>
</dependency>
<dependency>
<groupId>org.openrewrite.recipe</groupId>
<artifactId>rewrite-testing-frameworks</artifactId>
<version>3.24.0</version>
</dependency>
</dependencies>
</plugin>
First, active recipes are explicitly declared. Here, the migration is not limited to a single axis, but covers the entire technical foundation:
- Spring Boot 3 to 4,
- Java 21 to Java 25,
- JUnit 5 to JUnit 6.
Next, recipes are not bundled by default: they’re brought in via dedicated dependencies, each corresponding to a specific domain (Spring, Java, testing frameworks). This separation makes intent clear and avoids implicit or accidental migrations.
Finally, this configuration is durable. It can be replayed on another branch, another project, or inside a CI pipeline. Migration is no longer exceptional; it becomes an intrinsic capability of the project.
Once this foundation is in place, all that remains is to run OpenRewrite and observe how these recipes concretely transform the code. That’s what we’ll see next with a real migration example.
Discovering available recipes: discover
Before applying any transformation, OpenRewrite allows you to explore what’s available. The discover command serves precisely this purpose: listing recipes applicable to the current project.
It analyzes the code, dependencies, and build configuration to propose relevant recipes — whether related to Java versions, Spring Boot, or testing frameworks in use.
One essential point to understand is that these recipes are often composite. A high-level migration may include many sub-recipes. If some of them are declared multiple times — directly or indirectly — OpenRewrite will only apply them once. The engine automatically deduplicates transformations to avoid redundancy or side effects.
This step is often underestimated, yet it plays a crucial role:
- it reveals the true scope of possible migrations,
- it prevents activating inappropriate or unnecessary recipes,
- it provides a clear, structured view of the evolutions supported by the OpenRewrite ecosystem.
You don’t move forward blindly; you survey the terrain first.
[INFO] --- rewrite:6.26.0:discover (default-cli) @ openrewrite-demo ---
[INFO] Available Recipes:
[INFO] com.google.guava.InlineGuavaMethods
[INFO] org.apache.logging.log4j.InlineLog4jApiMethods
[INFO] org.openrewrite.AddToGitignore
[INFO] org.openrewrite.analysis.controlflow.ControlFlowVisualization
[INFO] org.openrewrite.analysis.search.FindFlowBetweenMethods
...
[INFO] Available Styles:
[INFO] com.netflix.eureka.Style
[INFO] com.netflix.genie.Style
[INFO] org.openrewrite.java.GoogleJavaFormat
[INFO] org.openrewrite.java.IntelliJ
[INFO] org.openrewrite.java.SpringFormat
[INFO] org.openrewrite.kotlin.IntelliJ
[INFO]
[INFO] Active Styles:
[INFO]
[INFO] Active Recipes:
[INFO] org.openrewrite.java.migrate.UpgradeToJava25
[INFO] org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0
[INFO] org.openrewrite.java.testing.junit6.JUnit5to6Migration
[INFO]
[INFO] Found 2860 available recipes and 6 available styles.
[INFO] Configured with 3 active recipes and 0 active styles.
Simulating without modifying: dryRun
Once recipes are selected, it’s tempting to execute them immediately. However, OpenRewrite provides a valuable intermediate step: dryRun.
This command applies recipes without modifying the source code. It allows you to see exactly:
- which files would be impacted,
- which transformations would be applied,
- and how the code would evolve.
Concretely, OpenRewrite generates diff files in the target directory. These files contain a clear representation of upcoming changes for each affected resource. You can review them like a code review—without touching the project yet.
This is a fundamental safety net. It allows you to:
- validate recipe intent,
- reassure the team about the true scope of the migration,
- discuss and adjust choices before any actual modification.
dryRun turns migration into a subject for reflection and review, rather than an irreversible blind operation.
[INFO] Using active recipe(s) [org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0, org.openrewrite.java.migrate.UpgradeToJava25, org.openrewrite.java.testing.junit6.JUnit5to6Migration]
[INFO] Using active styles(s) []
[INFO] Validating active recipes...
[INFO] Project [openrewrite-demo] Resolving Poms...
[INFO] Project [openrewrite-demo] Parsing source files
[INFO] Running recipe(s)...
[INFO] Printing available datatables to: target/rewrite/datatables/2026-01-08_08-39-02-848
[WARNING] These recipes would make changes to pom.xml:
[WARNING] org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_4
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_1
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0
[WARNING] org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_7
[WARNING] org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_6
[WARNING] org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_5
[WARNING] org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_4
[WARNING] org.openrewrite.java.spring.boot2.SpringBoot2JUnit4to5Migration
[WARNING] org.openrewrite.java.testing.junit5.JUnit4to5Migration
[WARNING] org.openrewrite.java.dependencies.RemoveDependency: {groupId=junit, artifactId=junit}
[WARNING] org.openrewrite.java.testing.junit5.ExcludeJUnit4UnlessUsingTestcontainers
[WARNING] org.openrewrite.maven.ExcludeDependency
[WARNING] org.openrewrite.java.dependencies.RemoveDependency: {groupId=org.junit.vintage, artifactId=junit-vintage-engine}
[WARNING] org.openrewrite.java.dependencies.UpgradeDependencyVersion: {groupId=org.springdoc, artifactId=*, newVersion=2.8.x}
[WARNING] org.openrewrite.java.spring.boot4.MigrateToModularStarters
[WARNING] org.openrewrite.java.dependencies.AddDependency: {groupId=org.springframework.boot, artifactId=spring-boot-starter-webmvc-test, version=4.0.x, onlyIfUsing=org.springframework.boot.test.autoconfigure.web.servlet.*}
[WARNING] org.openrewrite.maven.UpgradeParentVersion: {groupId=org.springframework.boot, artifactId=spring-boot-starter-parent, newVersion=4.0.x}
[WARNING] org.openrewrite.java.dependencies.ChangeDependency: {oldGroupId=org.springframework.boot, oldArtifactId=spring-boot-starter-web, newArtifactId=spring-boot-starter-webmvc}
[WARNING] org.openrewrite.java.migrate.UpgradeToJava25
[WARNING] org.openrewrite.java.migrate.UpgradeJavaVersion: {version=25}
[WARNING] org.openrewrite.maven.UpdateMavenProjectPropertyJavaVersion: {version=25}
[WARNING] These recipes would make changes to src/main/java/fr/eletutour/openrewritedemo/exception/ResourceNotFoundException.java:
[WARNING] org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_4
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_1
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0
[WARNING] org.openrewrite.java.migrate.UpgradeToJava17
[WARNING] org.openrewrite.java.migrate.lang.StringFormatted: {addParentheses=false}
[WARNING] org.openrewrite.java.migrate.UpgradeToJava25
[WARNING] org.openrewrite.java.migrate.UpgradeJavaVersion: {version=25}
[WARNING] These recipes would make changes to src/test/java/fr/eletutour/openrewritedemo/controller/LegacyControllerTest.java:
[WARNING] org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_4
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_1
[WARNING] org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0
[WARNING] org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_7
[WARNING] org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_6
[WARNING] org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_5
[WARNING] org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_4
[WARNING] org.openrewrite.java.spring.boot2.SpringBoot2JUnit4to5Migration
[WARNING] org.openrewrite.java.testing.junit5.JUnit4to5Migration
[WARNING] org.openrewrite.java.testing.junit5.UpdateTestAnnotation
[WARNING] org.openrewrite.java.spring.boot2.UnnecessarySpringRunWith
[WARNING] org.openrewrite.java.testing.junit5.RunnerToExtension: {runners=[org.springframework.test.context.junit4.SpringRunner, org.springframework.test.context.junit4.SpringJUnit4ClassRunner], extension=org.springframework.test.context.junit.jupiter.SpringExtension}
[WARNING] org.openrewrite.java.spring.boot2.UnnecessarySpringExtension
[WARNING] org.openrewrite.java.spring.boot4.ReplaceMockBeanAndSpyBean
[WARNING] org.openrewrite.java.ChangeType: {oldFullyQualifiedTypeName=org.springframework.boot.test.mock.mockito.MockBean, newFullyQualifiedTypeName=org.springframework.test.context.bean.override.mockito.MockitoBean}
[WARNING] org.openrewrite.java.spring.boot4.MigrateToModularStarters
[WARNING] org.openrewrite.java.spring.boot4.MigrateAutoconfigurePackages
[WARNING] org.openrewrite.java.ChangePackage: {oldPackageName=org.springframework.boot.test.autoconfigure.web.servlet, newPackageName=org.springframework.boot.webmvc.test.autoconfigure, recursive=true}
[WARNING] org.openrewrite.java.migrate.UpgradeToJava25
[WARNING] org.openrewrite.java.migrate.UpgradeJavaVersion: {version=25}
[WARNING] These recipes would make changes to src/test/java/fr/eletutour/openrewritedemo/controller/ArticleControllerTest.java:
[WARNING] org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0
[WARNING] org.openrewrite.java.spring.framework.UpgradeSpringFramework_7_0
[WARNING] org.openrewrite.java.jackson.UpgradeJackson_2_3
[WARNING] org.openrewrite.java.jackson.UpgradeJackson_2_3_PackageChanges
[WARNING] org.openrewrite.java.ChangePackage: {oldPackageName=com.fasterxml.jackson.databind, newPackageName=tools.jackson.databind, recursive=true}
[WARNING] org.openrewrite.java.spring.boot4.ReplaceMockBeanAndSpyBean
[WARNING] org.openrewrite.java.ChangeType: {oldFullyQualifiedTypeName=org.springframework.boot.test.mock.mockito.MockBean, newFullyQualifiedTypeName=org.springframework.test.context.bean.override.mockito.MockitoBean}
[WARNING] org.openrewrite.java.spring.boot4.MigrateToModularStarters
[WARNING] org.openrewrite.java.spring.boot4.MigrateAutoconfigurePackages
[WARNING] org.openrewrite.java.ChangePackage: {oldPackageName=org.springframework.boot.test.autoconfigure.web.servlet, newPackageName=org.springframework.boot.webmvc.test.autoconfigure, recursive=true}
[WARNING] org.openrewrite.java.migrate.UpgradeToJava25
[WARNING] org.openrewrite.java.migrate.UpgradeJavaVersion: {version=25}
[WARNING] These recipes would make changes to src/test/java/fr/eletutour/openrewritedemo/controller/AuthorControllerTest.java:
[WARNING] org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0
[WARNING] org.openrewrite.java.spring.framework.UpgradeSpringFramework_7_0
[WARNING] org.openrewrite.java.jackson.UpgradeJackson_2_3
[WARNING] org.openrewrite.java.jackson.UpgradeJackson_2_3_PackageChanges
[WARNING] org.openrewrite.java.ChangePackage: {oldPackageName=com.fasterxml.jackson.databind, newPackageName=tools.jackson.databind, recursive=true}
[WARNING] org.openrewrite.java.spring.boot4.ReplaceMockBeanAndSpyBean
[WARNING] org.openrewrite.java.ChangeType: {oldFullyQualifiedTypeName=org.springframework.boot.test.mock.mockito.MockBean, newFullyQualifiedTypeName=org.springframework.test.context.bean.override.mockito.MockitoBean}
[WARNING] org.openrewrite.java.spring.boot4.MigrateToModularStarters
[WARNING] org.openrewrite.java.spring.boot4.MigrateAutoconfigurePackages
[WARNING] org.openrewrite.java.ChangePackage: {oldPackageName=org.springframework.boot.test.autoconfigure.web.servlet, newPackageName=org.springframework.boot.webmvc.test.autoconfigure, recursive=true}
[WARNING] org.openrewrite.java.migrate.UpgradeToJava25
[WARNING] org.openrewrite.java.migrate.UpgradeJavaVersion: {version=25}
[WARNING] Patch file available:
[WARNING] /Users/erwanletutour/IdeaProjects/openrewrite-demo/target/rewrite/rewrite.patch
[WARNING] Estimate time saved: 21m
Applying transformations: run
The run command is the culmination of the process. It actually applies the configured recipes and modifies the source code.
At this point, nothing is improvised:
- recipes have been identified,
- their impact evaluated,
- their execution is deliberate and accepted.
OpenRewrite traverses the AST, applies planned transformations, and regenerates the code coherently. Changes are local, explicit, and immediately visible in version control.
A migration executed with run is not a black box: every change can be reviewed, understood, and validated. The resulting commit tells a clear story—that of a controlled evolution.
Conclusion
Technical migrations have long been seen as unavoidable, costly, and anxiety-inducing passages. They were postponed as much as possible, hoping time would somehow help. In reality, it only made the step higher.
OpenRewrite doesn’t eliminate the need to evolve a project, but it profoundly changes how that evolution is approached. By turning migrations into explicit, versioned, and replayable recipes, it brings method where there was often only urgency.
The value isn’t just in successfully upgrading Spring Boot, Java, or JUnit — it lies in preparing the next ones. Migration code becomes a technical legacy, passed on with the project, just like architectural choices or naming conventions.
From painful to predictable migrations was originally published in Javarevisited on Medium, where people are continuing the conversation by highlighting and responding to this story.
This post first appeared on Read More

