Skip to main content

Resolving 'java.lang.ClassNotFoundException: javax.servlet.Servlet' in Spring Boot 3+ Upgrades

 

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.

  1. Old World (Spring Boot 2.x / Tomcat 9): The Servlet API resides in the javax.servlet package.
  2. New World (Spring Boot 3.x / Tomcat 10): The Servlet API resides in the jakarta.servlet package.

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.

  1. Class File Analysis: It scans every .class file inside the JARs.
  2. Constant Pool Modification: It locates references to javax/servlet/Servlet in the constant pool and rewrites them to jakarta/servlet/Servlet.
  3. Service Provider Interface (SPI) Updates: It checks META-INF/services for old service definitions and renames them.
  4. Resource Transformation: It can even scan XML descriptors (like web.xml fragments 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.