Viktar Patotski Viktar Patotski · · Java & Spring  · 5 min read

Spring Boot 3 Migration Guide: javax to jakarta with OpenRewrite (and the sed Trap)

Migrate Spring Boot 2 to 3: the javax to jakarta package rename. Why the one-line sed trick quietly breaks javax.sql and javax.crypto, which packages actually move, and how OpenRewrite does the whole upgrade safely. Tested on real apps.

Migrate Spring Boot 2 to 3: the javax to jakarta package rename. Why the one-line sed trick quietly breaks javax.sql and javax.crypto, which packages actually move, and how OpenRewrite does the whole upgrade safely. Tested on real apps.

Spring Boot 3.0 shipped on 24 November 2022. It moved the baseline to Java 17, unlocked native executables, and renamed every Jakarta EE package from javax.* to jakarta.*. That last one is the change you hit first, in every file that imports a servlet, an entity, or a validation annotation.

The rename looks like a find-and-replace job, so the most-shared advice on the internet is a one-line sed. It is also the advice most likely to corrupt your build. Here is the trick, why it is dangerous, and the recipe that does the whole upgrade correctly.

The one-liner everyone copies

find . -name '*.java' -exec sed -i 's/javax/jakarta/g' {} +

It finds every *.java file and rewrites javax to jakarta everywhere. For a toy project with one controller and one entity, it compiles and you move on. That is why it spreads.

Why that sed quietly breaks your app

Not every javax package moved. Only the Jakarta EE packages did. The javax.* namespaces that belong to the JDK stayed exactly where they were, and they are not going anywhere. A blanket s/javax/jakarta/g rewrites those too, and now your code imports packages that do not exist.

The JDK packages a real Spring app commonly touches, all of which stay javax:

  • javax.sql.DataSource (every connection pool, every @Bean that builds one)
  • javax.crypto.* (encryption, JWT signing, password hashing)
  • javax.net.ssl.* (TLS, custom SSLContext, mutual auth)
  • javax.naming.* (JNDI lookups)
  • javax.management.* (JMX, custom MBeans)
  • javax.security.auth.*
  • javax.xml.parsers, javax.xml.transform (JAXP, distinct from javax.xml.bind)
  • javax.imageio, javax.swing (less common server-side, still real)

Run the blanket sed on a service that wires a javax.sql.DataSource and you get jakarta.sql.DataSource, which is not a thing. Best case it fails to compile and you notice. Worst case it is buried in a config class behind a profile you do not build locally, and it blows up in the environment that does.

Which packages actually move

These are the Jakarta EE namespaces that the migration renames. If you ever do this by hand, this is the allow-list:

javax.* (Spring Boot 2)jakarta.* (Spring Boot 3)
javax.servletjakarta.servlet
javax.persistencejakarta.persistence
javax.validationjakarta.validation
javax.transactionjakarta.transaction
javax.annotation (see below)jakarta.annotation
javax.websocketjakarta.websocket
javax.mailjakarta.mail
javax.ejbjakarta.ejb
javax.jmsjakarta.jms
javax.enterprisejakarta.enterprise
javax.injectjakarta.inject
javax.interceptorjakarta.interceptor
javax.eljakarta.el
javax.ws.rsjakarta.ws.rs
javax.xml.bind (JAXB)jakarta.xml.bind
javax.xml.ws (JAX-WS)jakarta.xml.ws

javax.annotation is the trap inside the trap. The Common Annotations (@PostConstruct, @PreDestroy, @Resource, @Generated) moved to jakarta.annotation. But javax.annotation.processing.*, the annotation-processing API, is JDK and stays javax. A regex cannot tell the two apart from the prefix alone. A tool that understands types can.

If you insist on sed for a small codebase, at least scope it to the moved packages instead of the whole namespace:

find . -name '*.java' -exec sed -i -E \
  's/\bjavax\.(servlet|persistence|validation|transaction|websocket|mail|ejb|jms|enterprise|inject|interceptor|el|ws\.rs|xml\.bind|xml\.ws)\b/jakarta.\1/g' {} +

