🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
// CAPTURED — OpenJDK 25.0.2 (Temurin), macOS arm64. Api.class loaded independently by a parentless URLClassLoader and by the 'app' loader.
Exception in thread "main" java.lang.ClassCastException: class com.devinhyderabad.plugin.Api cannot be cast to class com.devinhyderabad.plugin.Api (com.devinhyderabad.plugin.Api is in unnamed module of loader java.net.URLClassLoader @1dbd16a6; com.devinhyderabad.plugin.Api is in unnamed module of loader 'app')
at com.devinhyderabad.plugin.Main.main(Main.java:8)⚡ Quick Fix Works 80% of the time
Let the shared API be defined exactly once by the parent loader and mark it provided in the plugin build so the plugin delegates instead of redefining.
// plugin classloader MUST delegate for shared types
URLClassLoader pluginLoader = new URLClassLoader(
urls,
getClass().getClassLoader()); // parent = app loader, NOT null
// and in the plugin pom - never bundle the shared api
<dependency>
<groupId>com.devinhyderabad</groupId>
<artifactId>plugin-api</artifactId>
<scope>provided</scope>
</dependency>🧠 Why this Happens
Tap to expand the deep technical explanation
Since Java 9 the message helpfully prints the defining module and loader for each operand because identity, not spelling, decides casts. The JVM compares java.lang.Class objects; Api@loader-A and Api@loader-B are distinct classes even with byte-for-byte equal definitions, so checkcast finds them incompatible. The capture created the split deliberately with a parentless URLClassLoader; in production the same split arrives via plugin sandboxes, servlet-context loaders bundling their own api jar, or fat-jar repackaging that hides the original.
The HITEC City Parking Spot Analogy:
Identical twins both named Alex work at rival companies with rival ID systems. A guest list that says "Alex" admits only badge-holder Alex from THAT company — the twin walks in looking exactly right and gets stopped cold.
🔁 How to Reproduce Confirm this is your error
Compile one tiny Api class. Load its .class twice — once on the application classpath and once through a URLClassLoader constructed with a null parent over a directory holding a second copy. Instantiate through the plugin loader and assign into the application-loaded type: the captured trace appears verbatim. (Lab capture: OpenJDK 25.0.2.)
🛠️ Solutions (5 Ways to Fix)
Define the shared API exactly once via the common parent loader
👉 Use this whenever plugins, wars, or modules exchange typed objects with the host.
Host exposes plugin-api on the base classpath; plugin builds mark it provided and their custom loaders take the host loader as parent. Delegation then yields a single defining loader, making the cast trivially succeed.
ClassLoader host = getClass().getClassLoader();
URLClassLoader plugin = new URLClassLoader(pluginUrls, host); // delegate!
Api api = (Api) plugin.loadClass("com.devinhyderabad.plugin.Api")
.getDeclaredConstructor().newInstance();Never null-parent a loader that must interoperate with host types
👉 Use this when reviewing plugin frameworks or sandboxing code for this failure mode.
A bootstrap-parented loader redefines EVERY reachable class privately — perfect isolation, guaranteed CCE on first hand-off. Reserve null parents for engines that marshal pure data (bytes, Strings) outward.
// WRONG for shared-type plugins
new URLClassLoader(urls, null);
// RIGHT
new URLClassLoader(urls, hostLoader);Purge duplicated api jars from war/BOOT-INF directories
👉 Use this in Spring Boot, war, or EAR deployments where the framework also ships the shared library.
If the container or launcher provides plugin-api, embedding another copy inside WEB-INF/lib or BOOT-INF/lib creates the second definition even with correct delegation order. Provided scope plus a duplicate-check step keeps archives clean.
mvn -q dependency:tree | grep plugin-api
# ensure scope=provided and absent from target/*.jar contents:
unzip -l target/app.jar | grep plugin-api || echo CLEANBridge isolated worlds with data, not objects
👉 Use this when strong isolation is a hard requirement and shared loaders are impossible.
Serialize to JSON/bytes at the boundary and rebuild on the far side using that side’s own classes. Slower, but immune to identity splits and lets each plugin upgrade independently.
byte[] payload = toJson(result); // inside plugin
Map<String,Object> back = fromJson(payload); // inside hostProve the split with identity checks before refactoring
👉 Use this during diagnosis to confirm two loaders truly define the name differently.
Printing getClass().getClassLoader() for both operands shows the two defining loaders directly; equals() on the Class objects returning false is definitive proof the packaging needs the fixes above.
System.out.println(a.getClass() == Api.class); // false = split confirmed
System.out.println(a.getClass().getClassLoader()); // URLClassLoader @...
System.out.println(Api.class.getClassLoader()); // 'app'📋 Version Notes
Message was terse: com.X.Api cannot be cast to com.X.Api — no loader info printed.
Verbose form with module/loader parenthetical arrived in 9; standard since.
Same wording; loaders render as 'app'/'platform' for built-ins.
Unchanged.
🛡️ How to Prevent This Next Time
Publish plugin/module contracts as separate API artifacts consumed with provided scope, forbid null-parent loaders except in pure-data engines, and add an integration test where a freshly loaded plugin instance crosses into host-typed code.