LLD Dojo

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

corpus/library/reference/src/Member.java models a library member. Ada owes nothing today and five hundred tomorrow, and she is still Ada. The corpus's own decision log says why: "Her identity is memberId and nothing else, so equals compares only that."

Get this wrong and here is what actually happens, verbatim from running it. Put Ada in a HashSet, then hand back a snapshot carrying her new fine. If Member were a record, equals compares every field, so roll.contains(finedAda) prints false, and so does roll.remove(finedAda). Ada owing money is now a stranger to her own record. Nothing threw, and the next add quietly gives the roll two Adas.

Value equality answers "is this the same information." Identity equality answers "is this the same thing." Ask yourself: if every field changed except one, would you still call it the same real-world thing? If yes, that one field is the whole of equals, and hashCode has to be built from exactly that field and nothing else. The two are a pair, and the compiler will never check that you kept them in sync. If no second field survives every change, you have found a value, and A2 already covers it.

assertEquals(ada, ada.owing(500)) can pass for months while a HashMap still cannot find her, because a unit test does not call hashCode. That gap between "the assertion passed" and "the collection works" is the whole of this lesson.


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 memberId and nothing else, so equals compares 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:

equals generated over all components means Ada-owing-0 and Ada-owing-500 are different objects, so members.remove(ada) silently does nothing once she has been fined, and a Set<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:

  1. Consistency with equality. If a.equals(b) is true, a.hashCode() == b.hashCode() must also be true. Block 3's hashCode returning memberId.hashCode() is what makes this hold for two fine-differing snapshots of Ada; block 2's hashCode reading finesOwedMinor is what breaks it the moment the two disagree.
  2. Stability while a member of a hash-based collection. The fields feeding hashCode must not change while the object sits in a HashSet or is a HashMap key. Block 2's setFinesOwedMinor violates this on the same object reference; making Member immutable 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 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.

← A3 · Sealed hierarchy for a closed set of variants — when to close the set A5 · Invariant placement — no invalid instance can exist →

← all lessons