Equality, immutability and value objects

Part of the free Low-Level Design course on LogicWiz, in the chapter: Object-oriented basics, the way interviewers check them. The class-diagram exercises run in the app.

Equality, immutability and value objects

"Both coupons say FLAT50, equals says they match, and the set still holds two of them."

Priya, reviewing a pull request


Two coupons that look the same

SwiftKart allows each coupon code once per customer. Arjun keeps the codes a customer has used in a HashSet. A set is a collection that holds each item at most once. He wrote a small Coupon class and tried to compare two of them.

String a = "FLAT50";
String b = new String("FLAT50");
System.out.println(a == b);        // false
System.out.println(a.equals(b));   // true

Coupon c1 = new Coupon("FLAT50");
Coupon c2 = new Coupon("FLAT50");
System.out.println(c1 == c2);      // false
System.out.println(c1.equals(c2)); // false

The operator == asks "are these the same object?". Remember from the chapter on classes and objects that a variable holds a reference, an address of an object. For objects, == compares those addresses. For primitive types such as int and long, == compares the values.

The method equals asks "do these two mean the same thing?". Every class inherits equals from Object, but the inherited version does the same as ==. String overrides it to compare the characters, which is why the second line prints true. Coupon has no override, so its equals still compares addresses.

Arjun objected. "My test said "FLAT50" == "FLAT50" is true, so == works for strings." Priya explained. Java keeps one shared copy of each text typed directly in the source code, so two typed literals are the same object. A code that arrives from a request is a new object each time. His test passed by luck, and the bug would show up in production. For text and every other object, use equals. The one safe exception is an enum, because each enum constant exists as exactly one object.

Writing equals, and the bug without hashCode

To make two coupons with the same code equal, override equals.

public final class Coupon {
    private final String code;

    public Coupon(String code) { this.code = code; }

    @Override
    public boolean equals(Object other) {
        if (this == other) return true;
        if (!(other instanceof Coupon)) return false;
        return code.equals(((Coupon) other).code);
    }
}

The parameter is an Object because we are replacing the method that Object defines. The first line is a quick yes for the same object. The instanceof test is false for null and for any other type, so a wrong argument never causes a crash. The last line compares the fields that give the coupon its meaning. The class is final so that no subclass can change what "equal" means.

Now Arjun puts coupons in the set.

Set<Coupon> used = new HashSet<>();
used.add(new Coupon("FLAT50"));
used.add(new Coupon("FLAT50"));
System.out.println(used.size());                          // 2
System.out.println(used.contains(new Coupon("FLAT50")));  // false

Run it in your IDE: it prints 2 and false. The customer can use FLAT50 twice. The bug is the missing hashCode.

A hash code is an int that hashCode() returns, a quick fingerprint of an object. HashSet and HashMap use it to save time. They keep entries in numbered buckets and pick the bucket from the hash code. To find a coupon, they compute its hash code, open that one bucket, and only there ask equals.

Think of a post office that sorts letters into bins by pin code. Two letters for the same house with different pin codes go to different bins, and nobody ever compares them. The inherited hashCode is based on the object's address, so our two coupons get different fingerprints and different buckets. equals is never asked. The picture stops working in one place: a post office bins by an agreed address, while Java trusts whatever number your method returns, even a wrong one.

The rule is: if two objects are equal, their hash codes must be equal. The reverse is not required. Two different objects may share a hash code, which is called a hash collision. Then equals tells them apart inside the bucket. Add the missing method, using the same fields as equals.

@Override
public int hashCode() {
    return Objects.hash(code);
}

Run the set example again. It prints 1 and true. Override both methods together, or neither. In IntelliJ, the Generate menu (Alt+Insert on Windows and Linux, Cmd+N on a Mac) writes both from the fields you pick.

Records: the short way

Writing equals and hashCode for every small class is slow, and it is easy to forget a field. Java 16 added the record, a short class declaration for data that does not change. Java writes the final fields, the constructor, the accessor methods, equals, hashCode and toString for you. equals compares every component.

public record Coupon(String code) {
    public Coupon {
        if (code == null || code.isBlank()) {
            throw new IllegalArgumentException("Coupon code is required");
        }
        code = code.trim().toUpperCase();
    }
}

The block without parentheses is a compact constructor. It runs before the fields are set, so it is the place to check and clean the input. Assigning to the parameter code changes the value that gets stored. Now new Coupon(" flat50 ") equals new Coupon("FLAT50"), and the accessor is code(), not getCode().

A record is a good fit for values such as a coupon code, a pin code or an amount. It cannot extend another class, and its fields cannot be reassigned. Do not use it for an object whose state changes over time, such as a cart.

Immutability, with Money

SwiftKart's first Money class held a double. Then a test added ten items of ₹0.10.

System.out.println(0.1 + 0.2);       // 0.30000000000000004
double total = 0;
for (int i = 0; i < 10; i++) total += 0.1;
System.out.println(total);           // 0.9999999999999999

A double stores fractions in binary, and 0.1 has no exact binary form. Tiny errors appear and pile up, which is unacceptable for an invoice. So SwiftKart stores money as a whole number of paise, the hundredth part of a rupee, in a long. Whole numbers add exactly.

