What are Entity Relationships — and What Problem Do They Solve?
In a relational database, tables are linked through foreign keys.
In JPA, these links become Java object references — an
Order object holds a reference to a
Customer object, not just a customerId
long. JPA's relationship annotations tell the ORM how to translate
between those two representations.
Getting relationships wrong is the most common source of production performance problems with JPA. Fetch strategies, cascade choices, and ownership rules all have direct consequences in the SQL that gets executed. Understanding the mechanics — not just the annotations — is what separates correct from accidental.
// BEFORE JPA — you manage the foreign key yourself
public record OrderRow(long id, long customerId, String status) {}
public Optional<Customer> getCustomer(OrderRow order) {
return customerRepo.findById(order.customerId()); // separate query every time
}
// AFTER JPA — the ORM manages the join for you
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer; // loaded on demand, or joined with JOIN FETCH
}
| Annotation | Cardinality | E-commerce example | Default fetch |
|---|---|---|---|
@ManyToOne | Many → One | Order → Customer | EAGER (override to LAZY) |
@OneToMany | One → Many | Customer → Orders | LAZY |
@OneToOne | One → One | Customer → ShippingAddress | EAGER (override to LAZY) |
@ManyToMany | Many → Many | Order ↔ Product (via OrderItem) | LAZY — but prefer intermediate entity |
The Owning Side and mappedBy — The Rule Everyone Gets Wrong
In every bidirectional relationship there is an owning
side (the entity that holds the foreign key column) and
an inverse side (declared with
mappedBy). JPA only looks at the owning side to
decide what SQL to write. If you only update the inverse side,
the change is not persisted.
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id") // owning side — holds the FK column
private Customer customer;
}
@Entity
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "customer") // inverse side — mappedBy = field name in Order
private List<Order> orders = new ArrayList<>();
}
// WRONG — updates only the inverse side; Hibernate ignores it
customer.getOrders().add(order); // no SQL UPDATE issued
// CORRECT — update the owning side
order.setCustomer(customer); // UPDATE orders SET customer_id = ? WHERE id = ?
// ALSO CORRECT — update both sides so the in-memory object graph is consistent
order.setCustomer(customer);
customer.getOrders().add(order); // keeps the collection accurate without a DB roundtrip
mappedBy = "I am not the owner; look over there"The value of mappedBy is the field name
on the owning entity, not the column name, not the table name.
If you write mappedBy = "customer_id" (the column
name) instead of mappedBy = "customer" (the field
name), JPA will throw a mapping exception at startup. This is
the most frequent typo in bidirectional relationship
declarations.
@ManyToOne and @OneToMany — The E-Commerce Core
The @ManyToOne / @OneToMany pair is the
most common relationship in any real application. In e-commerce:
many orders belong to one customer; one customer has many orders.
The FK column customer_id lives in the
orders table — that's why Order is the
owning side.
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String fullName;
@OneToMany(
mappedBy = "customer",
cascade = {CascadeType.PERSIST, CascadeType.MERGE},
// NOTE: CascadeType.REMOVE is intentionally absent.
// Deleting a customer should not cascade-delete their orders —
// orders are financial records that must be retained.
// orphanRemoval is also absent for the same reason.
fetch = FetchType.LAZY
)
private List<Order> orders = new ArrayList<>();
// Bidirectional helper — keeps both sides of the in-memory graph consistent
public void addOrder(Order order) {
orders.add(order);
order.setCustomer(this);
}
public void removeOrder(Order order) {
orders.remove(order);
order.setCustomer(null);
}
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // always LAZY — override the EAGER default
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer; // owning side: holds the FK column
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.PENDING;
@OneToMany(
mappedBy = "order",
cascade = CascadeType.ALL, // ALL is correct here — items are children of the order
orphanRemoval = true // removing an item from the collection deletes it from the DB
)
private List<OrderItem> items = new ArrayList<>();
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
private int quantity;
private BigDecimal unitPrice; // snapshot — not product.getPrice()
}
CascadeType.ALL + orphanRemoval on a collection is not always correctThe example above uses CascadeType.ALL and
orphanRemoval = true on the
Order → OrderItem relationship. This is correct
because OrderItem is a dependent child that cannot
exist without its parent order. It is not
correct on the Customer → Order side, where those
same options would delete all of a customer's historical orders
when the customer is deleted. Before writing
cascade = CascadeType.ALL, orphanRemoval = true,
ask: "If the parent is deleted, should all children be deleted
too?" If the answer is "no" or "depends on business rules",
specify only PERSIST and MERGE.
@OneToOne — Two Patterns, One Correct Choice
The two common ways to map a one-to-one relationship are: a
separate FK column, or a shared primary key. The shared primary
key (@MapsId) is the better choice when the child
entity's identity is inseparable from the parent's — it removes
a redundant column and makes the JOIN condition trivial.
// Pattern A — separate FK column (default, fine for most cases)
@Entity
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY // override the EAGER default
)
@JoinColumn(name = "shipping_address_id")
private ShippingAddress shippingAddress;
}
// Pattern B — shared primary key (@MapsId), cleaner for child-is-extension semantics
// No extra column; shipping_addresses.id IS the FK to customers.id
@Entity
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(mappedBy = "customer", cascade = CascadeType.ALL,
orphanRemoval = true, fetch = FetchType.LAZY)
private ShippingAddress shippingAddress;
}
@Entity
@Table(name = "shipping_addresses")
public class ShippingAddress {
@Id
private Long id; // no @GeneratedValue — derived from customer
@OneToOne(fetch = FetchType.LAZY)
@MapsId // uses customer.id as this entity's PK
@JoinColumn(name = "id")
private Customer customer;
private String street;
private String city;
private String postalCode;
private String country;
}
@OneToOne lazy loading requires bytecode enhancementFetchType.LAZY on @OneToOne is
declared but not guaranteed. For the inverse side (the side
with mappedBy), Hibernate cannot use a proxy
because it needs to check whether a row even exists — which
requires a query. Without bytecode enhancement (Hibernate's
hibernate-enhance-maven-plugin with
enableLazyInitialization=true), the
@OneToOne inverse side will always load eagerly
regardless of the declared fetch type. If this relationship
is performance-sensitive, use bytecode enhancement or switch
to @MapsId (Pattern B), where Hibernate can proxy
because the ID is always known.
@ManyToMany — Avoid It; Use an Intermediate Entity
Pure @ManyToMany generates a join table with only two
FK columns. In practice you almost always need additional data on
that join — a quantity, a price at the time of purchase, an
enrollment date, a status. You can't add columns to a pure
@ManyToMany join table without converting it to an
intermediate entity anyway. Start with the intermediate entity.
// BAD — pure @ManyToMany — join table has only (order_id, product_id),
// no room for quantity or unit_price
@Entity
public class Order {
@ManyToMany
@JoinTable(name = "order_products",
joinColumns = @JoinColumn(name = "order_id"),
inverseJoinColumns = @JoinColumn(name = "product_id")
)
private List<Product> products; // ✗ can't store quantity or price here
}
// CORRECT — intermediate entity with composite key via @EmbeddedId + @MapsId
@Embeddable
public class OrderItemId implements Serializable {
private Long orderId;
private Long productId;
// equals and hashCode are mandatory for @Embeddable composite keys
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderItemId that)) return false;
return Objects.equals(orderId, that.orderId)
&& Objects.equals(productId, that.productId);
}
@Override
public int hashCode() { return Objects.hash(orderId, productId); }
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@EmbeddedId
private OrderItemId id = new OrderItemId();
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("orderId")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("productId")
private Product product;
// data that belongs to the join — impossible with pure @ManyToMany
private int quantity;
private BigDecimal unitPrice; // price at the time of purchase — not product.getPrice()
}
@Entity
public class Order {
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
public void addItem(Product product, int quantity) {
OrderItem item = new OrderItem();
item.setOrder(this);
item.setProduct(product);
item.setQuantity(quantity);
item.setUnitPrice(product.getPrice()); // snapshot the price now
items.add(item);
}
}
Fetch Types and the N+1 Problem
The N+1 problem is the most common JPA performance bug. It happens when you load N parent records and then trigger N additional queries to load a child association — one per parent. The result is N+1 queries where 1 JOIN would have sufficed.
How N+1 happens
// Scenario: list 50 orders with the customer email for each
// Query 1 — loads the orders
List<Order> orders = orderRepo.findAll();
// SELECT * FROM orders → 50 rows
// 50 queries follow — one per order, triggered by accessing order.getCustomer()
for (Order o : orders) {
System.out.println(o.getCustomer().getEmail());
// SELECT * FROM customers WHERE id = 1
// SELECT * FROM customers WHERE id = 2
// ...
// SELECT * FROM customers WHERE id = 50
// Total: 51 queries
}
// customer is LAZY — Hibernate can't know you'll need it when loading orders,
// so it defers each load to when you access it. By that point it's too late to batch.
Fix 1: JOIN FETCH in JPQL
// Fetch orders and customers in a single JOIN
@Query("""
SELECT o FROM Order o
JOIN FETCH o.customer
WHERE o.status = :status
""")
List<Order> findByStatusWithCustomer(@Param("status") String status);
// SELECT o.*, c.* FROM orders o JOIN customers c ON c.id = o.customer_id
// WHERE o.status = ?
// → 1 query, all data
Fix 2: EntityGraph (declarative, reusable)
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode(value = "items", subgraph = "items-with-product")
},
subgraphs = @NamedSubgraph(
name = "items-with-product",
attributeNodes = @NamedAttributeNode("product")
)
)
@Entity
public class Order { ... }
// In the repository — apply the graph to a specific query only
@EntityGraph("Order.withCustomerAndItems")
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findByIdWithDetails(@Param("id") Long id);
Fix 3: Batch size (global fallback for collections)
# application.properties — Hibernate fetches lazy collections in batches
# Instead of 50 queries (one per order), Hibernate does ceiling(50/20) = 3 queries
spring.jpa.properties.hibernate.default_batch_fetch_size=20
// Or per-collection for fine-grained control
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 20)
private List<OrderItem> items;
A common misconception is that switching to
FetchType.EAGER prevents N+1. It doesn't — it
makes N+1 happen at load time instead of at access time,
making it harder to notice and impossible to opt out of.
Every findAll() and every
findById() will join the eager association,
even when the caller doesn't need it. Keep everything LAZY
and use JOIN FETCH or EntityGraph for the specific queries
that need the association.
Cascade and orphanRemoval — Applied With Precision
| Cascade type | Propagates | Common use |
|---|---|---|
PERSIST | persist() | Save child when saving parent |
MERGE | merge() | Update child when updating parent |
REMOVE | remove() | Delete child when parent is deleted — use only for true parent-child |
ALL | Everything | Correct only for strictly dependent children (order items, line items, addresses) |
// orphanRemoval = true: removing an item from the collection issues a DELETE
// Without it: removing from the collection just sets the FK to NULL
Order order = orderRepo.findById(1L).orElseThrow();
order.getItems().remove(0); // with orphanRemoval=true → DELETE FROM order_items WHERE id = ?
// without orphanRemoval → UPDATE order_items SET order_id = NULL WHERE id = ?
orderRepo.save(order);
// CASCADE REMOVE + orphanRemoval decision matrix:
//
// Order → OrderItem YES: items cannot exist without the order
// Customer → Order NO: orders are financial records, retain even if customer is deleted
// Product → Category NO: category can exist independently; use FK constraint instead
Best Practices and Common Pitfalls
✅ Do
- Set
FetchType.LAZYon every relationship — override@ManyToOneand@OneToOnewhich default to EAGER - Always update the owning side of a bidirectional relationship; update both sides to keep the in-memory object graph consistent
- Use helper methods (
addItem(),removeItem()) to encapsulate both-side updates — never let callers manipulate collections directly - Choose cascade types explicitly per relationship:
{PERSIST, MERGE}is the safe default; addREMOVEonly when children cannot exist without the parent - Replace
@ManyToManywith an intermediate entity — you will need extra columns eventually, and the migration is painful - Always implement
equals()andhashCode()on@Embeddablecomposite keys — JPA requires it for correct collection behaviour
❌ Don't
- Don't use
FetchType.EAGERto "fix" aLazyInitializationException— use JOIN FETCH or@EntityGraphfor the specific query that needs the data - Don't write
cascade = CascadeType.ALL, orphanRemoval = trueby reflex — ask whether deleting the parent should delete the children; if unsure, the answer is no - Don't use
mappedBywith the column name ("customer_id") instead of the field name ("customer") — it will throw a mapping exception at startup - Don't use a unidirectional
@OneToManywithout@JoinColumn— JPA generates an extra join table where no join table should exist - Don't rely on
FetchType.LAZYon the inverse side of a@OneToOnewithout bytecode enhancement — it silently loads eagerly
Interview Questions
Q: What is the owning side of a relationship in JPA, and why does it matter?
The owning side is the entity that holds the foreign key column
in the database — typically the @ManyToOne side.
It matters because JPA only looks at the owning side to generate
SQL. If you update only the inverse side (the one with
mappedBy), the change will not be written to the
database. The correct approach is to always update the owning
side, and optionally also update the inverse side collection to
keep the in-memory object graph consistent.
Q: What is orphanRemoval = true and when should you use it?
orphanRemoval = true means that when a child entity
is removed from its parent's collection, JPA issues a DELETE for
that child. Without it, removing from the collection only sets
the foreign key to null. Use it when children are strictly
dependent on the parent and cannot exist independently — order
items that belong to an order are the canonical example. Don't
use it on relationships where the child has independent meaning,
such as orders belonging to a customer.
Q: What is the N+1 problem and how do you detect it?
N+1 happens when loading N entities triggers N additional queries
to load a lazy association — one per entity instead of a single
JOIN. Detection: enable SQL logging
(spring.jpa.show-sql=true and
logging.level.org.hibernate.type=TRACE) and look for
repeated identical queries differing only in the ID parameter.
Fix: use JOIN FETCH or @EntityGraph on the query
that needs the association.
Q: You have a Customer entity with
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
on the orders collection. A customer is deleted.
What happens, and is this correct?
All of the customer's orders are deleted — cascade REMOVE
propagates to every order in the collection, and those orders
cascade further to their items if that relationship also has
cascade REMOVE. This is almost certainly not correct for an
e-commerce application. Orders are financial records that must
be retained for accounting, auditing, and regulatory compliance,
regardless of whether the customer account still exists. The
correct cascade configuration for this relationship is
{PERSIST, MERGE} only. The customer's deletion
should either be blocked by a database FK constraint (if orders
still exist), or handled by a soft-delete pattern
(deleted_at TIMESTAMPTZ) that never actually removes
the row. CascadeType.ALL is correct only for
strictly dependent children — those that have no business meaning
outside the parent, like OrderItem relative to
Order.
Q: You declare @OneToOne(fetch = FetchType.LAZY)
on the inverse side of a bidirectional relationship. In
production, you observe that the association always loads
eagerly despite the declaration. Why?
For the inverse side of a @OneToOne, Hibernate
cannot use a proxy without knowing whether the associated row
exists. A proxy object would need to return null if no row
exists, but a null proxy is not the same as a null reference —
Hibernate can't distinguish "no associated entity" from "entity
exists but not yet loaded" without querying. The result: the
inverse side loads eagerly to determine existence, ignoring the
LAZY declaration. Solutions: use bytecode
enhancement via hibernate-enhance-maven-plugin
with enableLazyInitialization = true, which
instruments the proxy to handle nullable associations correctly;
or restructure to use @MapsId (shared primary key),
where Hibernate knows the FK equals the PK and can create a proxy
unconditionally.
Q: Why should you replace @ManyToMany with an intermediate entity in almost every production use case?
Pure @ManyToMany generates a join table with only
two foreign key columns. In practice, the join always ends up
needing additional data — a quantity, a price at purchase time,
a timestamp, a status. Adding columns to a pure
@ManyToMany join table requires converting it to an
intermediate entity anyway, and that migration is disruptive
because all the application code referencing the collection must
change. Starting with an intermediate entity costs one extra class
and provides a place for any future data on the join, clean
ownership semantics, explicit composite key management, and the
ability to query the join table as a first-class entity in JPQL.
In the e-commerce domain specifically:
Order ↔ Product via OrderItem with
quantity and unitPrice is the textbook
example — a pure @ManyToMany would make it
impossible to store what was actually purchased and at what
price.