equals and hashCode for entities

Hibernate · lesson 21 of 32 · 4 min read

Keep an entity usable in a HashSet both before and after Hibernate assigns its id.

Open this lesson in the learning hub

Key points

  • A generated id is null until flush. Deriving hashCode from it means the hash changes while the object sits in a set.
  • Once the hash moves, HashSet probes the wrong bucket, so contains is false for an object it still holds.
  • Best answer: use the natural business key - ISBN, email, order number - and mark it @NaturalId.
  • With no natural key, return a constant hashCode and compare ids in equals. One bucket, but always correct.
  • Compare with instanceof, not getClass(): a lazy proxy is a subclass, so getClass rejects a valid match.
  • Records cannot be entities: they are final, have no no-arg constructor, and their generated equals covers every field.

Example

import java.util.HashSet;
import java.util.Set;

public class Main {

    public static void main(String[] args) {
        Set<Book> byId = new HashSet<>();
        Book book = new Book("978-0134685991", "Effective Java");
        byId.add(book);
        System.out.println("id-based  before flush : contains=" + byId.contains(book));

        book.id = 42L;                       // exactly what flush does to a new entity
        System.out.println("id-based  after flush  : contains=" + byId.contains(book));
        System.out.println("id-based  still stored : size=" + byId.size()
                + ", title=" + byId.iterator().next().title);

        Set<Isbn> byKey = new HashSet<>();
        Isbn stable = new Isbn("978-0134685991", "Effective Java");
        byKey.add(stable);
        System.out.println("key-based before flush : contains=" + byKey.contains(stable));

        stable.id = 42L;
        System.out.println("key-based after flush  : contains=" + byKey.contains(stable));
        System.out.println("key-based equal copy   : contains="
                + byKey.contains(new Isbn("978-0134685991", "other title")));
    }

    /** The bug: the hash comes from a value that is null until the insert. */
    static final class Book {
        Long id;
        final String isbn;
        final String title;

        Book(String isbn, String title) { this.isbn = isbn; this.title = title; }

        @Override public int hashCode() { return id == null ? 0 : id.hashCode(); }

        @Override public boolean equals(Object o) {
            return o instanceof Book b && id != null && id.equals(b.id);
        }
    }

    /** The fix: hash the natural key, which never changes. */
    static final class Isbn {
        Long id;
        final String isbn;
        final String title;

        Isbn(String isbn, String title) { this.isbn = isbn; this.title = title; }

        @Override public int hashCode() { return isbn.hashCode(); }

        @Override public boolean equals(Object o) {
            return o instanceof Isbn other && isbn.equals(other.isbn);
        }
    }
}

Never hash a value Hibernate is going to change for you.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Hibernate course, and every lesson in it is listed on the Hibernate contents page.