🔴 The Error You're Seeing

Confirm this matches your console output. If it does, you're in the right place.

ERROR LOG// 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)

Solution 1✓ Most common cause

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();
Solution 2

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);
Solution 3

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 CLEAN
Solution 4

Bridge 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 host
Solution 5

Prove 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

Java 8

Message was terse: com.X.Api cannot be cast to com.X.Api — no loader info printed.

Java 11

Verbose form with module/loader parenthetical arrived in 9; standard since.

Java 17

Same wording; loaders render as 'app'/'platform' for built-ins.

Java 21

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.