
For three decades, the motto in the Java environment has been Write once, run everywhere. Nothing has changed in the content of this slogan; 30-year-old Java programs are still executable today. The way programs are written has changed dramatically over the last 30 years. The language has evolved, the frameworks used have changed, and the style of programming has changed completely. All these changes mean that an update from 20-year-old source code to modern Java has become very, very complex. Often so complex that, despite the obvious disadvantages, companies shy away from it for several years and are even prepared to accept a huge mountain of technical debt. Paying off this technical debt not only costs money in terms of maintenance, but also poses a risk to the economic success of a company. The greatest danger is not updating the software and foregoing the closing of security gaps and the performance gains from new versions.
In this article, I would like to encourage you and take away your fear of migration. I will show you the Migration Engineering tool, which makes it possible to bring 20-year-old source code into a technically new state. We will look at what migration engineering is and how we can use the tools of migration engineering to analyze the source code and then migrate it. Your projects will have specific requirements, and you will see how the existing recipes can be extended with the appropriate changes. Finally, we will look at what they can do to stay ahead of the migration wave, with always up-to-date source code and always up-to-date use of frameworks. Keep your developers happy and improve the productivity of your software development.
What is migration engineering, and do I need it?
The subject area of migration engineering deals with “How do I get from one version to another?”. If you compare this with conventional engineering disciplines such as electrical engineering, mechanical engineering, or my favorite subject, aerospace, then migration engineering is really just engineering. In other words, developing a product for the better. This improvement is usually associated with the replacement of certain components. If we take the comparison a little further, it becomes clear that the comparison is a little off. The big difference between an aircraft and our source code is that source code can be changed. An aircraft can only be modified to a limited extent. If we look at a migration of a test framework in our test code, then the complete source code file (see screenshot) that defines this test changes in the difference view.

A software engineer claims that this is still the same test. That’s like moving the engine of a jumbo from the center of the wing to the ends of the wing and claiming it’s the same jumbo. Software development is at this level of madness and complexity every day. We change integral, fundamental components and claim that it is exactly the same software as before. Mastering this complexity is what migration engineering is all about. Migration engineering only works with a high level of test coverage, because otherwise it cannot be guaranteed that the software will really behave in the same way after the migration as it did before.
I would like to briefly describe the work of a migration engineer based on my first weeks at Moderne Inc. A day started with a message in “Spring Boot 3.4 is available, let’s upgrade!”. Spring Boot version migrations are usually easy to follow through the published migration guide. But the migration guide consists of rough steps. Use the following new class and dependency. A migration engineer must first break this rough step down further until the atomic code changes are known, which a software developer performs manually.
- Add a dependency to the Maven POM
- Add a new import
- Use the new class instead of the old one
- Swap the order of the call parameters
- Delete the old import
- Delete the old dependency
Once these individual steps are in place, they are aggregated and put in the correct order until they result in the target migration. It can now be applied to all existing projects.
For us, this meant one pull request for each service that we operate. All in all, a lot of work due to the volume of medium-sized pull requests, which were all processed in parallel. And of course, there were queries and change requests. Individual services had to be migrated again. This meant that a migration of the migration had to take place. New recipes had to be created and applied to the existing changes. The components have now been updated, and I would like to give a rough estimate of the effort involved. I started at Moderne Inc. at the beginning of 2025 and spent roughly a week implementing the migration. I applied the migration for about 10 minutes and then worked with different people for about a week to transfer the merge requests into the source code. If this migration had been done manually by different teams, the result would have been divergent applications of the new components and a longer implementation time.
The question remains: does every project now need its own migration engineer who thinks about how the software components can be migrated from one version to the next? – I don’t think so, but I do think it is important that every software developer deals with the issue and identifies migration paths for their components. Is your team part of a platform team, and do decisions influence other teams, or is a library provided for further use? Then, it is definitely the team’s job to show not only what the changes are but also how they can be implemented in detail. This is the only way your teams can ensure that the styles and versions used are consistent. OpenRewrite provides the technology to formulate and apply changes in a scalable way.
OpenRewrite for migrating source code
Moderne Inc. is responsible for the further development and community of OpenRewrite. This collaboration has resulted in many fundamental building blocks that are combined to create value-adding migrations. These include migrations of major versions for Spring Boot, Quarkus, Hibernate, Maven, and Gradle, as well as small helpers such as applying best practices or fixing the most common issues of static code analysis.
All applicable recipes can be found in the Recipe Catalog in the OpenRewrite documentation. To find the right recipe for your use case, you can use the search at the top left (box shortcut: Ctrl/CMD+K). This search indexes not only the names, but also parts of the documentation of a migration to enable better-tailored results. The search in the Modern SaaS at https://app.moderne.io delivers even better results; this also delivers suitable results for imprecise queries. Alternatively, the migrations are also tagged according to their subject areas and grouped according to these tags. These groups enable an exploratory search, which is particularly helpful for the first applications.

