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.
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 recipeUpgradeSpringBoot_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 ajakarta.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 overrodeconfigure; 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.
- Full diff: 02-step1-diff.txt
- Legacy application: pom.xml
- UpgradeSpringBoot_3_5 recipe reference
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 becamespring-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 (Then the build:spring.redis.*tospring.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.
# 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.
- Full diff: 04-step2-diff.txt
- Related on this site: Spring Boot 4.2 preview: what to test now
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 behind | Why it matters | What to do |
|---|---|---|
"javax.persistence.lock.timeout" string in a query hint | The 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 present | The 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 unchanged | It 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.8 | There 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 adryRun 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 namedfailOnDryRunResults, 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
- Companion code: spring-boot-demo/openrewrite
- OpenRewrite documentation
- JavaxMigrationToJakarta recipe
- Related on this site: Jakarta EE 11 for Spring developers; Hibernate 8 preview
No Comments yet!