equals and hashCode on Entities

The Problem

A new entity has id == null. After persist it gets an id. If hashCode() uses the id, the object's hash changes while it sits in a HashSet, and the set can no longer find it.

Example

An OrderItem added to Order.items (a HashSet) before saving:

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Set<BadItem> bad = new HashSet<>();
        BadItem b = new BadItem();
        bad.add(b);
        b.id = 101L;                                   // persist() assigned the id
        System.out.println("bad:  contains after persist? " + bad.contains(b));
        System.out.println("bad:  remove worked? " + bad.remove(b) + ", size " + bad.size());

        Set<GoodItem> good = new HashSet<>();
        GoodItem g = new GoodItem();
        good.add(g);
        g.id = 101L;
        System.out.println("good: contains after persist? " + good.contains(g));

        GoodItem a = new GoodItem(), c = new GoodItem();
        System.out.println("good: two new (unsaved) items equal? " + a.equals(c));
    }
}

class BadItem {
    Long id;
    @Override public boolean equals(Object o) { return o instanceof BadItem x && Objects.equals(id, x.id); }
    @Override public int hashCode() { return Objects.hash(id); }            // changes when id is set!
}

class GoodItem {
    Long id;
    @Override public boolean equals(Object o) {
        if (this == o) return true;
        return o instanceof GoodItem x && id != null && id.equals(x.id);    // unsaved: only equal to itself
    }
    @Override public int hashCode() { return GoodItem.class.hashCode(); } // constant for the entity type
}

Output

bad:  contains after persist? false
bad:  remove worked? false, size 1
good: contains after persist? true
good: two new (unsaved) items equal? false

Recommended Implementation

@Entity
public class OrderItem {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Override
    public final boolean equals(Object o) {
        if (this == o) return true;
        if (o == null) return false;
        if (Hibernate.getClass(this) != Hibernate.getClass(o)) return false;   // proxy-safe
        OrderItem other = (OrderItem) o;
        return getId() != null && getId().equals(other.getId());                // getter: works on proxies
    }

    @Override
    public final int hashCode() {
        return Hibernate.getClass(this).hashCode();
    }
}
  • If the entity has a stable business key (SKU, email, PAN number), use it instead: annotate it with @NaturalId and base equals/hashCode on it.
  • Never use Lombok @Data or @EqualsAndHashCode on entities: it includes all fields, including lazy collections.
  • A constant hashCode puts all items of one type in one bucket. That is fine for the small sets you keep in an entity; do not put millions of entities in one HashSet.