public record Money(long paise) {
    public Money {
        if (paise < 0) throw new IllegalArgumentException("Money cannot be negative: " + paise);
    }
    public Money plus(Money other)  { return new Money(paise + other.paise); }
    public Money minus(Money other) { return new Money(paise - other.paise); }
    public Money times(int qty)     { return new Money(paise * qty); }
}

Notice that plus does not change this. It builds a new Money. An object that cannot change after it is built is immutable. To make a class immutable:

  • Make every field final and write no setters.
  • Make the class impossible to extend, with final (a record already is).
  • Return a new object from any method that "changes" something.
  • Copy any mutable object you receive or hand out.

The last rule needs an example. A delivery address has a list of notes for the rider. This record looks safe, but it is not.

public record Address(String line, String pincode, List<String> notes) {}

List<String> notes = new ArrayList<>(List.of("Gate 2"));
Address home = new Address("12 MG Road", "560001", notes);
notes.add("Ring twice");
System.out.println(home.notes());   // [Gate 2, Ring twice]

The record stored the caller's list, not a copy, so the caller still holds a handle on the inside of the address. The fix is a defensive copy: a private copy of a mutable object, made so outsiders cannot change your inside. In the compact constructor, write notes = List.copyOf(notes);. Now the output is [Gate 2], and List.copyOf returns a list that nobody can change, so the accessor is safe to hand out too. Arrays and Date objects need a copy on the way out as well.

💡 Tip: Immutable objects are easy to reason about. If a method receives a Money, nothing can change it behind your back, so a wrong total cannot come from a setter in a distant class.

Immutability also gives thread safety. A thread is one line of work that runs at the same time as others in the same program. Trouble starts when two threads change the same data, and an object that cannot change cannot have that trouble. The concurrency chapter shows the details. The cost is one new object per change. For small values that is cheap, but a Cart that changes all day should stay mutable.

The mutable key trap

A mock interviewer asked a candidate: "What happens if you use a mutable object as a HashMap key?" The candidate said: "The map will notice the change and move the entry to the right bucket." That is wrong. The map reads the hash code once, when you call put. It never looks again. Here MutableCoupon is a version of Coupon with a setCode method, and equals and hashCode that use the code.

Map<MutableCoupon, Integer> uses = new HashMap<>();
MutableCoupon flat50 = new MutableCoupon("FLAT50");
uses.put(flat50, 3);
flat50.setCode("FLAT75");               // changes the hash code

System.out.println(uses.get(flat50));                    // null
System.out.println(uses.containsKey(flat50));            // false
System.out.println(uses.size());                         // 1
System.out.println(uses.get(new MutableCoupon("FLAT50")));      // null

The entry sits in the bucket chosen by the old hash code. A lookup with the changed key opens a different bucket. A lookup with the old code opens the right bucket, but the stored key now says FLAT75, so equals fails. The entry still counts in size(), but you can no longer find it or remove it. It is lost memory.

The answer an interviewer wants: the entry becomes unreachable, so keys must not change while they are in the map. Use immutable keys: String, Integer, enums and records. The same holds for the elements of a HashSet. If you need an entity as a key, use its immutable id.

Entity or value object

Now we can name two kinds of objects. A value object has no identity of its own. It is defined by its values, so two ₹49.50 amounts are interchangeable. It is immutable, and equal when its values are equal. An entity has an identity that stays the same while its data changes. Order 8123 is the same order when its status moves from PLACED to DELIVERED. Entities are usually mutable, and equal when their ids are equal.

In SwiftKart, Money, Coupon and a delivery address on an order are values. Order, Customer and Rider are entities.

The line is a modelling choice, not a law. An address copied onto an order at checkout is a value, because the delivered order must keep it even if the customer later edits "Home". The same address in the customer's saved list is an entity with an id, because the customer edits it and expects the same "Home" tomorrow. Ask two questions: if two of them hold the same data, are they the same thing? Does its history matter? In an interview, say your choice in one sentence.

classDiagram
class Order {
    <<entity>>
    -String id
    -Money total
    -Address deliverTo
}
class Money {
    <<value>>
    -long paise
}
class Address {
    <<value>>
    -String line
    -String pincode
}
Order --> Money : total
Order --> Address : deliver to

The order has an id and changes over time. Both values are replaced, never edited. Try it: the starter keeps the total as a public double and the address as plain text. Add a Money class and an Address class, connect both to Order, and give Money the methods that make it a value. Use the note on each class to say it is a value object.

📌 Summary: == compares references, equals compares meaning, and hashCode must agree with it. Make small values immutable records, store money in paise, and never use a mutable object as a map key.

What Arjun learns next

Arjun can now say what a class owns, what it hides and when two objects are equal. His diagrams are still messy sketches. Next: how to draw a class box that any interviewer can read in seconds.

Check your understanding

  1. Coupon is a plain class with a code field and no equals method. Arjun runs new Coupon("A").equals(new Coupon("A")). What does it print?
  2. A teammate writes equals using only code, and hashCode using code and discountPercent. Two coupons have the same code and different discounts. What goes wrong?
  3. A mutable Coupon is a HashMap key. Its code is changed after put. What happens to the entry?
  4. Address is a record with a List<String> notes component, stored as the caller passed it. What can still go wrong?
  5. Order 8123 moves from PLACED to PACKED. Which comparison should decide whether two Order objects are the same order?