Entities and relationships

@Entity, identity, @OneToMany and @ManyToOne, owning sides, cascades, and the equals/hashCode that makes an entity safe in a Set.

8 min read🗃️ Spring Data JPA and Hibernate

An @Entity is not a class that can be saved. It is a class whose instances Hibernate watches, and almost every surprise in this course follows from that one word. You do not tell it to write your changes; it notices them. You do not tell it to load a relationship; it fetches when you look. The mapping annotations decide when all of that happens, and the defaults are not the ones you would choose.

This lesson is the mapping. The watching is the next one.

An entity has three identities, not one

Every entity you write has three different notions of "the same", and confusing them is the source of bugs that only appear once the data is real.

  • Object identitya == b, the same JVM object.
  • Database identity — the same primary key.
  • Business identity — the same ISBN, the same email, the same order number.

Hibernate guarantees the first two line up within one persistence context: load the same row twice in one session and you get the same object back. Outside that, they come apart, and the place they come apart most painfully is a Set.

The equals and hashCode that quietly breaks

Here is the implementation everyone writes first. It is the one your IDE generates, and it is wrong:

Tag.javajava
@Entity
public class Tag {
    @Id @GeneratedValue Long id;
    String name;
 
    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Tag)) return false;
        return Objects.equals(id, ((Tag) o).id);
    }
 
    @Override public int hashCode() { return Objects.hash(id); }
}

Put one in a HashSet before it is saved, save it, and look again:

plaintext
### before persist, id = null, set.contains(t) = true
### after  persist, id = 1, set.contains(t) = false
### the set still reports size 1, and iterating finds it: java

Read the middle line twice. The set contains that exact objectsize() is 1, iterating hands it back — and contains() says no. persist assigned the id, the id changed the hash code, and the object is now sitting in the wrong bucket. Nothing threw. Nothing logged.

The cause is a rule that has nothing to do with JPA: a hash code must not change while the object is in a hash-based collection, and a generated id changes exactly once, at the worst moment.

Three implementations actually work:

What you useWhen it is right
a business key — ISBN, email, order numberbest, when a real immutable one exists
a UUID you assign yourself in the constructorwhen there is no business key; the id exists before persist, so it never changes
getClass() in equals, a constant hashCode()the pragmatic fallback; correct, and degrades a Set to a list

That last one looks like cheating and is what Hibernate's own documentation suggests when there is no business key. A constant hash code means every entity lands in one bucket, so lookup is linear — which is fine for the handful of children in a collection and not fine for ten thousand.

The owning side is the only side that matters

A relationship in your object graph is two references. A relationship in the database is one foreign key column. Something has to decide which reference writes that column, and that is the owning side.

mappedBy marks the other one — the inverse side — and the inverse side is read-only. Watch what that means:

java
Author a = new Author("Bloch");
em.persist(a);
Book b = new Book("Effective Java");   // b.author deliberately not set
a.getBooks().add(b);                   // only the inverse side touched
em.persist(b);
em.getTransaction().commit();
plaintext
### committing: added to author.books, never set book.author
### the foreign key column in the database is: null

In memory the object graph is perfect — a.getBooks() has the book in it, and every assertion you write in that method passes. In the database there is no relationship at all. @OneToMany(mappedBy = "author") says Book owns this; I am a view of it, so Hibernate read book.author, found null, and wrote null.

This is why entities carry a helper that sets both sides, and why writing one without the other is the single most common relationship bug:

Author.javajava
public void addBook(Book book) {
    books.add(book);        // the side you read
    book.setAuthor(this);   // the side that is written
}

The fetch defaults are backwards from what you want

Each relationship annotation has a default fetch type, and they are not consistent:

AnnotationDefaultWhat it means
@ManyToOneEAGERloading the child loads the parent, always
@OneToOneEAGERsame
@OneToManyLAZYthe collection loads when touched
@ManyToManyLAZYsame

The two singular ones default to eager, and that is a decision from 2006 that nobody can change now. Here is what it costs. A Book with an ordinary @ManyToOne Author, no fetch type written anywhere, and a query that loads books and never mentions the author:

plaintext
### load 8 books. @ManyToOne author is declared with no fetch type.
Hibernate: select book0_.id, book0_.author_id, book0_.title from Book book0_
Hibernate: select author0_.id, author0_.name from Author author0_ where author0_.id=?
Hibernate: select author0_.id, author0_.name from Author author0_ where author0_.id=?
Hibernate: select author0_.id, author0_.name from Author author0_ where author0_.id=?
Hibernate: select author0_.id, author0_.name from Author author0_ where author0_.id=?
### loaded 8 books; not one line of code has touched .getAuthor()

Five queries where you asked for one, for data the code never reads. This is an N+1 that nobody wrote, produced entirely by a default.

Two details in that log are worth noticing. There are four extra queries for eight books, because the eight books share four authors and the persistence context returns the same object for a row it has already loaded — the first-level cache, which the next lesson is about. And this happens on a @ManyToOne, the relationship people assume is cheap because it is "just one row".

Cascade is about lifecycle, not convenience

cascade says which operations travel along a relationship. The tempting one is CascadeType.ALL, and it is the one that removes data you did not mean to remove.

CascadeTravels
PERSISTsaving the parent saves new children
MERGEmerging the parent merges children
REMOVEdeleting the parent deletes the children
ALLall of them, including REMOVE

The question that decides it is not "is it convenient". It is can this child exist without this parent?

An OrderLine cannot exist without its Order — it is part of it, and deleting the order should delete the lines. CascadeType.ALL is right. A Book can exist without this Author in any system where books have several authors, or where deleting an author should not shred the catalogue. REMOVE there is a data-loss bug waiting for a routine cleanup job.

orphanRemoval = true is the stronger version and is often what people actually want: remove a child from the collection and it is deleted, not merely unlinked. Use it for true parts, never for associations.

What @Entity requires of you

Small, unglamorous rules, each of which produces a confusing failure when broken:

  • A no-argument constructor, at least protected. Hibernate instantiates entities reflectively, and a proxy subclass needs to call it. Make it protected rather than public so your own code cannot build a half-built entity.
  • Not final, and no final methods on a lazily-loaded entity. Hibernate's proxy is a generated subclass; it cannot extend a final class or override a final getter — and the failure is a lazy field that silently stays null rather than an error.
  • A surrogate primary key. A natural key looks tidy until the business changes it, and a business does change an email address.
  • @Column(nullable = false) is not validation. It shapes the generated DDL and nothing else. If you use migrations — and the last lesson in this course argues you must — that annotation documents intent while the migration file decides reality. Validate with Bean Validation at the boundary instead.
Progress is saved on this device and to your account when signed in.