Each change below compiles, passes the library's own tests and looks harmless in a code review. Each one breaks code that depends on the library, either when it is recompiled or, worse, when it runs against the new jar without being recompiled. We checked every example on Java 25.
Two ways to break a caller
A Java library can break its users in two different ways, and a change can break one without the other.
Source compatibility: does the caller's code still compile against the new version?
Binary compatibility: does code that was compiled against the old version still run with the new jar?
Binary compatibility matters more than it seems. When an application updates your library, it rarely recompiles the other libraries that use it. Those were compiled once, against your old version, and they keep calling it exactly as they did then. The Java Language Specification describes the rules in chapter 13, Binary Compatibility.
In each example, version 1.0 is the release that callers use and 1.1 is the change. The error messages are the real ones.
1. A wider parameter type
// 1.0 public static int twice(int x) // 1.1: accepts more values, so it looks compatible public static long twice(long x) Recompiled callers: compile. An int argument widens to long automatically.
Old binaries: fail. The JVM looks up a method by its exact descriptor, parameter and return types included:
java.lang.NoSuchMethodError: 'int Lib.twice(int)' Instead: add the long version as an overload and keep the int one, delegating to the new method if you like.
2. A more general return type
// 1.0 public static List<String> names() // 1.1 public static Collection<String> names() Recompiled callers: compile if they only use what Collection has, such as size() or a for loop, and fail if they assign the result to a List or call get(i).
Old binaries: fail, even the ones that only call size(). The return type is part of the descriptor:
java.lang.NoSuchMethodError: 'java.util.List Lib.names()' Instead: keep the return type. If the new type is the right one, add a new method under a new name and deprecate the old one.
3. A new method in an interface
// 1.0 public interface Lib { String name(); } // 1.1, and the library now calls id() public interface Lib { String name(); String id(); } Recompiled callers: every class that implements the interface stops compiling until it implements id().
Old binaries: load without any error. The failure comes later, at the first call to the missing method, which can be deep inside your library and long after startup:
java.lang.AbstractMethodError: Receiver class Client does not define or inherit an implementation of the resolved method 'abstract java.lang.String id()' of interface Lib. Instead: give the new method a default implementation, for example default String id() { return name(); }. Old implementations then keep working, compiled or not. Interfaces that callers only use and never implement can grow freely; say so in the documentation, or mark them with an annotation such as @DoNotImplement.
4. Making a class final
// 1.0 public class Lib { ... } // 1.1: "nobody should extend this" public final class Lib { ... } Recompiled callers: subclasses stop compiling.
Old binaries: a subclass compiled against 1.0 cannot even be loaded:
java.lang.IncompatibleClassChangeError: class Client cannot inherit from final class Lib Adding final to a public or protected method breaks the classes that override it in the same way. Removing final is always safe.
Instead: decide before the first release. If the class is already open, deprecate extension first and make it final in the next major version.
5. A static method becomes an instance method
// 1.0 public static String version() // 1.1 public String version() Recompiled callers: every Lib.version() call stops compiling.
Old binaries: fail at the call. The opposite change, from instance to static, fails the same way:
java.lang.IncompatibleClassChangeError: Expected static method 'java.lang.String Lib.version()' Instead: add the instance method with a new name and keep the static one.
6. A new checked exception
java.lang.NoSuchMethodError: 'int Lib.twice(int)'
0
Recompiled callers: fail, unless they already catch or declare the exception:
java.lang.NoSuchMethodError: 'int Lib.twice(int)'
1
Old binaries: run. The JVM does not check throws clauses, so old callers get the exception at runtime without any code to handle it.
This is the reverse of the first two changes: old jars keep working, and the break appears the next time a user upgrades and recompiles. It is still a breaking change, and it belongs in a major version.
Instead: throw an unchecked exception, or add a new method that declares the checked one.
7. A new value for a constant
java.lang.NoSuchMethodError: 'int Lib.twice(int)'
2
Nothing fails, and that is the problem. A static final field of a primitive type or String, set to a constant expression, is copied into the class files of the code that uses it when that code is compiled. Callers compiled against 1.0 keep using 10 with the 1.1 jar, until they are recompiled. We printed the field from a client compiled against 1.0 and run with the 1.1 jar: it printed 10.
So two parts of one application can disagree about the value, depending on when each part was compiled.
Instead: if a value can change, don't make it a compile-time constant. Expose it through a method, or initialise the field in a way that is not a constant expression.
The seven at a glance
| Change in a minor release | Recompiled callers | Old binaries |
|---|---|---|
| Wider parameter type | Compile | NoSuchMethodError |
| More general return type | Some fail | NoSuchMethodError |
| New abstract method in an interface | Implementations fail | AbstractMethodError at the first call |
| Class or method made final | Subclasses fail | IncompatibleClassChangeError when loaded |
| Static to instance, or back | Fail | IncompatibleClassChangeError |
| New checked exception | Fail | Run, with no handler for it |
| New constant value | Get the new value | Keep the old value |
None of them fails the library's own build, and most of them get past at least one of the two checks. That is why they reach a release.
Why tests don't catch them
The library's tests are compiled together with the new code, so they always match it. They test the library as it is now. What they cannot see is the code that was written, or compiled, against the version you released before.
These are not rare cases either. A study of 119,879 library upgrades on Maven Central found a breaking change in 1 in 5 minor and patch upgrades (Ochoa et al., 2022). The numbers and their sources are in our report.
How to catch them
Compare your public API with the last release, not with your tests. Tools that do this for Java:
japicmp: compares two jars and reports binary and source incompatibilities. It has a Maven plugin, and there are Gradle plugins for it.
Revapi: API analysis with configurable rules, for Maven and Gradle.
binary-compatibility-validator: for Kotlin libraries, built on a committed API dump.
Bumpwise: our IntelliJ IDEA plugin. It compares the code in the editor with the last published release, highlights each breaking change while you type, offers the compatible alternative (such as keeping the old signature as
@Deprecated) and calculates the next version.
The first three run in the build, usually in CI. Bumpwise runs before the change is committed. They work well together: one check while you write, one before you release.
Disclosure: Gilware makes Bumpwise, a paid plugin with a free edition for Java libraries published on Maven Central.
How we checked
For each change we compiled the 1.0 library, compiled a small client against it, compiled the 1.1 library, then ran the old client with the new jar and recompiled the client against 1.1. OpenJDK 25.0.5. The error messages above are copied from those runs.
Found a mistake? Write to hello@gilware.com.
Originally published at gilware.com.
