Entity Relationships

How JPA maps foreign keys to Java objects — the ownership rules, the cascade traps, and the N+1 problem that will hit you in production

← Back to Index

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
}
AnnotationCardinalityE-commerce exampleDefault fetch
@ManyToOneMany → OneOrder → CustomerEAGER (override to LAZY)
@OneToManyOne → ManyCustomer → OrdersLAZY
@OneToOneOne → OneCustomer → ShippingAddressEAGER (override to LAZY)
@ManyToManyMany → ManyOrder ↔ 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
The rule: 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 correct

The 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 enhancement

FetchType.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;
EAGER fetch does not solve N+1 — it makes it worse

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 typePropagatesCommon use
PERSISTpersist()Save child when saving parent
MERGEmerge()Update child when updating parent
REMOVEremove()Delete child when parent is deleted — use only for true parent-child
ALLEverythingCorrect 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.LAZY on every relationship — override @ManyToOne and @OneToOne which 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; add REMOVE only when children cannot exist without the parent
  • Replace @ManyToMany with an intermediate entity — you will need extra columns eventually, and the migration is painful
  • Always implement equals() and hashCode() on @Embeddable composite keys — JPA requires it for correct collection behaviour

❌ Don't

  • Don't use FetchType.EAGER to "fix" a LazyInitializationException — use JOIN FETCH or @EntityGraph for the specific query that needs the data
  • Don't write cascade = CascadeType.ALL, orphanRemoval = true by reflex — ask whether deleting the parent should delete the children; if unsure, the answer is no
  • Don't use mappedBy with the column name ("customer_id") instead of the field name ("customer") — it will throw a mapping exception at startup
  • Don't use a unidirectional @OneToMany without @JoinColumn — JPA generates an extra join table where no join table should exist
  • Don't rely on FetchType.LAZY on the inverse side of a @OneToOne without bytecode enhancement — it silently loads eagerly

Interview Questions

🎓 Junior level

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.

🔥 Senior level

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.