@ManyToMany is the right answer for maybe 20% of the relationships people use it for. The other 80% look like many-to-many at the domain modelling stage, but they have one of two problems: they carry metadata on the join that @ManyToMany cannot represent, or they need behaviour (assignment rules, expiry, status) that an entity can carry but a join table row cannot. Both of those cases need two @OneToMany relationships and an explicit join entity instead.
This post covers when @ManyToMany is genuinely correct, the exact moment it becomes wrong, the join entity conversion, and the four Hibernate-specific behaviours that make the List-vs-Set choice non-negotiable.
When @ManyToMany Is Correct
The relationship is a true many-to-many and the join has no business meaning of its own: tags on a product, roles on a user, permissions on a group. The join table row is just a pair of foreign keys. Nobody ever needs to query “when was this tag applied” or “who applied this permission” — those questions don’t exist in the domain model. The entities on both sides are independent and shared.
For these cases @ManyToMany is clean and correct. Use it. Just use Set, not List, and use cascade = {PERSIST, MERGE}, never REMOVE.
The Moment Metadata Appears on the Join
A product has tags. Simple enough for @ManyToMany. Then the product team asks: “can we see when each tag was applied and by whom?” Now the join row needs appliedAt and appliedBy. The join has business meaning. The join is an entity.
// Wrong: @ManyToMany can't carry this metadata
@ManyToMany
@JoinTable(name = "product_tags")
private Set<Tag> tags;
// Right: explicit join entity
@Entity
@Table(name = "product_tags")
public class ProductTag {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
@ManyToOne(fetch = FetchType.LAZY)
private Tag tag;
private Instant appliedAt;
private String appliedBy;
}
The rule of thumb: the moment you find yourself wishing you could add a column to a join table, convert to a join entity. Converting from @ManyToMany to two @OneToMany after the fact requires a schema migration and breaks any Spring Data query methods that traversed the old association. Do it before data accumulates.
cascade = REMOVE on @ManyToMany Is Almost Always a Bug
// This deletes every role when any user with that role is deleted
@ManyToMany(cascade = CascadeType.ALL)
@JoinTable(name = "user_roles")
private Set<Role> roles;
cascade = ALL includes REMOVE. Deleting one user deletes every role that user had, affecting every other user who shares those roles. The correct cascade for @ManyToMany is {PERSIST, MERGE} — let saves propagate, but let each side manage its own lifecycle.
Set Is Mandatory — Here’s Why List Breaks
When you remove one element from a List-backed @ManyToMany collection, Hibernate cannot target just the affected join row. It deletes all rows for that parent, then reinserts everything except the removed element.
// List: removing one tag fires DELETE all + INSERT all remaining
product.getTags().remove(specificTag);
// DELETE FROM product_tags WHERE product_id = ? (all rows)
// INSERT INTO product_tags VALUES (?, ?) x (N-1 times)
// Set: removing one tag fires one targeted DELETE
product.getTags().remove(specificTag);
// DELETE FROM product_tags WHERE product_id = ? AND tag_id = ? (one row)
On a product with 50 tags, a single tag removal fires 50 INSERTs with a List but 1 DELETE with a Set. Always use Set.
⚠️ Design Warning: The “Many-to-Many” Trap
Avoid using the @ManyToMany annotation in long-lived enterprise schemas unless the relationship is strictly binary.
If your relationship might ever need metadata—such as an assigned_at timestamp, a role (e.g., Lead vs. Contributor), or specific permissions—the standard @ManyToMany will be insufficient.
Continue reading @ManyToMany Is Almost Always Wrong: When Two @OneToMany Wins →