Syllabus · A4
Entity vs value; identity and the equals/hashCode contract
The idea
Same person, different balance, and Java has to be told they are the same
Ada owes nothing today and five hundred tomorrow, and she is still Ada. Her fine changed; who she is did not. corpus/library/reference/src/Member.java models exactly that member, and its decision log states the choice directly: "Her identity is memberId and nothing else, so equals compares only that."
Member writes that decision out as two methods, each one line long:
public boolean equals(Object other) {
return other instanceof Member member && memberId.equals(member.memberId);
}
public int hashCode() {
return memberId.hashCode();
}
Notice what is missing from both. name, borrowLimit, and finesOwedMinor never appear, on purpose, even though Member is otherwise an ordinary immutable class with final fields and no setters. Paying a fine does not mutate Ada in place. It produces a new Member value with the same memberId, and the change reaches storage through MemberRepository.save(Member). Immutability and identity are answering two different questions here, and Member needs both at once. Identity means the library always means the same person by "Ada." Immutability means a fine cannot be applied twice by two threads racing on the same object.
See what happens when a type does not make that choice on purpose. Put Ada in a HashSet, then hand the set a fresh snapshot of her that carries the new fine, the same person with one updated field. If Member were a record instead of a type with a deliberate equals, its generated equals would compare every field rather than only the id. Both roll.contains(finedAda) and roll.remove(finedAda) would then print false, because a record built from the changed fine is not equal, by value, to the record built before it. Ada owing money has become a stranger to her own record, as far as the set is concerned. Nothing throws, and nothing warns you. The next add quietly gives the roll two Adas instead of one.
The reason this happens is that value equality and identity equality answer two different questions, and Java spells both of them equals. A value asks whether two things hold the same information right now. An entity asks whether they are the same real-world thing, regardless of what has changed about it since. So the question to ask about any type is direct: if every field changed except one, would you still call this the same thing? If the answer is yes, that surviving field is the whole of equals, and nothing else belongs in it. hashCode then has to be built from exactly that same field and nothing else, because the two methods are a pair the compiler will never check for you. Nothing stops you from writing an equals that disagrees with your hashCode, and a hash-based collection will not tell you that you did.
If no field survives every change you can imagine for the type, you have not found an entity at all. You have found a value, and A2 already covers what to do with one of those.
The gap is simple to state and hard to notice in review, because a unit test rarely exposes it on its own. assertEquals(ada, ada.owing(500)) can pass for months while a HashMap still cannot find her afterward, because a plain assertion never calls hashCode the way a hash-based collection does when it looks something up. The distance between "the assertion passed" and "the collection actually works" is the entire risk this lesson is about.
Worked walkthrough
NOTES — one member, three equals implementations, and the contract that ties them together
Run it first
.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
.toolchain\jdk-21\bin\java.exe -cp out Main
javac prints nothing — every file here compiles clean, including the two wrong designs. That is the point: nothing below is a straw man that fails to build. Real output, verbatim:
--- 1. Member as a record: every field decides equality
roll before fine : 1
roll.contains(fined Ada) : false
roll.remove(fined Ada) : false
roll after fine + re-add : 2 (two Adas)
--- 2. Member as a mutable class, equals over every field
roll.contains(bob) before mutation : true
roll.contains(bob) after mutation : false
same reference still in the set : true
roll.remove(bob) : false
--- 3. Member as the corpus writes it: identity-only equals/hashCode
roll3.contains(fined Ada) : true
roll3.remove(fined Ada) : true
roll3.size() after removal : 0
byId still has one entry : 1
byId.get("M-1").finesOwedMinor() : 500
--- 4. Loan as a value: equal only if every field matches
first.equals(same) : true
first.equals(extended) : false
set of first + same, size : 1
The question that decides the whole file
corpus/library/reference/DECISION_LOG.md has a heading, "Entity vs value · why Member is a class and Loan is a record", and it opens with the sentence this lesson is built on:
A member is an entity: Ada is Ada whether she owes nothing or owes five hundred. Her identity is
memberIdand nothing else, soequalscompares only that.
The same entry calls a loan a value snapshot, equal to another only if every field matches. Its reasoning: a loan is created once and later removed, never edited in between, so nothing about it plays the role memberId plays on Member.
One question, asked once per type: if every other field changed, would you still call it the same thing? Yes, and the surviving field is identity. Write an entity, and let that field be the entirety of equals. No, and no field survives every change, and you have a value. That is A2's territory, not this one.
The four blocks above are that question asked once and answered three different ways for the same two facts: a member's fine changes, a loan never does.
Block 1 — RecordMember.java: the wrong answer that looks the most natural
public record RecordMember(String memberId, String name, int borrowLimit, long finesOwedMinor) {
A record's equals reads every component, and there is no way to tell it to skip one. That is the whole bug, and it is not a mistake in how the record was written — it is a mistake in reaching for a record at all. Run the question on Member: if the fine changed and nothing else did, is it still Ada? Yes. So finesOwedMinor is exactly the field that must not decide equality, and a record cannot be told that.
Block 1's output is the cost, read literally. roll.contains(finedAda) is false because the generated equals sees a different finesOwedMinor and calls them unequal. roll.remove fails for the same reason — it has to find an equal element first. The set now has to be told to add the new snapshot, and it ends up holding two Adas, roll after fine + re-add : 2. That is corpus/library/reference/DECISION_LOG.md's own line, verified rather than quoted:
equalsgenerated over all components means Ada-owing-0 and Ada-owing-500 are different objects, somembers.remove(ada)silently does nothing once she has been fined, and aSet<Member>can hold the same person twice.
Block 2 — MutableMember.java: a correct override of the wrong fields, then mutated
public void setFinesOwedMinor(long finesOwedMinor) {
this.finesOwedMinor = finesOwedMinor;
}
@Override
public boolean equals(Object other) {
return other instanceof MutableMember member
&& memberId.equals(member.memberId)
&& name.equals(member.name)
&& finesOwedMinor == member.finesOwedMinor;
}
This is not the mistake J6 already covers. equals and hashCode are both present and they agree with each other — J6's MINOR_VALUE-style omission is not what is happening here. The bug is that finesOwedMinor feeds both methods and is also free to change after the object is already sitting inside a HashSet.
roll2.contains(bob) is true right after insertion, because nothing has moved yet. Then bob.setFinesOwedMinor(500L) changes the value hashCode reads, without telling the HashSet, which placed bob in a bucket computed from the old hash. roll2.contains(bob) now prints false for the same reference, not merely for an equal copy, and the loop in Main proves bob is still physically sitting in the set's internal array: same reference still in the set : true. The object is there and unreachable at the same time, and roll2.remove(bob) reports false because remove has the identical lookup problem contains does.
The contract clause this breaks: a hashCode used to place an object in a hash-based collection must not change for as long as the object is a member of that collection. It is not a Java rule enforced anywhere — nothing throws, nothing warns, and javac -Xlint:all is silent on every line above.
Block 3 — Member.java, copied from the corpus, and why both problems disappear
/** Identity is the id. Two snapshots of Ada are the same Ada. */
@Override
public boolean equals(Object other) {
return other instanceof Member member && memberId.equals(member.memberId);
}
@Override
public int hashCode() {
return memberId.hashCode();
}
One field, in both methods, and the field never changes. memberId is assigned once, in the constructor, and there is no setter anywhere in the class. That single fact rules out both earlier bugs at once. Block 1 needed a field that changes to be part of equals; block 2 needed a field that feeds hashCode to be mutable. Read Member and neither condition holds, so roll3.contains(finedAda) is true and roll3.remove(finedAda) is true — the exact two operations block 1 got wrong.
public Member owing(long finesOwedMinor) {
return new Member(memberId, name, borrowLimit, finesOwedMinor);
}
Being an entity did not require being mutable. It required the opposite. owing builds a new Member; nothing about ada changes. The write still has to reach whoever is supposed to see it, which here is a Map<String, Member> keyed by id, standing in for MemberRepository. That happens through an explicit put. That is why byId.get("M-1").finesOwedMinor() reads 500 after the write, and byId.size() stays 1. The corpus's own words for this, from the same decision log entry:
Being an entity does not mean being mutable.
A mutable entity handed out by a repository is changed by whoever holds it with no call to
save.
That "works" for the in-memory Map above, since nobody else can touch it. It silently loses the write against a real store, since nothing ever told the store the value had changed.
Block 4 — Loan.java: the other half of the same question, answered "value"
public record Loan(String loanId, String isbn, String memberId, long dueEpochDay) {
Run the question on a loan: if dueEpochDay changed and nothing else did, is it still the same loan, or a different fact about lending? The corpus's answer is that a loan is never edited. It is created once and later removed, so there is nothing that plays the role memberId plays on Member. Every field is part of what the loan is, which is exactly what a record's generated equality assumes.
first.equals(same) is true because every field matches, and first.equals(extended) is false because one does not. A Set<Loan> holding first and same collapses to size 1. Deduplicating two snapshots of one fact is correct here, on the same generated equality that was the bug one type over.
The contract, stated once
equals on its own carries four shape rules. Three are the properties the contract names — reflexivity, symmetry, transitivity. The fourth is that it never throws on null or on the wrong type. faded/GapTest.java's gap 2 checks all four directly. None of blocks 1 through 3 breaks any of those. What they break is the relationship between equals and hashCode, and that relationship is two separate rules:
- Consistency with equality. If
a.equals(b)istrue,a.hashCode() == b.hashCode()must also betrue. Block 3'shashCodereturningmemberId.hashCode()is what makes this hold for two fine-differing snapshots of Ada; block 2'shashCodereadingfinesOwedMinoris what breaks it the moment the two disagree. - Stability while a member of a hash-based collection. The fields feeding
hashCodemust not change while the object sits in aHashSetor is aHashMapkey. Block 2'ssetFinesOwedMinorviolates this on the same object reference; makingMemberimmutable is what makes the violation impossible to write, rather than merely unlikely.
Both clauses point at the same design move. An entity's equals and hashCode read the one field that identifies it, and that field is never the one a requirement asks you to change.
When not to
Giving something an id it did not ask for
The lesson so far argues for identity. The overuse risk is symmetric with A2's. Having recently learned that an entity needs an id, it is tempting to manufacture one for anything that looks important, including things that were values all along.
The concrete cost
corpus/library/contract/Loan.java is a record: isbn, memberId and a due date, equal when every field matches. Say a caller wants to spot a duplicate borrow request before it reaches the repository — two identical requests typed in twice by a slow network retry. With the value as-is, that is a Set<Loan> and nothing else. Real output:
value form, same request typed twice, equals : true
value form, pending set size (dedup'd) : 1
Give the same idea a requestId assigned at construction, on the theory that "the request has an identity too," and equals written against that field instead:
id form, same request typed twice, equals : false
id form, pending set size (not dedup'd) : 2
Both requests describe the same borrow. Deduplication is gone, silently, because the field that now decides equality is the one field guaranteed to differ between them. This is the exact inverse of block 1 in worked/. There, a value's fields leaked into an entity's equality and broke a lookup. Here, a manufactured id leaked into a value's equality and broke a deduplication that used to be free.
The test, reused from A2 and pointed the other way. If every field of the thing in front of you could change and you would still call it the same real-world fact, it needs an id. If nothing changes it (a request, a page of results, a rate), giving it an id anyway does not make it safer. It removes the one thing a value gave you for nothing: two descriptions of the same fact compare equal.
The other half of the contract, for free from the compiler
Override equals and forget hashCode and javac -Xlint:all says so on its own, unprompted:
warning: [overrides] Class Reservation overrides equals, but neither it nor any
superclass overrides hashCode method
a.equals(b) reads true and a.hashCode() == b.hashCode() reads false in the same run. The two methods have started disagreeing, and byReservation.get(b) returns null for a reservation that the map already holds under a key equals calls identical to b. Read that warning the moment it appears. It is naming the one bug in this lesson that the compiler is actually willing to catch for you.
Worked source
The 5 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/Loan.java18 linesworked/src/Member.java70 linesworked/src/MutableMember.java48 linesworked/src/RecordMember.java22 linesworked/src/Main.java63 lines
worked/src/Loan.java18 lines
import java.util.Objects;
/**
* Adapted from {@code corpus/library/contract/Loan.java}. One copy, out with one member, due back
* on one date.
*
* <p>A <i>snapshot</i>, not an entity. Two {@code Loan}s are equal only if every field matches —
* the generated record equality is the right equality here, unlike on {@link Member} — because a
* loan never changes: it is created, and later it is gone.
*/
public record Loan(String loanId, String isbn, String memberId, long dueEpochDay) {
public Loan {
Objects.requireNonNull(loanId, "loanId");
Objects.requireNonNull(isbn, "isbn");
Objects.requireNonNull(memberId, "memberId");
}
}
worked/src/Member.java70 lines
import java.util.Objects;
/**
* Copied from {@code corpus/library/reference/src/Member.java}. Somebody who may borrow books:
* their id, their name, how many books they may hold, and what they owe.
*
* <p>A member is an <b>entity</b>: Ada is Ada whether she owes nothing or owes five hundred. Her
* identity is {@code memberId} and nothing else, so {@link #equals(Object)} reads only that field
* — never {@code name}, never {@code finesOwedMinor}.
*
* <p>Being an entity does not mean being mutable. Paying a fine produces a new {@code Member}
* value with the same identity, through {@link #owing(long)}. Nothing here has a setter.
*/
public final class Member {
private final String memberId;
private final String name;
private final int borrowLimit;
private final long finesOwedMinor;
public Member(String memberId, String name, int borrowLimit, long finesOwedMinor) {
this.memberId = Objects.requireNonNull(memberId, "memberId");
this.name = Objects.requireNonNull(name, "name");
if (memberId.isBlank()) {
throw new IllegalArgumentException("a member needs an id");
}
if (finesOwedMinor < 0) {
throw new IllegalArgumentException("a member cannot owe " + finesOwedMinor);
}
this.borrowLimit = borrowLimit;
this.finesOwedMinor = finesOwedMinor;
}
public String memberId() {
return memberId;
}
public String name() {
return name;
}
public int borrowLimit() {
return borrowLimit;
}
public long finesOwedMinor() {
return finesOwedMinor;
}
/** The same member, owing a different amount. Refuses to go negative. */
public Member owing(long finesOwedMinor) {
return new Member(memberId, name, borrowLimit, finesOwedMinor);
}
/** Identity is the id. Two snapshots of Ada are the same Ada. */
@Override
public boolean equals(Object other) {
return other instanceof Member member && memberId.equals(member.memberId);
}
@Override
public int hashCode() {
return memberId.hashCode();
}
@Override
public String toString() {
return name + " (" + memberId + ", owes " + finesOwedMinor + ")";
}
}
worked/src/MutableMember.java48 lines
import java.util.Objects;
/**
* The second wrong draft: a mutable class with a hand-written {@code equals}/{@code hashCode}
* over every field — syntactically exactly what J6 asks for, and still wrong.
*
* <p>Nothing here is missing an override. The bug is which fields the override reads, combined
* with a setter that changes one of them after the object is already living inside a
* {@code HashSet}.
*/
public final class MutableMember {
private final String memberId;
private final String name;
private long finesOwedMinor;
public MutableMember(String memberId, String name, long finesOwedMinor) {
this.memberId = Objects.requireNonNull(memberId, "memberId");
this.name = Objects.requireNonNull(name, "name");
this.finesOwedMinor = finesOwedMinor;
}
public String memberId() {
return memberId;
}
public long finesOwedMinor() {
return finesOwedMinor;
}
/** Changes this object in place. Whatever collection is holding it is not told. */
public void setFinesOwedMinor(long finesOwedMinor) {
this.finesOwedMinor = finesOwedMinor;
}
@Override
public boolean equals(Object other) {
return other instanceof MutableMember member
&& memberId.equals(member.memberId)
&& name.equals(member.name)
&& finesOwedMinor == member.finesOwedMinor;
}
@Override
public int hashCode() {
return Objects.hash(memberId, name, finesOwedMinor);
}
}
worked/src/RecordMember.java22 lines
import java.util.Objects;
/**
* The wrong first draft: {@code corpus/library}'s {@code Member} written as a record instead of a
* class. Every field, including {@code finesOwedMinor}, feeds the generated {@code equals} and
* {@code hashCode}.
*
* <p>Kept here only to be run and watched fail in {@code Main}. The real design is
* {@link Member}, copied from {@code corpus/library/reference/src/Member.java}.
*/
public record RecordMember(String memberId, String name, int borrowLimit, long finesOwedMinor) {
public RecordMember {
Objects.requireNonNull(memberId, "memberId");
Objects.requireNonNull(name, "name");
}
/** A new snapshot with the same everything except the fine. Nothing here is `this` again. */
public RecordMember owing(long finesOwedMinor) {
return new RecordMember(memberId, name, borrowLimit, finesOwedMinor);
}
}
worked/src/Main.java63 lines
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Four short runs. Nothing here asserts anything — {@code javac -Xlint:all} is silent, and the
* point is to read the printed output, which is transcribed verbatim into worked/NOTES.md. */
public final class Main {
public static void main(String[] args) {
System.out.println("--- 1. Member as a record: every field decides equality");
RecordMember ada0 = new RecordMember("M-1", "Ada", 3, 0L);
Set<RecordMember> roll1 = new HashSet<>();
roll1.add(ada0);
RecordMember ada500 = ada0.owing(500L);
System.out.println(" roll before fine : " + roll1.size());
System.out.println(" roll.contains(fined Ada) : " + roll1.contains(ada500));
System.out.println(" roll.remove(fined Ada) : " + roll1.remove(ada500));
roll1.add(ada500);
System.out.println(" roll after fine + re-add : " + roll1.size() + " (two Adas)");
System.out.println("--- 2. Member as a mutable class, equals over every field");
MutableMember bob = new MutableMember("M-2", "Bob", 0L);
Set<MutableMember> roll2 = new HashSet<>();
roll2.add(bob);
System.out.println(" roll.contains(bob) before mutation : " + roll2.contains(bob));
bob.setFinesOwedMinor(500L);
System.out.println(" roll.contains(bob) after mutation : " + roll2.contains(bob));
boolean stillPhysicallyThere = false;
for (MutableMember m : roll2) {
if (m == bob) {
stillPhysicallyThere = true;
}
}
System.out.println(" same reference still in the set : " + stillPhysicallyThere);
System.out.println(" roll.remove(bob) : " + roll2.remove(bob));
System.out.println("--- 3. Member as the corpus writes it: identity-only equals/hashCode");
Member ada = new Member("M-1", "Ada", 3, 0L);
Map<String, Member> byId = new HashMap<>();
byId.put(ada.memberId(), ada);
Set<Member> roll3 = new HashSet<>();
roll3.add(ada);
Member adaFined = ada.owing(500L);
System.out.println(" roll3.contains(fined Ada) : " + roll3.contains(adaFined));
System.out.println(" roll3.remove(fined Ada) : " + roll3.remove(adaFined));
System.out.println(" roll3.size() after removal : " + roll3.size());
byId.put(adaFined.memberId(), adaFined);
System.out.println(" byId still has one entry : " + byId.size());
System.out.println(" byId.get(\"M-1\").finesOwedMinor() : " + byId.get("M-1").finesOwedMinor());
System.out.println("--- 4. Loan as a value: equal only if every field matches");
Loan first = new Loan("L-1", "978-0", "M-1", 19000L);
Loan same = new Loan("L-1", "978-0", "M-1", 19000L);
Loan extended = new Loan("L-1", "978-0", "M-1", 19003L);
System.out.println(" first.equals(same) : " + first.equals(same));
System.out.println(" first.equals(extended) : " + first.equals(extended));
Set<Loan> loans = new HashSet<>();
loans.add(first);
loans.add(same);
System.out.println(" set of first + same, size : " + loans.size());
}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.