The Crash
You have successfully refactored your codebase to Spring Boot 3. You updated your imports, replaced javax.persistence with jakarta.persistence in your entities, and the build passes successfully.
However, upon startup or during a specific API call, the application crashes with the following stack trace:
java.lang.ClassNotFoundException: javax.servlet.Servlet
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:520)
...
This error halts migration efforts immediately. It indicates that while your application code is targeting the modern Jakarta EE APIs, a compiled dependency deep in your classpath is still attempting to load the deprecated Java EE classes.
The Root Cause: The Great Namespace Shift
Spring Boot 3 is built on Spring Framework 6, which mandates the Jakarta EE 9/10 APIs. This is not a standard version increment; it is a breaking namespace change resulting from the transfer of Java EE from Oracle to the Eclipse Foundation.
- Old World (Spring Boot 2.x / Tomcat 9): The Servlet API resides in the
javax.servletpackage. - New World (Spring Boot 3.x / Tomcat 10): The Servlet API resides in the
jakarta.servletpackage.
Spring Boot 3 embeds Tomcat 10 (or Jetty 11/Undertow 2.3), which provides the servlet container. These containers strictly load jakarta.servlet.* classes.
The ClassNotFoundException occurs because an external library (dependency) in your project contains compiled bytecode making calls to javax.servlet.*. Since the runtime container (Tomcat 10) no longer provides the javax classes, the classloader fails.
Adding the old javax.servlet-api dependency manually to your pom.xml is not a valid solution. It creates a "split brain" scenario where your code speaks Jakarta and the library speaks Javax, preventing the library from interacting with the actual HTTP request/response objects managed by the container.
Diagnosis
Before applying the fix, identify the offending library. The stack trace usually points to the specific class triggering the load. If not, run the dependency tree command to find libraries pulling in old API specs:
mvn dependency:tree -Dincludes=javax.servlet:javax.servlet-api
Common culprits include:
- Older versions of Swagger/SpringDoc (OpenAPI).
- Legacy PDF or Excel generation libraries.
- Internal corporate libraries that haven't been migrated.
- Old HTTP client wrappers.
The Fix: Bytecode Transformation
If you cannot upgrade the offending library (e.g., it is abandonware or an internal artifact pending update), you must transform the bytecode of the JAR at build time to be compatible with Jakarta EE.
We use the Eclipse Transformer Maven Plugin. This tool hooks into your build lifecycle, unpacks the target dependency, renames the packages in the bytecode (from javax. to jakarta.), and repackages it.
Step 1: Configure the Plugin
Add the following configuration to your pom.xml in the <build><plugins> section. This example assumes you have a legacy library named legacy-reporting-lib causing the crash.
<plugin>
<groupId>org.eclipse.transformer</groupId>
<artifactId>transformer-maven-plugin</artifactId>
<version>1.0.0</version>
<extensions>true</extensions>
<configuration>
<rules>
<jakartaDefaults>true</jakartaDefaults>
</rules>
</configuration>
<executions>
<execution>
<id>transform-legacy-jar</id>
<goals>
<goal>jar</goal>
</goals>
<phase>package</phase>
<configuration>
<artifact>
<groupId>com.example.legacy</groupId>
<artifactId>legacy-reporting-lib</artifactId>
<version>2.5.0</version>
</artifact>
<!--
This creates a new classifier for the transformed artifact
to avoid overwriting the original in the local repo.
-->
<classifier>jakarta</classifier>
</configuration>
</execution>
</executions>
</plugin>
Step 2: Override Dependency Resolution
Since the plugin generates a transformed JAR with a specific classifier (e.g., jakarta), you must ensure your application uses that JAR instead of the original broken one during the final packaging.
However, a cleaner approach for runtime (specifically if using spring-boot-maven-plugin to build a fat jar) is to transform the dependency before the final artifact is assembled, or use the Javax-to-Jakarta Migration Tool on the resulting Fat JAR.
Preferred Approach: Transform the Uber-Jar If you have multiple dependencies causing issues, it is often more efficient to run the transformer on your final Spring Boot executable JAR.
Update your pom.xml to chain the transformation after the spring-boot-maven-plugin repackages the app.
<build>
<plugins>
<!-- Standard Spring Boot Plugin -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Eclipse Transformer -->
<plugin>
<groupId>org.eclipse.transformer</groupId>
<artifactId>transformer-maven-plugin</artifactId>
<version>1.0.0</version>
<executions>
<execution>
<id>jakarta-ee-migration</id>
<goals>
<goal>jar</goal>
</goals>
<phase>package</phase>
<configuration>
<!-- Input: The JAR generated by Spring Boot -->
<artifact>
<groupId>${project.groupId}</groupId>
<artifactId>${project.artifactId}</artifactId>
<version>${project.version}</version>
</artifact>
<!-- Output: Overwrite or create a new classifier -->
<baseName>${project.build.finalName}-jakarta</baseName>
<rules>
<jakartaDefaults>true</jakartaDefaults>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
Step 3: Deployment
When deploying, ensure you use the transformed artifact:
java -jar target/my-app-0.0.1-SNAPSHOT-jakarta.jar
Explanation: How It Works
The Eclipse Transformer performs generic bytecode manipulation. When configured with <jakartaDefaults>true</jakartaDefaults>, it applies a pre-defined set of rules mapping Java EE packages to Jakarta EE packages.
- Class File Analysis: It scans every
.classfile inside the JARs. - Constant Pool Modification: It locates references to
javax/servlet/Servletin the constant pool and rewrites them tojakarta/servlet/Servlet. - Service Provider Interface (SPI) Updates: It checks
META-INF/servicesfor old service definitions and renames them. - Resource Transformation: It can even scan XML descriptors (like
web.xmlfragments inside JARs) to update namespaces.
By running this on the final Spring Boot Uber-JAR, you blanket-patch all transitive dependencies included in BOOT-INF/lib. This resolves the ClassNotFoundException because the legacy library's bytecode now effectively requests jakarta.servlet.Servlet, which the Tomcat 10 classloader can happily provide.
Conclusion
The transition to Spring Boot 3 is mandatory for long-term support, but the Jakarta EE namespace fracture creates significant friction for legacy dependencies. While the ideal path is upgrading libraries to their Jakarta-compatible versions (e.g., Hibernate 6, Jersey 3), the Eclipse Transformer provides a robust, engineered fallback for dependencies that are stuck in the past. This allows you to modernize your platform infrastructure without rewriting third-party libraries.