🔴 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. Interface Notifier recompiled with send(String) changed from default to abstract; LegacyNotifier.class left stale.
Exception in thread "main" java.lang.AbstractMethodError: Receiver class com.devinhyderabad.pay.LegacyNotifier does not define or inherit an implementation of the resolved method 'abstract void send(java.lang.String)' of interface com.devinhyderabad.pay.Notifier.
at com.devinhyderabad.pay.ReportSender.main(ReportSender.java:4)⚡ Quick Fix Works 80% of the time
Clean-rebuild every module together so implementers and interfaces come from the same snapshot — mvn clean install at the aggregator root or ./gradlew clean build.
# Maven multi-module: build everything in one reactor pass
mvn clean install
# Gradle equivalent
./gradlew clean build🧠 Why this Happens
Tap to expand the deep technical explanation
While send(String) existed as a default method, LegacyNotifier inherited it silently — its .class file contains neither a body nor a reference to one. A newer Notifier demotes send to abstract. At the first invokeinterface on a LegacyNotifier instance the JVM walks the receiver class and every superclass hunting a concrete body; there is none anywhere, so dispatch dies with AbstractMethodError. The check runs lazily per call site rather than at load time, which is why the application boots happily and explodes the first time someone actually sends a report.
The HITEC City Parking Spot Analogy:
Your old printed coupon quietly included free gift-wrapping because store policy did. The new policy makes wrapping mandatory customer-provided — the coupon still scans, but checkout refuses the order.
🔁 How to Reproduce Confirm this is your error
Compile interface v1 with send(String) as a default method plus a caller that invokes it through an implementing class. Recompile only the interface with send declared abstract and put its output directory first on the classpath. The sanity run prints default:daily-report; the mixed run throws the captured trace. (Lab capture: OpenJDK 25.0.2.)
🛠️ Solutions (5 Ways to Fix)
Clean-rebuild all modules together from one source snapshot
👉 Use this first, always — most AbstractMethodErrors are plain stale-output problems from partial incremental compiles.
Incremental builds compare timestamps, not semantics. One reactor pass over the whole project guarantees implementers and interfaces were produced by the same compiler run against the same sources.
# Maven multi-module: build everything in one reactor pass
mvn clean install
# Gradle equivalent
./gradlew clean buildPurge stale jars from the deployment itself
👉 Use this when the rebuild was already clean but a server, war, or image keeps resurrecting old copies.
Old artifacts hide in WEB-INF/lib after hot redeploy, in Tomcat work directories, and inside Docker layers that COPYed libs before your fix. List every jar containing the interface package and delete every copy except the current one.
# find every jar shipping the changed interface
for j in $(find . -name "*.jar"); do
unzip -l "$j" | grep -q "com/devinhyderabad/pay/Notifier.class" && echo "$j"
done
# Tomcat: clear cached exploded webapp + work dir before redeploying
rm -rf $CATALINA_HOME/webapps/payapp $CATALINA_HOME/work/Catalina/localhost/payappLibrary authors: evolve interfaces forward-compatibly with default methods
👉 Use this when you own the interface that keeps breaking consumers across releases.
Adding a default method is binary-compatible: old implementers inherit it. Demoting a default to abstract, or adding an abstract method to a released major, guarantees some stale binary somewhere will blow up. Publish new requirements as defaults first and tighten later majors.
// SAFE evolution - old implementers keep working
public interface Notifier {
void ping();
/** @since 2.4 */
default void send(String msg) {
throw new UnsupportedOperationException("send not implemented");
}
}Gate releases with a binary-compatibility checker
👉 Use this if the library ships to other teams or to Maven Central and interface churn is routine.
Tools like Revapi, Clirr, or japicmp diff two releases at the bytecode level and fail the build on breaking changes such as default-to-abstract demotions, with the exact member named.
<plugin>
<groupId>org.revapi</groupId>
<artifactId>revapi-maven-plugin</artifactId>
<configuration>
<oldArtifacts><artifact>${project.groupId}:${project.artifactId}:1.3.0</artifact></oldArtifacts>
<newArtifacts><artifact>${project.groupId}:${project.artifactId}:${project.version}</artifact></newArtifacts>
</configuration>
</plugin>Align shaded or embedded copies of the interface package
👉 Use this if a fat jar bundles its own copy of a framework interface next to the framework itself.
Two copies of com.devinhyderabad.pay.Notifier mean one object graph talks to interface v1 and another to v2. Shade-plugin relocation gives each consumer its private renamed copy, or drop the embedded copy so exactly one survives.
<relocations>
<relocation>
<pattern>com.devinhyderabad.pay</pattern>
<shadedPattern>shaded.pay</shadedPattern>
</relocation>
</relocations>📋 Version Notes
Default methods arrived largely to avoid exactly this breakage when published interfaces grow.
Semantics unchanged; the module system does not police member resolution either.
Receiver-class message wording (introduced in 9) is now the standard form you see.
Unchanged.
🛡️ How to Prevent This Next Time
Build monorepos atomically in CI, publish interfaces under semantic versioning with a compatibility checker in the pipeline, and forbid manual jar swaps in server directories — deployments should always come from one built artifact.