Dependency upgrades are where old sins surface. As part of our Java 17 migration we were bringing every library to its latest release — pinned old versions are exactly where upgrade pain likes to hide — and one library sat squarely in the blocking path: JaVers, which we use for object auditing and diffs.
The problem was issue #1370, open since 2024: comparing collections of entities sometimes produced an invalid diff. New entities would simply fail to produce a NewObject change. No exception, no warning — the audit trail just quietly missed things. For a library whose whole job is telling you what changed, that's the worst failure mode there is.
When our own deployment hit it — flagged not by a user report but by a rigorous unit test exercising our domain’s edge cases — I went into the library source. The cause turned out to be a beautiful little footgun.
Two strings walk into a bucket
JaVers computes the left/right difference of object graphs in GraphPair, using a set-difference helper driven by a hasher: objectNode -> objectNode.cdoHashCode() — an Integer. And Java string hashes collide cheerfully. The canonical example — the one from the original bug report — is "Ea" and "FB": both hash to 2236.
With collisions in play, two failure modes compound. Distinct global IDs overwrite each other in hash-keyed structures, and the removal path had no equality check — so an old object with a colliding hash made a genuinely new object look "already present." Delete-by-hash, no verify. The new entity disappears from the diff.
The fix: stop hashing away the identity
The change I proposed, and eventually shipped in PR #1454, is almost anticlimactic: hash by the thing that is the identity rather than a lossy projection of it —
Function<ObjectNode, String> hasher = objectNode -> objectNode.getGlobalId().value();
— and check string equality on removal. Two global IDs can share an Integer hash; they cannot share their own value. The javers-core test suite passed, the maintainer asked me to open a PR so CI could run, it merged the next day, and the fix went out in JaVers 7.8.6. Our Java 17 upgrade rolled forward on clean upstream binaries — and javers.org now lists me among the project’s contributors, which I won’t pretend wasn’t a good day.
Why upstream, not a fork
We could have pinned an old version or carried a patched fork. Both are loans against the future: pins are where the next upgrade's pain accumulates, and private forks rot quietly until they're their own migration project. Fixing it upstream meant production runs on binaries anyone can reproduce, the fix gets maintained by the project, and every other team that hits #1370 gets it for free.
What I took from it
- Audit libraries must fail loudly. A diff engine that misses changes silently is worse than one that throws. When you evaluate a dependency, ask what its worst silent failure looks like.
- Unit-test your dependencies against your domain's failure modes. A library's guarantees are assumptions until your own suite exercises them on the inputs your domain actually produces. We were lucky to have a test rigorous enough to catch a silent miss — but that kind of luck is manufactured in advance, one paranoid test at a time.
- Hash is a hint, not an identity. Any structure keyed on
hashCode()without an equality check is a collision away from losing data. The JDK's own collections get this right; helpers that shortcut it eventually pay. - The distance to "merged" is shorter than it looks. Read the source, reproduce the bug, bring a fix and passing tests — maintainers meet you more than halfway. This was my first open-source contribution; the gap between "we hit a bug" and "the fix is in the release" was four days.