tl;dr

See here for how to properly generate equals and hashCode for Hibernate. The catch is that with Hibernate you end up with a mix of Entity class and HibernateProxy subclasses, which will not match the equals methods suggested in most places.

Overview

Review the article linked above for the technical details of how equals and hashCode are used by Hibernate. The key is that these need to reflect the primary key of the entity so that when Hibernate needs an object for a database record it can locate it in its session.

Also, Hibernate uses proxies to allow for lazy loading of relationships, by overriding the getters.

Depending on your fetch strategy, you’ll end up with a mix of Entity classes and HibernateProxy classes, particularly with @OneToMany relationships, all based on when the entity was fetched or referenced.

In this example below there is a @OneToMany(fetch = FetchType.EAGER) relationship (this is the default for JPA). In the screenshot you can see the contents of the List contains a mix of object classes.

fetch eager

Specifically, you can see Person(id=1) is duplicated as both a proxy and base Entity.

Now, with @OneToMany(fetch = FetchType.LAZY):

fetch lazy

Everything is a proxy!

Consequences and Solution

In this particular case I was trying to group the results by person, which produced invalid results because Person(id=1) ended up in 2 groups. Interestingly, this error only became apparent when upgrading from Hibernate 5 to Hibernate 6, as the behaviour of @OneToMany changed from default LAZY to default EAGER to conform with the JPA spec.

The terrible bandaide solution would be to force the fetch to always be LAZY. This basically just hides a landmine for future developers.

The proper solution is to implement an equals that is aware of HibernateProxy and checks that the proxied classes are the same. In my case I made a helper class that is used by all entities.

    public static <T> boolean equals(T a, Object b, Function<T, Object> keyExtractor) {
        if (a == b) {
            return true;
        }
        if (b == null) {
            return false;
        }
        Class<?> bEffectiveClass = b instanceof HibernateProxy
                ? ((HibernateProxy) b).getHibernateLazyInitializer().getPersistentClass()
                : b.getClass();
        Class<?> aEffectiveClass = a instanceof HibernateProxy
                ? ((HibernateProxy) a).getHibernateLazyInitializer().getPersistentClass()
                : a.getClass();
        if (aEffectiveClass != bEffectiveClass) {
            return false;
        }
        return keyExtractor.apply(a).equals(keyExtractor.apply((T) b));
    }

Person.java:

public class Person {
...
    @Override
    public boolean equals(Object o) {
        return EntityHelper.equals(this, o, Person::getId);
    }
}