A team could thus become aware of the migration to Java 21. The associated documentation page is generated automatically and has the same structure for all migrations, see also the figure above. The Fully Qualified Name is specified under the heading; this is unique and is required for further use. The tags, a link to the resources and the license information are also provided. There are three main types of licenses:
- Apache License 2.0, the core framework is Apache-licensed so that framework authors can offer migrations for their customers. Some do, such as Micronaut and Quarkus. In cases where the framework authors do not provide such migrations, there is a marketplace for third-party migrations, including those from Modern.
- Moderne Source Available License are recipes that are freely available but may not be provided as part of another service. In the past, large companies have taken advantage of OpenRewrite to make their commercial AI migration solutions more reliable. To encourage products such as Amazon Q, GitHub Copilot, and IBM Watson to cooperate, Moderne now publishes its recipes under the MSAL. This means that projects can continue to use the recipes to their full extent, but are prohibited from exploiting them commercially.
- Moderne Proprietary are recipes that may only be used with a license from Moderne. These are particularly value-adding recipes that are associated with a large investment by Moderne. These recipes can also be applied to open-source projects.
The various licenses serve to prohibit third parties from using the recipes in their commercial services and marketing them further without giving anything back to the project. A detailed and up-to-date list can be found in the Moderne documentation. At the time of publication, Moderne Inc. and the OpenRewrite community are still looking for a final solution. The goal is still to provide open-source projects with access to the modernization capabilities of OpenRewrite and the Moderne products.
After the legal notes, the documentation lists the migration steps to be carried out. Each step is also a migration with a documentation page of the same structure. A later paragraph deals with how such migrations can be created for your own needs and configured via YAML files. The documentation lists the ways in which this migration can be used. In addition to code adaptations, migrations also collect data for later analysis, the DataTables. Common information includes runtimes, changed files, or analysis information on the use of constructs. The migration should now be applied. After deciding on a migration, the Usage paragraph mentioned above can be used to study the application of a recipe. There are basically three ways to execute a migration.
- Maven, via the CLI or the Rewrite Maven plugin
- Gradle, rewrite plugin, or an init script
- Modern CLI, a standalone power user terminal tool
- Modern SaaS, on the OpenRewrite web application
A list of OpenRewrite recipes can be specified via the Maven CLI, which is executed with the Rewrite Maven plugin. This mode is primarily suitable for the use of one-off migrations such as framework updates; the same applies to the Gradle Init Script. Copying the examples from the documentation for this type of application is recommended. For our JUnit Migrate to Java 21 migration, the Maven CLI call is given in the listing below. The rewrite Maven plugin Goal run is called, and the migration UpgradeToJava21 is specified with rewrite.activeRecipes. As this is not integrated in OpenRewrite, the artifact coordinates for rewrite-migrate-java must be specified under rewrite.recipeArtifactCoordinates.
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:RELEASE \
-Drewrite.activeRecipes=org.openrewrite.java.migrate.UpgradeToJava21
Ende
In the case of the Migrate to Java 21 migration, it makes sense to run the recipe again from time to time. This is because, due to carelessness, a developer could use old Java APIs that need to be migrated again. It makes sense to use the recipe as an optional plugin in the build. To do this, the rewrite-mave-plugin is integrated as in the listing below, the UpgradeToJava21 recipe is activated, and the migration artifact is added as a dependency. With a simple mvn rewrite:run, the plugin is executed, performs the configured migration,s and applies the necessary changes to the code. With each run of the rewrite plugins, the plugin reports which migration has made a change in which file. It also creates a rough estimate of how much effort manual editing would have entailed. From here, all that remains to be done is to commit, push, and review.
<project>
<build>
<plugins>
<plugin>
<groupId>org.openrewrite.maven</groupId>
<artifactId>rewrite-maven-plugin</artifactId>
<version>6.3.1</version>
<configuration>
<exportDatatables>true</exportDatatables>
<activeRecipes>
<recipe>org.openrewrite.java.migrate.UpgradeToJava21</recipe>
</activeRecipes>
</configuration>
<dependencies>
<dependency>
<groupId>org.openrewrite.recipe</groupId>
<artifactId>rewrite-migrate-java</artifactId>
<version>3.4.0</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>
The last option is the Modern CLI as a powerful tool based on OpenRewrite. The Modern CLI can be used free of charge for projects in public repositories; licensing is required for closed-source projects. In contrast to the rewrite plugins, the Modern CLI can be used on several projects simultaneously and in parallel. Furthermore, the Modern CLI can persist and reuse the generated metadata. This means that the complete metadata is not generated with every run, and the runtime is drastically improved.
The Modern CLI is installed as a native application, executables exist for all common operating systems, and a pure JAR variant is also provided via Maven Central. After installation, the CLI will be available at the terminal. The metadata must be generated before a migration can be executed. The mod build . command is used for this, which searches for projects in the current directory and generates the metadata. In addition, the list of available migrations must be downloaded with mod sync. To execute a recipe, the mod run. command and the -recipe parameter to start the migration and execute it. After the migration, the changes can be applied or studied. Basically, the process is guided by the CLI in an understandable way. After each run, it reports which next steps would be useful. A step-by-step guide can be found in the documentation.
Each execution of a recipe provides data tables in addition to code adaptations. The datatables are the basis for all analysis options with the OpenRewrite technology. The Maven and Gradle integration produces data tables for a project. In contrast, the CLI and Modern SaaS provide data for several projects at the same time. In Moderne SaaS, there are some standard visualizations that allow migration engineers to analyze the use of different framework versions or the distribution of changes. The screenshot below shows the DevCenter, which contains information about the repositories for an exemplary organization. In addition, the achievement of strategic business goals such as Spring Boot 3 or Java 21 migrations is shown in a clear manner.

