A: 
Tim Visher
+1  A: 

The equals method in JobStateChange uses direct field access. Changing it to use getters for the various properties will fix the problem. You may also want to consider using the HibernateProxyHelper.getClassWithoutInitializingProxy method when performing the instanceof comparison.

For example, the JobStateChange.equals method might look like:

@Override
public boolean equals(final Object obj) {
    if (this == obj) {
        return true;
    } else if (!(HibernateProxyHelper.getClassWithoutInitializingProxy(obj) 
                 instanceof JobStateChange)) {
        return false;
    }

    JobStateChange candidate = (JobStateChange) obj;

    return this.getState() == candidate.getState()
        && this.getActingUser().equals(candidate.getUser())
        && this.getDate().equals(candidate.getDate());
}

Likewise, the JobStateChange.hashCode() method should also use getters (I would also recommend writing the hashCode method to match the algorithm suggested by Joshua Bloch in Effective Java, Chapter 3 (starting on page 38), but that isn't really relevant to the question):

@Override
public int hashCode() {
    return this.getState().hashCode()
         + this.getActingUser().hashCode()
         + this.getDate().hashCode();
}

Part of the "magic" behind Hibernate are dynamic proxies. In many cases, Hibernate creates (at run time) a subclass of your entity classes, and overrides the getter and setter methods of the persisted properties. Thus, you cannot reference the properties directly in equals and hashCode, but instead should access them using the property getters and setters, even internally in the entity class.

You were receiving a "unique constraint" error because Hibernate relies on the equals method when persisting changes to a collection.

With the old equals method, the proxied JobStateChange objects will never be equal to one another, or to non-proxied JobStateChange objects. Thus, Hibernate thought that the existing elements in the stateChanges collection were new items, and attempted to insert them into the database. Since the rows in job_state must be unique (defined by the primary key across all columns), a constraint volition was generated.

Jim Hurne
__Fantastic__. Thanks so much for your phenomenal answer.
Tim Visher
You're welcome.
Jim Hurne
While your answer makes a lot of sense, I don't know if you could shed any light on one of the more confusing parts of the problem to me, specifically, why everything works if I annotate the JobStateChange.Date field as @Temporal(TemporalType.DATE). It's incorrect from the semantic perspective of the application, but it does work from the technical perspective of Hibernate, and I have no idea why. Thanks if you have time to explain further!
Tim Visher