Unidirectional vs bidirectional associations, the owning side and mappedBy, infinite recursion when serialising bidirectional entities, many-to-many with extra columns, @JoinColumn vs @JoinTable, cascading in one-to-one and parent-child hierarchies, CascadeType.ALL on both sides, orphan removal, default fetch types, and one-to-one with a shared primary key (@MapsId).
Published September 25, 2026
Mapping mistakes cause most JPA performance and correctness bugs: extra UPDATEs, orphaned rows, recursive JSON, and accidental cascaded deletes. For each answer, say which table owns the foreign key, and what SQL Hibernate will run.
Short answer:
OrderLine.order with @ManyToOne, and no collection on Order). It's simpler, with fewer synchronisation bugs. Unidirectional @OneToMany without @JoinColumn creates a join table, and extra statements, which is usually a performance smell.Order.lines @OneToMany(mappedBy = "order") and OrderLine.order @ManyToOne). It's convenient for navigation both ways, but you must keep both sides in sync in code (helper methods), and watch out for recursive toString/JSON serialisation.The best practice: model @ManyToOne on the child (the FK side) as the primary mapping. Add the parent's collection only if you genuinely navigate that way, and prefer queries over huge collections (@OneToMany on thousands of rows is a trap).
mappedBy mean, and when do you use it? Which side is the owning side, and why does it matter?Short answer: In a bidirectional association, only one side controls the foreign key: the owning side. The other side declares mappedBy = "fieldOnOwningSide", meaning "this relationship is already mapped by that field; don't manage the FK from here".
@ManyToOne is always the owning side (the FK column lives in its table).@OneToOne, the owner is the side with the @JoinColumn.@ManyToMany, you choose one owner (with @JoinTable), and the other uses mappedBy.Why it matters: Hibernate only looks at the owning side when writing the FK. If you only do order.getLines().add(line) (the inverse side) without line.setOrder(order), no FK is written (or it's set to null). If both sides were mapped as owners, you'd get duplicate updates, or two FK or join-table mappings.
@Entity class Order {
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
public void addLine(OrderLine l) { lines.add(l); l.setOrder(this); } // keep both sides in sync
public void removeLine(OrderLine l) { lines.remove(l); l.setOrder(null); }
}
@Entity class OrderLine {
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id", nullable = false)
private Order order; // the owning side
}
Short answer: Order → lines → order → lines… makes Jackson (and Lombok @ToString/@EqualsAndHashCode) recurse until a StackOverflowError. The fixes:
@JsonManagedReference/@JsonBackReference (the forward part is serialised, and the back reference is omitted);@JsonIgnore on the back side;@JsonIdentityInfo (serialises repeated objects as IDs).@ToString and @EqualsAndHashCode, or don't use @Data on entities.Short answer: A plain @ManyToMany join table can't hold extra columns. Promote the join table to an entity, with two @ManyToOnes. For example, Enrollment between Student and Course, with enrolledAt and grade:
@Entity
public class Enrollment {
@EmbeddedId private EnrollmentId id = new EnrollmentId();
@ManyToOne(fetch = FetchType.LAZY) @MapsId("studentId") private Student student;
@ManyToOne(fetch = FetchType.LAZY) @MapsId("courseId") private Course course;
private Instant enrolledAt;
private String grade;
}
@Embeddable public record EnrollmentId(Long studentId, Long courseId) implements Serializable {
public EnrollmentId() { this(null, null); } // JPA needs a no-arg constructor
}
// Student: @OneToMany(mappedBy = "student") Set<Enrollment> enrollments;
Key points to cover:
@Id @GeneratedValue Long id, plus a unique constraint on (student_id, course_id)) is often simpler than a composite key.Set for many-to-many collections (a List "bag" causes delete-all-and-reinsert behaviour on changes).@JoinColumn and @JoinTable?Short answer:
@JoinColumn: the association is stored as a foreign-key column in one of the entities' tables (order_line.order_id). It's the norm for @ManyToOne, @OneToOne, and a unidirectional @OneToMany (with the FK in the child table).@JoinTable: the association lives in a separate link table (student_course(student_id, course_id)). It's required for @ManyToMany, and optional for one-to-many or one-to-one (for example, when you can't change the child table, or the relationship is optional and sparse).Join tables add a join, and extra inserts. Use them when the model needs them.
Short answer: Yes. Cascades work on any association: @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) for a composition (User → UserProfile), where the child's lifecycle is owned by the parent. For aggregates (Order → OrderLines → LineDiscounts):
cascade = {PERSIST, MERGE, REMOVE} (or ALL) plus orphanRemoval = true on the parent's collection;order.addLine(...)).The rules:
REMOVE on @ManyToOne/@ManyToMany: deleting an order line would delete the shared product or customer.Large deletes are better done with bulk queries, or database ON DELETE CASCADE, than by loading everything and removing each entity one by one.
cascade = CascadeType.ALL?Short answer: Operations propagate in both directions. The dangerous one is REMOVE from the child side:
OrderLine cascades to its Order;EntityNotFound and constraint violations.PERSIST/MERGE in both directions also cause confusing re-attachment of graphs, and extra work. Only the aggregate root cascades, and child-to-parent associations usually have no cascade.
Short answer: orphanRemoval = true on a @OneToMany or @OneToOne means that removing a child from the parent's collection (or nulling a one-to-one reference) deletes the child row at flush. It's for composition: children can't exist without their parent.
CascadeType.REMOVE, which deletes the children only when the parent is deleted. Orphan removal also triggers on disassociation.order.setLines(newList)). Hibernate throws "A collection with cascade=all-delete-orphan was no longer referenced". Clear it and add to it instead.Short answer: In JPA:
@ManyToOne and @OneToOne are EAGER by default;@OneToMany and @ManyToMany are LAZY;The best practice: set every association to FetchType.LAZY explicitly (@ManyToOne(fetch = LAZY)), and fetch what each use case needs through fetch joins, entity graphs or projections. Eager defaults on to-one associations are a common source of N+1 queries: every order loads its customer with a separate SELECT when you query a list.
Short answer:
@ManyToOne (FK) side. It gives the most efficient SQL (a single insert with the FK).@OneToMany(mappedBy) is optional. Add it for aggregate navigation and cascading.addLine/removeLine), and don't expose setters that let callers break consistency.@OneToMany without @JoinColumn (it creates a join table, and extra statements).@OneToOne, prefer making the child the owner with @MapsId (a shared primary key), which avoids an extra FK column and lazy-loading problems on the inverse side.Set for many-to-many, and List with @OrderColumn only if the order matters.equals/hashCode using a business key, or a stable ID strategy (not generated IDs that are null before persist).Short answer: The child's primary key is also the foreign key to the parent, which gives one ID for both rows. Use @MapsId on the child's @OneToOne:
@Entity
public class UserProfile {
@Id private Long id; // the same value as User.id
@OneToOne(fetch = FetchType.LAZY) @MapsId
@JoinColumn(name = "id")
private User user;
private String bio;
}
// Load a profile directly by the user ID: em.find(UserProfile.class, userId); no extra FK column, no join needed
The benefits:
@OneToOne(mappedBy) on the parent can't be lazy, because Hibernate must query to know whether it's null. Often you don't map the parent side at all, and fetch the profile by ID when needed.Q: Why do bidirectional @OneToMany updates sometimes produce an INSERT followed by an UPDATE?
A: That's a unidirectional @OneToMany with @JoinColumn, or a missing owning-side assignment: Hibernate inserts the child, then updates its FK from the collection side. Mapping the relationship as bidirectional, with the @ManyToOne as owner (and a not-null FK), gives a single INSERT.
Q: List vs Set for @ManyToMany?
A: A List without @OrderColumn is a "bag". Removing one element can make Hibernate delete every link row and re-insert the rest. A Set deletes exactly one row. Fetching two bags in one query also causes MultipleBagFetchException.
Q: How do you implement soft deletes with JPA?
A: Use a deleted flag or deleted_at timestamp, with @SQLDelete (turning delete into an update) and @SQLRestriction (Hibernate 6.3+, which replaced @Where) to filter the deleted rows, or Hibernate's native @SoftDelete (6.4+). Remember the unique constraints (partial indexes) and cascades.
Q: Should entities use @Data from Lombok?
A: No. The generated equals/hashCode touch lazy associations and mutable fields, and toString recurses and triggers loading. Use explicit getters, and carefully written equals/hashCode (a business key, or an ID with a null-safe strategy).