Skip to main content

Automating javax → jakarta and Boot 3 → 4 Upgrades with OpenRewrite

Every Spring team eventually faces the same afternoon: change javax. to jakarta. in four hundred files, rewrite the security configuration, rename a dozen properties, and then do it again for the next major version. It is dull, error-prone work, and it is exactly what a tool called OpenRewrite is for. OpenRewrite reads your source into a tree, applies recipes — small, named, repeatable edits — and writes the result back, formatting intact. You run it as a Maven plugin. This article runs it for real on a deliberately old application, shows every line it changed, and, more usefully, lists what it left for you. You need to know what Maven is and what a Spring Boot upgrade involves; nothing about OpenRewrite.
Versions and status. rewrite-maven-plugin 6.46.1, rewrite-spring 6.37.1, rewrite-migrate-java 3.42.1. Legacy app: Spring Boot 2.7.18 on JDK 17.0.20, Maven 3.9. Recipes moved it to Boot 3.5.16 and then 4.0.8; I bumped the last step to 4.1.1 by hand. One legacy application with one test is a demonstration, not a survey — your codebase will find things this one does not. Companion code: spring-boot-demo/openrewrite.

The application we are going to upgrade

A tool is only as convincing as its test subject, so the module contains a small but realistic Spring Boot 2.7.18 application: a JPA entity with validation annotations, a REST controller with a @PostConstruct seed, a servlet filter, a security configuration that extends WebSecurityConfigurerAdapter, a RestTemplate client, an old-style JUnit 4 test, an auto-configuration registered in spring.factories, old property names, and a Hibernate query-hint string that mentions javax.persistence. Each is something real legacy applications contain and something that changes on the way to Boot 3 or 4. The baseline first, so that any later failure is the upgrade’s and not the application’s:
# 01-baseline: legacy app, Spring Boot 2.7.18, JDK 17
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Captured in 01-baseline.txt.

The run, end to endBoot 2.7.18legacy app, JDK 17Boot 3.5.16recipe: 52 sBoot 4.0.8recipe: 32 sBoot 4.1.1hand-edited versionBuild and test ran after every arrow; all three stages passed the application’s one test.Left for humans: a javax string literal, a stale spring.factories entry, RestTemplate, the 4.1 bump.
The diagram summarises the run: two recipe stages that each took under a minute, one manual edit, and a build after every step.

Step one: Boot 2.7 to 3.5, including javax to jakarta

The recipes are looked up by name from two libraries: rewrite-spring for Spring, rewrite-migrate-java for the Java EE to Jakarta rename. The script that runs them is short:
mvn -B -q org.openrewrite.maven:rewrite-maven-plugin:$RW:run \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:$SPRING,org.openrewrite.recipe:rewrite-migrate-java:$MIGRATE \
  -Drewrite.activeRecipes="$3" -Drewrite.exportDatatables=false 2>&1

Source: rewrite.sh, lines 7–9.

The recipe UpgradeSpringBoot_3_5 is a chain: it applies the 3.0 recipe, then 3.1, and so on up to 3.5, and the 3.0 recipe is the one that brings in the javax to jakarta migration. It took 52 seconds. The build file changes first:
# 02-step1-diff: recipe org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5 on the legacy app (52 s)
diff -ru -x target legacy-app/pom.xml step1/pom.xml
--- legacy-app/pom.xml
+++ step1/pom.xml
@@ -4,7 +4,7 @@
   <parent>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-parent</artifactId>
-    <version>2.7.18</version>
+    <version>3.5.16</version>
     <relativePath/>
   </parent>
   <groupId>com.ankurm</groupId>
@@ -14,6 +14,10 @@
     <java.version>17</java.version>
   </properties>
   <dependencies>
+    <dependency>
+      <groupId>jakarta.servlet</groupId>
+      <artifactId>jakarta.servlet-api</artifactId>
+    </dependency>
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
@@ -21,7 +25,6 @@
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
     <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
-    <dependency><groupId>org.junit.vintage</groupId><artifactId>junit-vintage-engine</artifactId><scope>test</scope></dependency>
     <dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
   </dependencies>
   <build>

Captured in 02-step1-diff.txt.

The parent moves from 2.7.18 to 3.5.16. Two side effects are worth noticing: the JUnit 4 compatibility engine is removed (the test was rewritten to JUnit 5 in the same run), and a jakarta.servlet-api dependency appears without a version, which the Boot parent supplies. The import rename is exactly what you hoped for, on the entity and everywhere else:
diff -ru -x target legacy-app/src/main/java/com/ankurm/legacy/Customer.java step1/src/main/java/com/ankurm/legacy/Customer.java
--- legacy-app/src/main/java/com/ankurm/legacy/Customer.java
+++ step1/src/main/java/com/ankurm/legacy/Customer.java
@@ -1,10 +1,10 @@
 package com.ankurm.legacy;
 