This leaves javax.sql and friends alone. It still does not handle the javax.annotation split, and it does nothing about the rest of the 2-to-3 upgrade: property renames, deprecated WebSecurityConfigurerAdapter, the spring.factories move, dependency versions. The package rename is the easy 20% that a regex can fake. The other 80% is where the upgrade actually lives.

The safe path: OpenRewrite

OpenRewrite parses your code into a typed syntax tree, so it changes javax.servlet to jakarta.servlet while leaving javax.sql.DataSource untouched, because it knows one is a servlet type and the other is JDBC. The Spring Boot 3 recipe does the whole upgrade, not only imports:

  • the javax to jakarta rename, correctly scoped
  • bumps the parent and starters to the target Spring Boot version
  • migrates renamed configuration properties in application.properties / .yml
  • replaces deprecated APIs (for example WebSecurityConfigurerAdapter, RestTemplateBuilder methods)
  • updates common third-party deps (Hibernate, Thymeleaf, MyBatis) to their Jakarta-ready versions

Recipe id for the latest line: org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5. There is one per minor (UpgradeSpringBoot_3_0 through UpgradeSpringBoot_3_5). Run the one that matches the version you are targeting.

Run it from your build tool. No permanent build-file change is needed for Maven; Gradle wants a small plugin block.

Run it as a one-off, nothing added to the pom. Swap the run goal for dryRun to preview a patch without touching a file:

mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:LATEST \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5

Add the plugin and recipe to build.gradle:

plugins {
    id 'org.openrewrite.rewrite' version 'latest.release'
}

rewrite {
    activeRecipe('org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5')
}

dependencies {
    rewrite('org.openrewrite.recipe:rewrite-spring:latest.release')
}

Then preview, then apply:

./gradlew rewriteDryRun   # preview the diff, change nothing
./gradlew rewriteRun      # apply it

Always run the dry run first. It writes a patch you can read before a single file changes. Commit a clean tree, apply the recipe, then read the diff like a pull request: the recipe is good, not magic, and a human should still review what it touched.

The gotchas no tool fully removes

After the rename compiles, these are the things that still bite, in rough order of how often I have seen them:

  • Spring Security. WebSecurityConfigurerAdapter is gone in Boot 3. You move to a SecurityFilterChain bean. OpenRewrite migrates the common shape, but custom security config usually needs a human pass.
  • Third-party libraries without a Jakarta release. If a dependency still ships only javax bytecode, renaming your imports does not help. You need a newer version of that library or a replacement. This is the most common reason a migration stalls.
  • Renamed config properties. Plenty of spring.* keys changed names in 3.x. The recipe migrates the known ones, but app-specific or starter-specific keys can slip through and fail silently at runtime, not at compile time.
  • Generated code. Anything produced by an annotation processor or a code generator (MapStruct, QueryDSL, JAXB-generated classes) is regenerated from its source, not edited. Rerun the generator after the upgrade.

Which to use

For a throwaway sample or a single-file experiment, the scoped sed is fine and fast. For anything you ship, run OpenRewrite, read the dry-run diff, then handle the security and property gotchas by hand. The rename is one minute either way. Doing the rest of the upgrade right is the work.


The package rename is the easy part. If you are moving to Spring Boot 3 to unlock native binaries, virtual threads, or to push off a tech-debt cliff, I help teams plan the broader migration without a full rewrite. Free 30-min scan.

Back to Blog

Related Posts

View All Posts »
Performance Viktar Patotski Viktar Patotski · 9 min read

Spring Boot on the JVM vs GraalVM Native: What Actually Wins on AWS

A head-to-head benchmark of the same Spring Boot app built for the JVM and as a GraalVM native binary - on real AWS hardware with a real database, run multiple times. Native wins startup, memory, and predictability; the warm JVM wins the median, peak throughput, and often the tail too - but the JVM swings run-to-run while native stays flat.