Java Object Contracts
Purpose
Make the methods every collection, framework and debugger silently calls behave the way
those callers assume. The failure modes are quiet by nature: an object that cannot be found
in the HashSet it was just added to; a TreeSet that drops a value it considers equal to
one already present; a sort that throws only on some inputs; a log line carrying a password
because a record generated toString for every component.
Workflow
Inspect compiler release/toolchains, target JVM, collection usage, published consumers and
ORM/provider configuration first. No single authoring baseline is declared; references use
Java SE 25, while records and pattern instanceof need Java 16+, List.copyOf Java 10+.
Adapt to the project without upgrades or preview. Missing lifecycle/provider evidence makes
equality recommendations conditional; state what must be tested before changing the contract.
- Decide the intended identity. Service objects often need reference equality. Value types need component equality; entities may need row/business identity across contexts. A mutable type can still use an immutable identity key; define lifecycle and collection membership before choosing, rather than excluding all mutable objects or entities.
- If it has value semantics, define the value. List the fields that constitute it. Exclude incidental state (load timestamps, caches, lazy proxies). Derived hash inputs are valid only if equal objects are guaranteed to derive the same value.
- Write
equalsandhashCodetogether, from the same field list, or let a record write both. A valid inherited hash implementation can suffice; overriding hash alone while retaining reference equality is not inherently a contract violation. - Check the inheritance question explicitly. Either the class is
final, orequalsis defined so subclasses cannot break symmetry.references/equals-and-hashcode.mdhas the two defensible answers and the one that is a trap. - If the type will be sorted or put in a sorted collection, implement
Comparablefor the natural order or supply aComparatorthat satisfies the total-order contract. Add a unique deterministic tiebreaker when distinct elements must coexist in a sorted set, or when pagination/canonicalization must reproduce one strict sequence. - Write
toStringfor the person reading the incident, then check what it discloses. - Verify by contract, not by example. Reflexivity, symmetry, transitivity and the hash obligation are properties: assert them over generated pairs, not over one hand-picked pair.
Rules
- Override
hashCodewhenever you overrideequals. The obligation is one-directional and absolute: equal objects must produce equal hashes; unequal objects may collide. Violating it makes the object undiscoverable in every hash-based collection, including ones the code does not know it is in —HashSet,HashMap,ConcurrentHashMap,distinct()in a stream, set-based dirty tracking in an ORM. - Never include a mutable field in
equals/hashCodeif instances are used as keys. Changing equality/hash-relevant state after insertion can make lookup search a different bucket or change equality without reindexing the stored entry. Behavior is unspecified; iteration may still find it, so do not rely on either lookup failure or success. - Prefer a record when the type is its components. The generated
equalsandhashCodecover every component; the two edge cases to know are array components (compared by identity — useListinstead) and floating-point components (compared as byDouble.compare, soNaNequalsNaNand0.0does not equal-0.0). - Choose floating-point equality deliberately.
Double.compare/Float.compareprovide the wrapper/record equivalence needed by conventional collections (all NaNs equivalent, signed zeros distinct); primitive==has different semantics. Reject/canonicalize special values or use a tolerance in an algorithm—not inequals—when the domain requires another relation. And compare arrays withArrays.equals/Arrays.deepEquals, never==.Objects.equalshandles null on both sides for everything else. - Do not depend on any hash value crossing a process, a restart or a JVM version.
Object's — and therefore every enum's —hashCodeis identity-based without a cross-run guarantee;String's is specified and stable but is not a distribution function. Persisting, sharding, partitioning or deduplicating onhashCodeis a defect; use an explicit digest or key. See consistent-hashing and idempotency. - Entity equality is a lifecycle/provider decision. Reference equality may be sufficient inside
one persistence context. For detached/cross-context values, prefer an immutable real business
key or an application-assigned identifier available at construction. A generated id requires
special handling: two transient instances with null ids are never equal, equality becomes
id-based only after assignment, and hash membership must remain stable. Proxy-safe type checks
are provider-specific—plain
instanceof,getClass()and provider “effective class” helpers make different inheritance/loading trade-offs. Test transient, managed, detached and proxy/unproxied pairs with the actual provider; see orm-structural-mapping. equals,hashCodeandtoStringmust not trigger loading. Touching a lazy association inside them turns a debugger step, a log line or aSet.addinto a query, and outside a session into aLazyInitializationException.- Keep
compareToconsistent withequalsunless you can state why not. When it is not —BigDecimal("1.0")versusBigDecimal("1.00")is the canonical case — aTreeSetand aHashSetof the same elements have different sizes, and the sorted one is usually the surprise. - Never implement
compareToby subtraction (a.value - b.value). It overflows for large and negative operands and returns the wrong sign. UseInteger.compare,Long.compare,Double.compare, or build the comparator withComparator.comparingInt(...). - A comparator that violates its contract may corrupt ordered-collection semantics or be detected
by a sorting implementation. OpenJDK object sorts commonly use TimSort and can throw
IllegalArgumentException: Comparison method violates its general contract!for some input shapes; detection is not guaranteed. Treat the exception as evidence against the comparator, and test its algebraic properties over adversarial/generated triples. - Any order used for paging, for cross-service comparison, or for a reproducible export must be total: append a unique tiebreaker (the id) after every business sort key. Ties broken arbitrarily mean two pages can both contain, or both skip, the same row.
- Write
toStringfor diagnosis, and treat what it exposes as a disclosure decision. A record generates atoStringcontaining every component — including tokens, passwords, PII and card numbers — and that string reaches logs, exception messages and traces. Override it on any type carrying a secret; structured-logging covers what belongs in a log at all. - Nothing may parse
toString. If a textual form is part of the API, give it a named method and a documented grammar (toIso8601,format), and lettoStringstay free to change. - Avoid introducing
Cloneableinto new domain APIs. It is a marker interface around a protected, shallow-copy mechanism; final reference fields cannot be replaced by ordinary clone code and inheritance makes deep-copy semantics hard to state. Immutable objects can safely be shared or shallow-copied, soCloneableis not inherently incompatible with them—it is usually unnecessary. Prefer a copy constructor,copyOf, explicit deep-copy operation or withers. Arrays retain an idiomatic publicclone().
References
Deliver the identity/equality/order contract, affected collection or lifecycle evidence, compatibility impact and checks executed. Use pairs for symmetry/hash agreement and triples for transitivity/ordering, including null, special numeric values and proxy states where relevant. Finite tests expose defects; they do not prove the relation universally correct.
- equals and hashCode — read when writing or reviewing either method, when a class with subclasses needs value equality, when an entity or a proxied object needs identity, or when hash-based lookups behave inconsistently.
- Ordering and comparators — read when implementing
Comparable, building aComparator, putting objects in aTreeMap/TreeSet, diagnosing a TimSort contract violation, or defining a sort a second process or a paging query must reproduce. - toString and copying without Cloneable — read when
designing a diagnostic representation, when a generated
toStringmay disclose secrets, or when code needs a copy of an object andcloneis being considered.