-import javax.persistence.Entity;
-import javax.persistence.GeneratedValue;
-import javax.persistence.Id;
-import javax.validation.constraints.Email;
-import javax.validation.constraints.NotBlank;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
 
 @Entity
 public class Customer {

Captured in 02-step1-diff.txt.

The security class is where the recipe earns its keep. WebSecurityConfigurerAdapter no longer exists in Spring Security 6, so the class has to change shape, not just names:
diff -ru -x target legacy-app/src/main/java/com/ankurm/legacy/SecurityConfig.java step1/src/main/java/com/ankurm/legacy/SecurityConfig.java
--- legacy-app/src/main/java/com/ankurm/legacy/SecurityConfig.java
+++ step1/src/main/java/com/ankurm/legacy/SecurityConfig.java
@@ -1,17 +1,19 @@
 package com.ankurm.legacy;
 
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
-import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.web.SecurityFilterChain;
 
 @Configuration
 @EnableWebSecurity
-public class SecurityConfig extends WebSecurityConfigurerAdapter {
-	@Override
-	protected void configure(HttpSecurity http) throws Exception {
-		http.csrf().disable()
-			.authorizeRequests().antMatchers("/actuator/health").permitAll()
-			.anyRequest().permitAll();
+public class SecurityConfig {
+	@Bean
+	SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+		http.csrf(csrf -> csrf.disable())
+			.authorizeHttpRequests(requests -> requests.requestMatchers("/actuator/health").permitAll()
+				.anyRequest().permitAll());
+		return http.build();
 	}
 }

Captured in 02-step1-diff.txt.

The old class extended an adapter and overrode configure; the new one exposes a SecurityFilterChain bean, and the fluent calls became lambdas. The old antMatchers became requestMatchers. The JUnit 4 test became a JUnit 5 test in the same pass. Then the build, on JDK 17:
# 03-step1-build: after step 1, JDK 17
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Captured in 03-step1-build.txt.

Why the test matters. That single green test is the only evidence that the migrated application still starts, wires JPA and security, and answers a request. Without tests, “the recipe ran without errors” tells you nothing about behaviour.
Going deeper: every changed file, and what I did not check
The full diff is 173 lines and covers the pom, five Java files, the properties file and the test. Two more changes are in it: @Configuration became @AutoConfiguration on the auto-configuration class, and a META-INF/spring/…AutoConfiguration.imports file was created to replace the spring.factories registration (the old file is not deleted; see the leftovers below). The run happened once. Running the 3.5 recipe again on the finished application made no changes (the CI gate section shows that), but I did not compare repeated runs byte for byte.

Step two: Boot 3.5 to 4.0

Boot 4 is a bigger structural change than a version bump: starters were renamed and split, and test support moved into per-technology modules. The 4.0 recipe handles that in the build file, in 32 seconds:
# 04-step2-diff: recipe org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0 applied on top of step 1 (32 s)
diff -ru -x target step1/pom.xml step2/pom.xml
--- step1/pom.xml
+++ step2/pom.xml
@@ -4,7 +4,7 @@
   <parent>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-parent</artifactId>
-    <version>3.5.16</version>
+    <version>4.0.8</version>
     <relativePath/>
   </parent>
   <groupId>com.ankurm</groupId>
@@ -18,14 +18,23 @@
       <groupId>jakarta.servlet</groupId>
       <artifactId>jakarta.servlet-api</artifactId>
     </dependency>
-    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
+    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
+    <dependency>
+      <groupId>org.springframework.boot</groupId>
+      <artifactId>spring-boot-starter-restclient</artifactId>
+    </dependency>
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
     <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
-    <dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
+    <dependency>
+      <groupId>org.springframework.boot</groupId>
+      <artifactId>spring-boot-starter-webmvc-test</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security-test</artifactId><scope>test</scope></dependency>
   </dependencies>
   <build>
     <plugins>

Captured in 04-step2-diff.txt.

The web starter became spring-boot-starter-webmvc, a REST-client starter was added (the application uses RestTemplate, which I assume is why), and the test dependencies were split into webmvc-test and security-test. The last piece of the diff is a source change, because the package of @AutoConfigureMockMvc moved:
diff -ru -x target step1/src/test/java/com/ankurm/legacy/CustomerControllerTest.java step2/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
--- step1/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
+++ step2/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
@@ -6,7 +6,7 @@
 
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.test.web.servlet.MockMvc;
 

Captured in 04-step2-diff.txt.

The same recipe also renamed properties. This is the properties file diff:
diff -ru -x target step1/src/main/resources/application.properties step2/src/main/resources/application.properties
--- step1/src/main/resources/application.properties
+++ step2/src/main/resources/application.properties
@@ -1,6 +1,6 @@
-spring.redis.host=localhost
-spring.redis.port=6379
-management.metrics.export.prometheus.enabled=true
+spring.data.redis.host=localhost
+spring.data.redis.port=6379
+management.prometheus.metrics.export.enabled=true
 spring.jpa.hibernate.ddl-auto=create-drop
-server.max-http-header-size=16KB
+server.max-http-request-header-size=16KB
 spring.jpa.properties.jakarta.persistence.validation.mode=none

Captured in 04-step2-diff.txt.

Renamed properties. The property renames (spring.redis.* to spring.data.redis.* and the Prometheus and header-size keys) appear in the step-two diff in this run. I saw a different split between the two steps when I ran an earlier, smaller version of this file, so do not rely on which recipe renames a property — only that the chain does, and check the final file.
Then the build:
# 05-step2-build: after step 2, JDK 17
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Captured in 05-step2-build.txt.

It builds and passes. Notice that the application code was not touched in this step apart from one import: in this application, Boot 4 changed dependencies far more than source.
Going deeper: what the 4.0 recipe covers, and 4.1
The recipe named UpgradeSpringBoot_4_0 is the last upgrade recipe in rewrite-spring 6.37.1; there is a SpringBootProperties_4_1 recipe for property changes, but no UpgradeSpringBoot_4_1. That is why the next section bumps the version by hand. Boot 4.0 is also not the latest: the 4.0.8 the recipe chose is a maintenance release of the previous minor line, while 4.1.1 is the current general availability release.

What the recipes did not do

This is the part worth reading twice. After both recipes finished, a script searched the result for the things a careful reviewer would check:
# 06-what-it-left-behind (in /tmp/or-work/step2, after both recipes)
--- leftover 'javax.' string literals or keys in source and resources:
src/main/java/com/ankurm/legacy/CustomerRepository.java:12:	@QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000"))
--- META-INF files:
./spring.factories
./spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
--- explicit jakarta.servlet-api dependency added to the pom:
1
--- RestTemplate usages (recipe does not touch them):
src/main/java/com/ankurm/legacy/RemoteClient.java:4:import org.springframework.web.client.RestTemplate;
src/main/java/com/ankurm/legacy/RemoteClient.java:8:	private final RestTemplate rest = new RestTemplate();
--- Spring Boot version the recipe stopped at:
    <version>4.0.8</version>

Captured in 06-what-it-left-behind.txt.

Left behindWhy it mattersWhat to do
"javax.persistence.lock.timeout" string in a query hintThe recipe renames types and property keys, but not a string inside Java code. I did not test the runtime effect of the old name.Grep for javax. after every run
spring.factories still presentThe recipe created the new AutoConfiguration.imports file but left the old registration. Boot 3 no longer reads that key, so it is dead weight.Delete the entry by hand
RestTemplate unchangedIt still compiles and runs on 4.1.1 (next section), but is deprecated for removal in the 4.2 milestone (see the 4.2 preview post).Plan a move to RestClient
Recipe stopped at 4.0.8There is no 4.1 upgrade recipe in this version of the library.Bump the parent yourself
The rule of thumb. OpenRewrite is very good at mechanical, local, syntactic changes, and it stops where a decision begins. Treat its output as a first commit that you review, not as the finished migration.

One manual step to 4.1.1

Because no recipe covers 4.1, the script bumps the parent version with a one-line edit and repeats the build:
# 07-step3-boot-4.1.1: parent bumped by hand from the recipe's 4.0.x to 4.1.1, JDK 17
    <version>4.1.1</version>
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Captured in 07-step3-boot-4.1.1.txt.

The application starts and passes its test on 4.1.1. That is a smaller claim than it sounds — one test, no external services — but it is the honest measure of what was checked.

Running it in CI without surprises

You rarely want a CI job to apply changes; you want it to fail when a change is still pending. The plugin has a dryRun goal that computes the changes without writing them and can fail the build if any exist. It has a trap:
# 08-ci-gate (mvn rewrite:dryRun, exit code decides the CI job)
legacy app,   -Drewrite.failOnDryRunResults=true (the name you would guess): exit code 0
legacy app,   -DfailOnDryRunResults=true (the real user property):          exit code 1
migrated app, -DfailOnDryRunResults=true:                                   exit code 0

Captured in 08-ci-gate.txt.

The property is named failOnDryRunResults, without the rewrite. prefix that most of the plugin’s other properties carry. With the prefix, Maven accepts the flag and does nothing: exit code 0 on an application that still needs migrating, which is a green build that means nothing. The correct spelling fails the legacy application (exit 1) and passes the migrated one (exit 0).
A second silent trap. OpenRewrite skips files that git ignores. If your sources sit under a .gitignored path, the plugin reports success and changes nothing. This is what that looks like:
# 00-gotcha-gitignore: same recipe, same project, sources under a .gitignore'd directory
[INFO] Project [legacy-app] Parsing source files
[INFO] Applying recipes would make no changes. No patch file generated.

Captured in 00-gotcha-gitignore.txt.

I hit this myself while building the demo, which is why the script does its work outside the git checkout.
Going deeper: a pipeline shape that works
A reasonable arrangement: one scheduled job runs dryRun with the gate above to tell you a migration is pending; a human runs run on a branch, reviews the diff and the build, and merges. I have not run this on a hosted CI system; the exit codes above are from a local run.

Should you use it?

Yes — as a first pass, with tests behind it. On this application each recipe finished in under a minute and made the mechanical changes, including a security rewrite that is tedious by hand. They did not remove the need for review: a string literal, a stale registration file and a deprecated client all remained, and the last minor version was not covered at all. If you have little test coverage, add smoke tests before running the tool, not after.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.