Summary
The issue is a type resolution mismatch in JaVers. While a CustomValueComparator for Object.class is registered, JaVers internally uses generic type arguments (Map<String, Object>) to select comparators. It treats Object as a container type (specifically Map/Collection) rather than a value type, bypassing the custom comparator when comparing map values. This results in standard Object.equals() behavior instead of the numeric tolerance logic.
Root Cause
JaVers’ internal type registry has two conflicting behaviors for the Object type:
- Value Type Registration: When you call
registerValue(Object.class, ...), JaVers marksObjectas a value type globally. - Container Type Resolution: When scanning
Map<String, Object>, JaVers attempts to determine the concrete type of the map values. It sees the generic parameterObjectand interprets it as a container (map or collection) becauseObjectis the root of the Java type hierarchy and does not inherently carry value semantics.
Consequently, when JaVers reaches the map value 1.0f, the internal comparator logic ignores the registered Object value comparator and falls back to the default equality check (or a specific primitive check for Float), which does not apply your tolerance logic.
Why This Happens in Real Systems
This behavior stems from Java Generics Type Erasure and Type Hierarchy Ambiguity:
- Type Erasure: At runtime,
Map<String, Object>loses its generic signature, becoming a rawMap. JaVers relies on its internalTypemapping to reconstruct the generic context. - Ambiguity of
Object: TheObjectclass represents two distinct concepts:- Value: An opaque data blob (e.g., a string).
- Container: A structure holding other types (e.g., a list or map).
- Because JaVers must be defensive, when it encounters
Objectas a generic parameter in a collection context, it defaults to treating it as a container to avoid casting errors, skipping value-type customizations.
Real-World Impact
- Silent Logic Failure: The
Diffproduced is mathematically incorrect based on the business requirements (e.g.,1.0fis considered not equal to1L). - Data Consistency Issues: If this comparator is used for database state snapshotting or change tracking, JaVers will generate false positives for “changes,” leading to unnecessary audit logs or failed synchronization jobs.
- Hard to Debug: The comparator is registered successfully, but the execution path is never triggered. This requires digging into the JaVers source code (specifically
PrimitiveOrValueTypeandMapChangeAppender) to understand why thevalueComparatorfield is null.
Example or Code
The following code demonstrates the failure. Even though NumericComparator is registered for Object.class, the Diff will show a change because the internal comparison uses Float.equals(Long) (which returns false) rather than the custom numeric logic.
import java.util.HashMap;
import java.util.Map;
import javers.core.Javers;
import javers.core.JaversBuilder;
import javers.core.diff.Diff;
import javers.core.valuetype.CustomValueComparator;
public class JaVersMapObjectIssue {
public static void main(String[] args) {
// 1. Register the comparator for Object.class
Javers javers = JaversBuilder.javers()
.registerValue(Object.class, new NumericComparator())
.build();
// 2. Create Maps with different number types in the values
Map map1 = new HashMap();
map1.put("a", 1.0f); // Float
Map map2 = new HashMap();
map2.put("a", 1L); // Long
// 3. Compare
Diff diff = javers.compare(map1, map2);
// 4. Result
// Expected: No changes (because 1.0f == 1L numerically)
// Actual: Change detected (because Object.equals is not used,
// and specific number comparison bypasses custom comparator)
System.out.println("Changes found: " + diff.hasChanges());
System.out.println(diff);
}
}
How Senior Engineers Fix It
To fix this, you must bypass the generic Object resolution and force JaVers to treat the content as a specific value type.
- Use a Specific Interface (Recommended): Since the map is dynamic, you cannot type the generic signature easily. However, if you have control over the data, wrap the numeric value in a class that implements a marker interface (e.g.,
NumericValue). Register the comparator for that interface. - Global
ObjectComparator: If the map is truly dynamic (containing mixed types), you cannot rely onMap<String, Object>generic hints. You must ensure the comparator is applied globally for all value types that happen to beObject.- Fix: Register the comparator not just for
Object.class, but register it as the fallback for all primitives if possible, or explicitly register for all number types (Float.class,Long.class, etc.).
- Fix: Register the comparator not just for
- Wrapper Class Strategy: Create a generic wrapper
class JaversNumericWrapper<T extends Number>. Map your data to this wrapper. Register the comparator forJaversNumericWrapper.class. This removes theObjectambiguity entirely.
Senior Takeaway: Never rely on Object generic parameters in collection types for specific value logic. Concrete types (even if custom) are required for JaVers to select the correct ValueComparator.
Why Juniors Miss It
- “Object is a Class” Misconception: Juniors often think
Object.classacts as a catch-all wildcard for every object in the system. They fail to realize that in JaVers’ internal type graph,Objectis treated specially as a potential Collection or Map container. - Generic Signature Blindness: They check that the comparator is “registered,” but they don’t check how it is retrieved. They miss that retrieval depends on the generic type argument found during graph traversal, not just the raw class of the value instance.
- Expectation of Recursive Application: They expect the comparator to apply recursively to any
Objectfound anywhere. In reality, comparators are selected at the node level based on the declared type at that specific node in the object graph.
Key Takeaway: When dealing with Map<String, Object>, the Object type parameter is often interpreted as a Container Type, not a Value Type, causing custom comparators registered for Object.class to be ignored.