It is not uncommon for projects in companies to have special requirements for the use of certain patterns or internal libraries. In this case, the existing OpenRewrite recipes must be extended.
Custom OpenRewrite Recipes
In OpenRewrite technology, migrations are defined as recipes. A recipe instructs the OpenRewrite tool how to adapt the existing code to achieve a goal. Recipes can be defined in 3 different ways with increasing complexity and functionality:
- Declarative YML recipes
- Refaster templates in Java recipes
- Imperative Java recipes
The individual properties of the recipes are listed in the table below. The declarative recipes offer the easiest entry point; these follow a YAML schema and define the list of recipes to be executed with their configurations in addition to the necessary properties. These recipes are used to aggregate other recipes. Refaster recipes use refaster templates as defined by the Google Error Prone project. These templates are used for type-safe search and replace method calls. If more complex manipulations are required that cannot be mapped by the basic blocks, it is necessary to formulate an imperative recipe. The full scope of Java as a programming language can be used here, and many OpenRewrite utilities are available to implement the migration logic.
| Declarative Recipe Rezept | Refaster Recipe | Imperativ Recipe |
| YAML | Refaster Templates in Java | Pure Java |
| aggregate Recipes | Search & Replace | high fkexible |
| configure Recipes | Typsafe | Visitor-Pattern |
| simple | Limited capabilities | multiple Utilities |
It is advisable to start with a declarative recipe and try to map as much of the migration as possible by configuring existing recipes. However, before a recipe can be created, a corresponding project must be set up, and the acceptance tests must be defined.
Declarative recipes are used to configure existing recipes and execute them together. In the first step, the required recipe is identified in the Open Rewrite recipe catalog, and the fully qualified name is identified. The fully qualified name is specified directly under the heading of the documentation. If recipes from your own database are to be used, the qualifying name is also used in this case. The org.openrewrite.java.ChangeType recipe can be used to replace a class reference, for example. In the moderneinc/rewrite-recipe-starter GitHub project, this recipe is configured in resources/META-INF/rewrite/stringutils.yml, see also the listing below, in order to use the correct StringUtils class.
type: specs.openrewrite.org/v1beta/recipe
name: com.yourorg.UseApacheStringUtils
displayName: Use Apache `StringUtils`
description: Replace Spring string utilities with Apache string utilities.
recipeList:
- org.openrewrite.java.ChangeType:
oldFullyQualifiedTypeName: org.springframework.util.StringUtils
newFullyQualifiedTypeName: org.apache.commons.lang3.StringUtils
The type identifies it as a recipe for OpenRewrite migrations and can be addressed via the qualifying name after the name. The displayName is mainly used for the automatically created recipe catalog. The recipes to be executed are specified in the recipeList. The possible configurations are specified on the corresponding recipe pages. If the existing basic recipes in the recipe catalog are not sufficient, new recipes must be written. One possible example would be the simplification of a ternary operator to a constant expression. The use of Refaster templates from the Error Prone project is suitable here.
Error Prone Refaster can be used to make typical pattern-based changes. OpenRewrite supports the templates of Refaster and thus offers a suitable abstraction for the adaptation of method calls. A Refaster template recipe is marked as a recipe by the @RecipeDesciptor, given a name for the documentation and a description. The code passage to be replaced is specified in the @BeforeTemplate marked method. The expressions are used in the method in a type-safe manner. The method marked with @AfterTemplate defines the target state and uses type safety to create the new expression. Refaster templates can only make changes within a code block.
@RecipeDescriptor(
name = "Replace `booleanExpression ? true : false` with `booleanExpression`",
description = "Replace ternary expressions like `booleanExpression ? true : false` with `booleanExpression`.")
public static class SimplifyTernaryTrueFalse {
@BeforeTemplate
boolean before(boolean expr) {
return expr ? true : false;
}
@AfterTemplate
boolean after(boolean expr) {
return expr;
}
}
If further adjustments are required and these are not possible by combining existing recipes, an imperative recipe can be created. These imperative recipes extend the abstract class org.openrewrite.Recipe, see listing below.
public class TestRecipe extends org.openrewrite.Recipe {
@Override
public String getDisplayName() {
return "An example Recipe";
}
@Override
public String getDescription() {
return "This Recipe is an example.";
}
@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
return new JavaIsoVisitor<ExecutionContext>() {
@Override
public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration m, ExecutionContext ctx) {
J.MethodDeclaration m2 = super.visitMethodDeclaration(m, ctx);
return m2.getName().getSimpleName().equals("foo") ?
m2.withName(m2.getName().withName("bar")) :
m2;
}
};
}
}
The getDisplayName method returns the display name, and getDescription the description for the automatically generated documentation. The getVisitor method returns an instance of a TreeVisitor. A TreeVisitor traverses the Lossless Semantic Tree (LST) and manipulates individual nodes in this tree. The LST is the OpenRewrite representation of the complete source code within the project, spanning languages and technologies. The LST is created when Open Rewrite is started and is used after all recipes have been executed to convert the changes back into source code. There is a separate LST implementation and customized visitors for each supported language. To manipulate Java source code in the example, the org.openrewrite.java.JavaIsoVisitor is used. This contains a visit method for each type of element in the LST. These return the changed element. In this example, all method declarations are visited, and the J.MethodDeclaration, which has the name “foo”, is searched for in order to rename it to “bar”. If the currently considered method is to be retained, it is returned subverted; if null is returned, the method definition is deleted. Further complete implementations of recipes can be found in moderneinc/rewrite-recipe-starter. Among others in the class NoGuavaListsNewArrayList, this recipe migrates the use of Guava to JDK contained methods for handling ArrayLists. A detailed step-by-step workshop for the home office is available on Moderne.io and covers other important tools in addition to the techniques mentioned here.
Stay in front of the Migration Wave
In the coming months and years, we developers will continue to be bombarded by migrations. With OpenRewrite technology and the migration engineering approach described here, organizations can put themselves in a position to act in a planned manner again instead of just continuing to react. The OpenRewrite step-by-step guides on upgrading from Java 5 to Java 21 or migrating from Java EE to Jakarta EE 10.0 offer a good starting point here. By using these two guides, teams can learn how much time can be saved when migrating 20-year-old applications. The knowledge learned can then be applied to internal migrations and libraries to keep the organization fit for another 30 years of Java.
