Part 3: Relationships - One-To-One and One-To-Many
Navigation: Index | Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6
Table of Contents
- Understanding JPA Relationships
- One-To-One: The Basics
- One-To-One: Foreign Key Approach (Most Common)
- One-To-One: Shared Primary Key Approach
- One-To-One: Bidirectional
- One-To-Many and Many-To-One: The Basics
- Bidirectional One-To-Many (The Right Way)
- Cascade Types Deep Dive
- Fetch Types: LAZY vs EAGER
- The N+1 Problem: Deep Dive
- N+1 Solutions: JOIN FETCH
- N+1 Solutions: EntityGraph
- N+1 Solutions: Batch Fetching
- List vs Set vs Collection in Relationships
- Helper Methods for Bidirectional Consistency
- When to Use Which Approach
- Tips and Best Practices
- Anti-Patterns and Pitfalls
1. Understanding JPA Relationships
Owning Side vs Inverse Side
This is the most fundamentally misunderstood concept in JPA. Get this right and everything else falls into place.
In every JPA relationship, one side owns the relationship. The owning side is the one that holds the foreign key column in the database.
OWNING SIDE: The entity whose table has the foreign key column.
JPA reads and writes the FK based on this side.
Determined by: @JoinColumn (on owning side)
INVERSE SIDE: The entity that references back without holding the FK.
Determined by: mappedBy attribute
Changes to the inverse side are IGNORED by JPA.
This means: if you set a relationship only on the inverse side, it will NOT be persisted. You MUST set it on the owning side.
Cascade: Propagate Operations from Parent to Child
When you do something to a parent entity, cascade determines what happens to its children automatically.
mappedBy: Declaring the Inverse Side
mappedBy = "fieldName" tells JPA:
- "This is the inverse side of the relationship"
- "The foreign key is managed by the
fieldNamefield on the OTHER entity" - "Do not create a join table or manage FK from this side"
2. One-To-One: The Basics
A one-to-one relationship means: one row in table A corresponds to exactly one row in table B, and vice versa.
Real-world examples:
- User and UserProfile (one user has one profile)
- Order and Payment (one order has one payment record)
- Employee and ParkingSpot (one employee has one assigned parking spot)
Database Representation
users user_profiles
+----+---------+ +----+---------+-----------+
| id | email | | id | user_id | bio |
+----+---------+ +----+---------+-----------+
| 1 | a@b.com | | 1 | 1 | Developer |
| 2 | c@d.com | | 2 | 2 | Designer |
+----+---------+ +----+---------+-----------+
^
FK: user_profiles.user_id -> users.id
UNIQUE constraint enforces one-to-one
3. One-To-One: Foreign Key Approach (Most Common)
The child table holds a foreign key pointing to the parent. This is the most common and recommended approach.
User Entity (Parent, Inverse Side)
package com.company.ecommerce.domain.user;
import com.company.ecommerce.common.audit.AuditableEntity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(
name = "users",
uniqueConstraints = {
@UniqueConstraint(name = "uk_users_email", columnNames = "email"),
@UniqueConstraint(name = "uk_users_username", columnNames = "username")
}
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = {"profile", "addresses", "orders"})
public class User extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email", nullable = false, unique = true, length = 255)
private String email;
@Column(name = "username", nullable = false, unique = true, length = 100)
private String username;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private UserStatus status = UserStatus.ACTIVE;
/*
* mappedBy = "user": The 'profile' field is the INVERSE side.
* The 'user' field in UserProfile is the OWNING side (it has the FK).
*
* cascade = CascadeType.ALL: When you save/delete a User,
* automatically save/delete the UserProfile.
*
* fetch = FetchType.LAZY: Do NOT load UserProfile unless explicitly accessed.
* This is the correct default. Loading the profile on every User fetch is wasteful.
*
* optional = true: User can exist without a profile. Set to false if mandatory.
*/
@OneToOne(
mappedBy = "user",
cascade = CascadeType.ALL,
fetch = FetchType.LAZY,
optional = true
)
private UserProfile profile;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User user)) return false;
return id != null && id.equals(user.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}UserProfile Entity (Child, Owning Side - has the FK)
package com.company.ecommerce.domain.user;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDate;
@Entity
@Table(
name = "user_profiles",
uniqueConstraints = {
// UNIQUE enforces the one-to-one at the database level
@UniqueConstraint(name = "uk_user_profiles_user_id", columnNames = "user_id")
}
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = "user")
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/*
* This is the OWNING side of the relationship.
* @JoinColumn defines the foreign key column in user_profiles table.
*
* name = "user_id": The FK column is called user_id in user_profiles table.
* nullable = false: Every profile MUST belong to a user.
* unique = true: One user can only have one profile. (Also enforced by UNIQUE constraint above)
* foreignKey: Custom constraint name for the FK (better for debugging than Hibernate's generated name)
*/
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(
name = "user_id",
nullable = false,
unique = true,
foreignKey = @ForeignKey(name = "fk_user_profiles_user_id")
)
private User user;
@Column(name = "first_name", length = 100)
private String firstName;
@Column(name = "last_name", length = 100)
private String lastName;
@Column(name = "bio", columnDefinition = "TEXT")
private String bio;
@Column(name = "avatar_url", length = 500)
private String avatarUrl;
@Column(name = "date_of_birth")
private LocalDate dateOfBirth;
@Column(name = "phone_number", length = 20)
private String phoneNumber;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof UserProfile up)) return false;
return id != null && id.equals(up.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}Service Layer Usage
@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
private final UserRepository userRepository;
/*
* Creating a User with a UserProfile in one transaction.
* Because of cascade = CascadeType.ALL, saving the User also saves the Profile.
* But we MUST set both sides of the bidirectional relationship.
*/
public User createUserWithProfile(CreateUserRequest request) {
User user = User.builder()
.email(request.getEmail())
.username(request.getUsername())
.passwordHash(passwordEncoder.encode(request.getPassword()))
.build();
UserProfile profile = UserProfile.builder()
.firstName(request.getFirstName())
.lastName(request.getLastName())
.user(user) // Set the OWNING side (FK side)
.build();
user.setProfile(profile); // Set the INVERSE side (for in-memory consistency)
// Saving user cascades to profile because of CascadeType.ALL
return userRepository.save(user);
}
/*
* Fetching user WITHOUT profile - efficient, no join needed
*/
@Transactional(readOnly = true)
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found: " + id));
}
/*
* Fetching user WITH profile - explicit, no surprises
* The JPQL JOIN FETCH loads both in a single query.
*/
@Transactional(readOnly = true)
public User getUserWithProfile(Long id) {
return userRepository.findByIdWithProfile(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found: " + id));
}
}Repository with Custom Fetch
package com.company.ecommerce.domain.user;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
/*
* JOIN FETCH: Loads User and UserProfile in ONE SQL query.
* Without this, accessing user.getProfile() triggers a second query.
* LEFT JOIN FETCH: Returns the user even if profile is null.
*/
@Query("SELECT u FROM User u LEFT JOIN FETCH u.profile WHERE u.id = :id")
Optional<User> findByIdWithProfile(@Param("id") Long id);
}4. One-To-One: Shared Primary Key Approach
In this approach, the child entity uses the SAME primary key as the parent. The FK IS the PK. This is more efficient (no separate ID column) but tightly couples the lifecycle of both entities.
package com.company.ecommerce.domain.user;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "user_profiles")
@Getter
@Setter
@NoArgsConstructor
public class UserProfile {
/*
* @Id without @GeneratedValue: We do NOT auto-generate this ID.
* @MapsId: Maps this entity's @Id to the FK of the @OneToOne relationship.
* The user's ID becomes both the PK of user_profiles AND the FK pointing to users.
*
* Database table: user_profiles.user_id is BOTH the PK and the FK.
*/
@Id
private Long id;
@MapsId
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "bio", columnDefinition = "TEXT")
private String bio;
// ... other fields
}MySQL DDL for shared PK:
CREATE TABLE user_profiles (
user_id BIGINT NOT NULL, -- This IS both the PK and FK
bio TEXT,
-- ...
PRIMARY KEY (user_id),
CONSTRAINT fk_user_profiles_user_id
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);When to Use Shared Primary Key
- When profile is always created with the user and has the same lifecycle
- When you want the tightest possible coupling between parent and child
- When saving space matters (no extra id column in child table)
- When you access the profile by the user's ID directly (efficient lookup)
When to Use Separate FK
- When the child can be created independently or later
- When the child might be replaced (new profile record for the same user)
- When the lifecycle of parent and child can diverge
- Most common scenario - prefer this for clarity
5. One-To-One: Bidirectional
Most one-to-one relationships in production are bidirectional, meaning you can navigate from either side. This is what we showed in Section 3 (User -> UserProfile and UserProfile -> User).
The Lazy Loading Challenge
One-to-one has a well-known Hibernate limitation: the INVERSE side (@OneToOne with mappedBy) cannot be truly lazy-loaded without bytecode enhancement. Here is why:
When Hibernate loads a User entity, it must decide whether to put NULL
or a proxy in the 'profile' field. To know which one to use, it MUST
check if a UserProfile exists for this user.
For @ManyToOne (owning side), this check is trivial: check the FK column.
For @OneToOne inverse side, Hibernate must issue a SELECT to check existence.
Result: Even with fetch = FetchType.LAZY, the inverse side may trigger
a secondary query when the parent entity is loaded.
Solutions:
Option A: Always access profile through UserProfile repository directly (most efficient)
// Instead of user.getProfile(), query directly:
Optional<UserProfile> profile = userProfileRepository.findByUserId(userId);Option B: Use bytecode enhancement (advanced, enables true lazy loading)
<!-- pom.xml: Enable Hibernate bytecode enhancement -->
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<version>6.4.0.Final</version>
<executions>
<execution>
<goals>
<goal>enhance</goal>
</goals>
<configuration>
<enableLazyInitialization>true</enableLazyInitialization>
</configuration>
</execution>
</executions>
</plugin>Option C: Use JOIN FETCH in all queries (practical and explicit)
6. One-To-Many: The Basics
A one-to-many relationship means: one row in the parent table corresponds to many rows in the child table.
Real-world examples:
- Order has many OrderItems
- User has many Addresses
- Category has many Products
- User has many Orders
Database Representation
orders order_items
+----+--------+ +----+----------+----------+----------+
| id | total | | id | order_id | product | quantity |
+----+--------+ +----+----------+----------+----------+
| 1 | 150.00 | | 1 | 1 | Phone | 1 |
| 2 | 49.99 | | 2 | 1 | Case | 2 |
+----+--------+ | 3 | 2 | Book | 1 |
+----+----------+----------+----------+
^
FK: order_items.order_id -> orders.id
The foreign key is ALWAYS on the "many" side (order_items), never on the "one" side (orders).
7. Bidirectional One-To-Many (The Right Way)
Order Entity (One Side - Inverse Side)
package com.company.ecommerce.domain.order;
import com.company.ecommerce.common.audit.AuditableEntity;
import com.company.ecommerce.domain.user.User;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(
name = "orders",
indexes = {
@Index(name = "idx_orders_customer_id", columnList = "customer_id"),
@Index(name = "idx_orders_status", columnList = "status"),
@Index(name = "idx_orders_customer_status", columnList = "customer_id, status")
}
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = {"items", "customer"})
public class Order extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_number", nullable = false, unique = true, length = 30)
private String orderNumber;
/*
* ManyToOne: Many orders belong to one customer.
* fetch = LAZY: Do NOT load the User when loading an Order.
* This is the default for @ManyToOne but always be explicit.
*/
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false,
foreignKey = @ForeignKey(name = "fk_orders_customer_id"))
private User customer;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30)
@Builder.Default
private OrderStatus status = OrderStatus.PENDING;
@Column(name = "subtotal", nullable = false, precision = 12, scale = 2)
private BigDecimal subtotal;
@Column(name = "tax_amount", nullable = false, precision = 10, scale = 2)
private BigDecimal taxAmount;
@Column(name = "total_amount", nullable = false, precision = 12, scale = 2)
private BigDecimal totalAmount;
@Column(name = "placed_at", nullable = false)
private Instant placedAt;
@Column(name = "shipped_at")
private Instant shippedAt;
@Version
@Column(name = "version", nullable = false)
private Long version;
/*
* mappedBy = "order": This is the INVERSE side.
* The 'order' field in OrderItem is the OWNING side (it has the FK).
*
* cascade = CascadeType.ALL: Operations on Order cascade to items.
* Save Order -> saves all items.
* Delete Order -> deletes all items.
*
* orphanRemoval = true: If an OrderItem is removed from this collection,
* it is automatically deleted from the database. Without this, removing from
* the list would only set order_id to NULL, leaving orphan records.
*
* fetch = FetchType.LAZY: Do NOT load items unless explicitly requested.
* This is the most critical performance setting in JPA.
*
* @Builder.Default: Without this, Lombok's @Builder sets items to null.
* We want an empty ArrayList to avoid NullPointerException when adding items.
*/
@OneToMany(
mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@Builder.Default
private List<OrderItem> items = new ArrayList<>();
// ===<mark class="obsidian-highlight"> HELPER METHODS FOR BIDIRECTIONAL CONSISTENCY </mark>=<mark class="obsidian-highlight">
// These are ESSENTIAL. JPA does not automatically sync both sides.
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this); // Set the owning side
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null); // Clear the owning side
}
// </mark>=<mark class="obsidian-highlight"> BUSINESS LOGIC </mark>===
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException(
"Only PENDING orders can be confirmed. Current status: " + this.status);
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel() {
if (this.status <mark class="obsidian-highlight"> OrderStatus.DELIVERED || this.status </mark> OrderStatus.SHIPPED) {
throw new IllegalStateException(
"Cannot cancel order in status: " + this.status);
}
this.status = OrderStatus.CANCELLED;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order order)) return false;
return id != null && id.equals(order.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}OrderItem Entity (Many Side - Owning Side - has the FK)
package com.company.ecommerce.domain.order;
import com.company.ecommerce.domain.product.Product;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
@Entity
@Table(
name = "order_items",
indexes = {
@Index(name = "idx_order_items_order_id", columnList = "order_id"),
@Index(name = "idx_order_items_product_id", columnList = "product_id")
}
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = {"order", "product"})
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/*
* This is the OWNING side of the Order-OrderItem relationship.
* The order_id column in order_items is managed from here.
*
* CRITICAL: insertable = true, updatable = false
* Once an order item belongs to an order, it should not be moved to another.
* Setting updatable = false enforces this.
*/
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(
name = "order_id",
nullable = false,
foreignKey = @ForeignKey(name = "fk_order_items_order_id")
)
private Order order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(
name = "product_id",
nullable = false,
foreignKey = @ForeignKey(name = "fk_order_items_product_id")
)
private Product product;
@Column(name = "quantity", nullable = false)
private Integer quantity;
/*
* unit_price is a SNAPSHOT of the product price at the time of ordering.
* This is intentional denormalization. Product prices change over time,
* but the order history must reflect what the customer actually paid.
*/
@Column(name = "unit_price", nullable = false, precision = 10, scale = 2)
private BigDecimal unitPrice;
@Column(name = "discount", nullable = false, precision = 5, scale = 2)
@Builder.Default
private BigDecimal discount = BigDecimal.ZERO;
public BigDecimal getLineTotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity)).subtract(discount);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderItem item)) return false;
return id != null && id.equals(item.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}Service Layer for Order Management
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
private final ProductRepository productRepository;
public Order createOrder(Long customerId, CreateOrderRequest request) {
User customer = userRepository.findById(customerId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
Order order = Order.builder()
.orderNumber(generateOrderNumber())
.customer(customer)
.placedAt(Instant.now())
.subtotal(BigDecimal.ZERO)
.taxAmount(BigDecimal.ZERO)
.totalAmount(BigDecimal.ZERO)
.build();
BigDecimal subtotal = BigDecimal.ZERO;
for (CreateOrderRequest.ItemRequest itemRequest : request.getItems()) {
Product product = productRepository.findById(itemRequest.getProductId())
.orElseThrow(() -> new ResourceNotFoundException(
"Product not found: " + itemRequest.getProductId()));
OrderItem item = OrderItem.builder()
.product(product)
.quantity(itemRequest.getQuantity())
.unitPrice(product.getPrice()) // Snapshot current price
.build();
order.addItem(item); // Uses helper method to set both sides
subtotal = subtotal.add(item.getLineTotal());
}
BigDecimal tax = subtotal.multiply(new BigDecimal("0.18"));
order.setSubtotal(subtotal);
order.setTaxAmount(tax);
order.setTotalAmount(subtotal.add(tax));
return orderRepository.save(order);
// CascadeType.ALL ensures all OrderItems are also saved
}
public void removeItemFromOrder(Long orderId, Long itemId) {
Order order = orderRepository.findByIdWithItems(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
OrderItem itemToRemove = order.getItems().stream()
.filter(item -> item.getId().equals(itemId))
.findFirst()
.orElseThrow(() -> new ResourceNotFoundException("Item not found in order"));
order.removeItem(itemToRemove);
// orphanRemoval = true means the removed OrderItem is deleted from DB
}
}8. Cascade Types
Cascade determines which operations on the parent entity are propagated to its children.
Cascade Types Reference
| CascadeType | Operation | What Happens |
|---|---|---|
| PERSIST | save() | Saving parent also saves unsaved children |
| MERGE | merge() | Merging parent also merges detached children |
| REMOVE | delete() | Deleting parent also deletes children |
| REFRESH | refresh() | Refreshing parent also refreshes children from DB |
| DETACH | detach() | Detaching parent also detaches children from session |
| ALL | All of above | All operations cascade |
Choosing the Right Cascade
// Pattern 1: Parent owns children completely (composition)
// Children cannot exist without parent
// Use: CascadeType.ALL + orphanRemoval = true
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items;
// Pattern 2: Parent manages child lifecycle but child can outlive parent
// Use: CascadeType.PERSIST, CascadeType.MERGE
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Post> posts;
// Pattern 3: Independent entities, no cascade
// User and Role: Deleting a user should NOT delete the ADMIN role
@ManyToMany
private Set<Role> roles; // NO cascadeorphanRemoval vs CascadeType.REMOVE
These are different and often confused:
// CascadeType.REMOVE: When you delete the ORDER, delete its ITEMS
// Parent is deleted -> children deleted
@OneToMany(cascade = CascadeType.REMOVE)
// orphanRemoval = true: When you REMOVE an item from the collection,
// delete that item from the database
@OneToMany(orphanRemoval = true)
// Example of orphanRemoval:
order.getItems().remove(someItem); // With orphanRemoval=true: SQL DELETE for someItem
// Without orphanRemoval: SQL UPDATE order_items SET order_id=NULL
// CascadeType.ALL includes REMOVE, but orphanRemoval handles collection removals
// Use BOTH for full lifecycle management:
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)9. Fetch Types: LAZY vs EAGER
Fetch type determines WHEN associated entities are loaded from the database.
Default Fetch Types in JPA
| Relationship | Default Fetch | Recommended |
|---|---|---|
| @OneToOne | EAGER | LAZY |
| @ManyToOne | EAGER | LAZY |
| @OneToMany | LAZY | LAZY |
| @ManyToMany | LAZY | LAZY |
The golden rule: Always use LAZY fetch type unless you have a proven, specific reason for EAGER.
Why EAGER is Dangerous
// EAGER loading: User entity with EAGER relationships
@Entity
public class User {
@OneToOne(fetch = FetchType.EAGER) // Loads UserProfile ALWAYS
private UserProfile profile;
@OneToMany(fetch = FetchType.EAGER) // Loads ALL orders ALWAYS
private List<Order> orders;
@ManyToMany(fetch = FetchType.EAGER) // Loads ALL roles ALWAYS
private Set<Role> roles;
}
// Now every single query to get a User ALSO loads:
// - The UserProfile
// - ALL Orders (which each EAGERLY load their OrderItems, which load Products...)
// - ALL Roles
// What seems like: SELECT * FROM users WHERE id = 1
// Actually becomes: JOIN to user_profiles, JOIN to orders, multiple JOINs for items...
// For a user with 500 orders: catastrophic performanceLAZY Loading in Action
// CORRECT approach with LAZY
@Entity
public class User {
@OneToOne(fetch = FetchType.LAZY, mappedBy = "user")
private UserProfile profile; // NOT loaded unless you call user.getProfile()
@OneToMany(mappedBy = "customer", fetch = FetchType.LAZY)
private List<Order> orders; // NOT loaded unless you call user.getOrders()
}
// Load only what you need:
// Use case 1: Display user list - only need id, email, username
User user = userRepository.findById(id).orElseThrow(...);
// user.profile is a proxy - NOT loaded yet. No extra query.
// user.orders is a proxy - NOT loaded yet. No extra query.
// Use case 2: User profile page - need profile data
user.getProfile(); // ONLY NOW is a query issued for UserProfile
// Use case 3: Order history page - need orders
user.getOrders(); // ONLY NOW is a query issued for OrdersThe LazyInitializationException
// This is the most common JPA error for beginners:
public User getUser(Long id) {
return userRepository.findById(id).orElseThrow(...);
// Transaction ends here in a typical @Transactional method
}
// Somewhere outside the transaction:
User user = userService.getUser(1L);
user.getProfile().getBio(); // LazyInitializationException!
// Hibernate session is CLOSED. Cannot load profile anymore.Solutions:
- Load what you need within the transaction (use JOIN FETCH)
- Use DTOs - map to DTO inside service (in transaction), return DTO
- Enable OSIV (bad practice - see Part 1 anti-patterns)
10. The N+1 Problem: Deep Dive
The N+1 problem is the most notorious performance issue in ORM-based applications. Every senior developer must understand it.
What It Is
// Example: Display list of 100 orders with their items
// Query 1: Get all orders
List<Order> orders = orderRepository.findAll(); // SELECT * FROM orders -> 100 rows
// For each order, accessing items triggers a separate query
for (Order order : orders) {
System.out.println(order.getItems().size());
// ^--- Each call: SELECT * FROM order_items WHERE order_id = ?
}
// Total queries: 1 (for orders) + 100 (one per order for items) = 101 queries
// This is "N+1": 1 query for the parent, N queries for childrenWhy It Happens
Because items is loaded LAZILY. Accessing it triggers Hibernate to issue a new SQL query to load the collection. In a loop over N records, you get N additional queries.
Detecting N+1 in Development
Method 1: Log SQL queries and count them
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACEMethod 2: Use datasource-proxy to count queries in tests
<dependency>
<groupId>net.ttddyy</groupId>
<artifactId>datasource-proxy</artifactId>
<version>1.10</version>
<scope>test</scope>
</dependency>// In test: Assert that exactly 1 query was issued
@Test
void loadingOrdersShouldNotCauseNPlusOne() {
// Using datasource-proxy to count queries
int queryCount = QueryCountHolder.getGrandTotal().getSelect();
List<OrderResponse> responses = orderService.getAllOrdersWithItems();
int newQueryCount = QueryCountHolder.getGrandTotal().getSelect();
assertThat(newQueryCount - queryCount).isEqualTo(1);
}Method 3: Hibernate statistics
spring:
jpa:
properties:
hibernate:
generate_statistics: true// In tests, assert no N+1:
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
long queryCount = stats.getQueryExecutionCount();11. N+1 Solutions: JOIN FETCH
The most direct and explicit solution. Use JPQL with JOIN FETCH to load the association in a single query.
Repository with JOIN FETCH
public interface OrderRepository extends JpaRepository<Order, Long> {
/*
* JOIN FETCH items: Loads Order AND its OrderItems in ONE SQL query.
* DISTINCT: Prevents duplicate Order results when items is a List.
* When an order has 3 items, the JOIN creates 3 rows per order.
* DISTINCT collapses them back to 1 order with 3 items.
*/
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
/*
* For loading ALL orders with items:
* WARNING: This loads ALL orders and ALL items in memory.
* Only suitable for small datasets or internal processing.
* For large datasets, use pagination.
*/
@Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items")
List<Order> findAllWithItems();
/*
* Multi-level fetch: Order -> Items -> Product
* Loads everything in one query.
* Be careful: each additional JOIN FETCH multiplies rows.
*/
@Query("""
SELECT DISTINCT o FROM Order o
LEFT JOIN FETCH o.items i
LEFT JOIN FETCH i.product
WHERE o.customer.id = :customerId
ORDER BY o.placedAt DESC
""")
List<Order> findByCustomerIdWithItemsAndProducts(@Param("customerId") Long customerId);
}JOIN FETCH with Pagination - The Hidden Problem
/*
* WARNING: You CANNOT use JOIN FETCH with pagination (Pageable) for collections.
* Hibernate will issue a WARNING and fetch ALL records in memory, then paginate.
* This defeats the purpose of pagination and causes OutOfMemoryError on large tables.
*
* The error in logs: "HHH90003004: firstResult/maxResults specified with collection fetch;
* applying in memory!"
*/
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItemsPaged(Pageable pageable); // DANGEROUS - do not use this
/*
* CORRECT approach for pagination with associations:
* Step 1: Paginate the parent IDs only (no JOIN FETCH, just IDs)
* Step 2: Load entities with JOIN FETCH using those IDs
*/Production solution for pagination with associations:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderQueryService {
private final OrderRepository orderRepository;
public Page<OrderWithItemsDto> getOrdersWithItems(Long customerId, Pageable pageable) {
// Step 1: Get paginated order IDs (no collection fetch - safe pagination)
Page<Long> orderIdsPage = orderRepository.findIdsByCustomerId(customerId, pageable);
if (orderIdsPage.isEmpty()) {
return Page.empty(pageable);
}
// Step 2: Fetch full orders with items using those IDs
List<Order> orders = orderRepository.findByIdsWithItems(orderIdsPage.getContent());
// Step 3: Map to DTO and preserve page metadata
List<OrderWithItemsDto> dtos = orders.stream()
.map(orderMapper::toDto)
.collect(Collectors.toList());
return new PageImpl<>(dtos, pageable, orderIdsPage.getTotalElements());
}
}public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o.id FROM Order o WHERE o.customer.id = :customerId ORDER BY o.placedAt DESC")
Page<Long> findIdsByCustomerId(@Param("customerId") Long customerId, Pageable pageable);
@Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findByIdsWithItems(@Param("ids") List<Long> ids);
}12. N+1 Solutions: EntityGraph
@EntityGraph allows you to define a graph of entities to eagerly load for a specific operation, without writing JPQL.
Defining an EntityGraph
@Entity
@Table(name = "orders")
/*
* Named entity graph: Define once on the entity, use anywhere.
* "Order.withItems": Load Order with items
* "Order.withItemsAndProducts": Load Order, items, and each item's product
*/
@NamedEntityGraph(
name = "Order.withItems",
attributeNodes = {
@NamedAttributeNode("items")
}
)
@NamedEntityGraph(
name = "Order.withItemsAndProducts",
attributeNodes = {
@NamedAttributeNode(value = "items", subgraph = "items-subgraph")
},
subgraphs = {
@NamedSubgraph(
name = "items-subgraph",
attributeNodes = {
@NamedAttributeNode("product")
}
)
}
)
public class Order { ... }Using EntityGraph in Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// Method-level EntityGraph using the named graph
@EntityGraph(value = "Order.withItems", type = EntityGraph.EntityGraphType.LOAD)
List<Order> findByStatus(OrderStatus status);
// Inline EntityGraph (no need to define @NamedEntityGraph on entity)
@EntityGraph(attributePaths = {"items", "items.product"})
Optional<Order> findById(Long id);
// With Pageable - EntityGraph works with pagination (unlike JOIN FETCH for collections)
@EntityGraph(attributePaths = {"items"})
Page<Order> findByCustomerId(Long customerId, Pageable pageable);
/*
* EntityGraph with pagination: Hibernate uses TWO queries:
* 1. COUNT query for total pages
* 2. Main query with the entity graph
* This is safe and correct. Unlike JOIN FETCH, no in-memory pagination.
*/
}EntityGraph Type: LOAD vs FETCH
/*
* EntityGraphType.LOAD (default): Specified attributes are EAGER.
* Unspecified use their @FetchType setting.
*
* EntityGraphType.FETCH: Specified attributes are EAGER.
* Unspecified are all LAZY regardless of mapping.
*
* Rule of thumb: Use LOAD for most cases.
*/
@EntityGraph(value = "Order.withItems", type = EntityGraph.EntityGraphType.LOAD)13. N+1 Solutions: Batch Fetching
Batch fetching is a Hibernate feature that, instead of loading one collection at a time, loads multiple collections in one query. It is the most transparent solution - you do not need to modify your queries.
Configuration-Level Batch Fetching
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25What it does:
Instead of N queries like:
SELECT * FROM order_items WHERE order_id = 1
SELECT * FROM order_items WHERE order_id = 2
SELECT * FROM order_items WHERE order_id = 3
...
SELECT * FROM order_items WHERE order_id = 100It issues batched queries like:
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, 4, ..., 25)
SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50)
SELECT * FROM order_items WHERE order_id IN (51, 52, ..., 75)
SELECT * FROM order_items WHERE order_id IN (76, 77, ..., 100)100 queries become 4 queries. Same code, much better performance.
Entity-Level Batch Fetching
@Entity
public class Order {
/*
* @BatchSize(size = 25): Load up to 25 orders' items in one query.
* This overrides the global default_batch_fetch_size for this specific collection.
*/
@BatchSize(size = 25)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
}Comparing N+1 Solutions
| Solution | Queries | Pagination Safe | Complexity | Use Case |
|---|---|---|---|---|
| JOIN FETCH | 1 | No (for collections) | Medium | Single entity or small list fetch |
| EntityGraph | 1-2 | Yes | Low-Medium | Repository-level control |
| Batch Fetch | N/batchSize | Yes | Low | Default baseline for all queries |
| DTO Projection | 1 | Yes | Medium | Read-only, reporting queries |
Industry recommendation: Enable default_batch_fetch_size: 25 globally as a baseline. Use JOIN FETCH or EntityGraph for critical code paths that load many records.
14. List vs Set vs Collection in Relationships
The collection type you choose for @OneToMany and @ManyToMany has significant performance implications.
List: Ordered, Allows Duplicates
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> items = new ArrayList<>();
// Pros: Maintains insertion order, allows index-based access
// Cons: When deleting a child, Hibernate:
// 1. Deletes ALL children (DELETE FROM order_items WHERE order_id = ?)
// 2. Re-inserts all remaining children
// This is extremely inefficient for large collections!Set: Unordered, No Duplicates
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private Set<OrderItem> items = new HashSet<>();
// Pros: Deletes target only one row at a time - much more efficient
// Prevents duplicates
// Cons: Unordered (use @OrderBy or @OrderColumn if order matters)
// Requires: equals() and hashCode() on OrderItem to work correctly
// With @OrderBy for consistent ordering:
@OrderBy("createdAt ASC")
@OneToMany(mappedBy = "order")
private Set<OrderItem> items = new LinkedHashSet<>();When to Use List vs Set
| Use Case | Collection Type | Reason |
|---|---|---|
| @OneToMany where order matters | List with @OrderBy | Maintains predictable order |
| @OneToMany where you frequently delete items | Set | Efficient single-row deletes |
| @ManyToMany | Set | Prevents duplicates, correct equals/hashCode |
| Large collections | Set | Better Hibernate delete behavior |
| Ordered data (e.g., ranked items) | List with @OrderColumn | DB-managed ordering column |
15. Helper Methods for Bidirectional Consistency
Bidirectional relationships require both sides to be in sync in memory. JPA reads from the owning side for DB operations, but your application code sees the inverse side's state.
@Entity
public class Order {
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
/*
* Always provide helper methods to manage bidirectional relationships.
* These ensure that both sides of the relationship are consistent in memory,
* not just in the database.
*
* Without addItem(), you might do:
* order.getItems().add(item); // OK for DB (since items is inverse anyway)
* // BUT item.getOrder() is null in memory -> bugs in the same transaction
*
* The helper method guarantees both sides are always consistent.
*/
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this); // Set owning side - CRITICAL for FK to be written
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
}// Similar pattern for User and Addresses
@Entity
public class User {
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Address> addresses = new ArrayList<>();
public void addAddress(Address address) {
addresses.add(address);
address.setUser(this);
}
public void removeAddress(Address address) {
addresses.remove(address);
address.setUser(null);
}
}16. When to Use Which Approach
Decision Guide for One-To-One
Question 1: Can the child exist independently?
Yes -> Use Optional @OneToOne with nullable FK in child
No -> Use non-optional @OneToOne with NOT NULL FK, consider shared PK
Question 2: Are they always loaded together?
Yes -> Consider merging into one table (avoid the join)
No -> Keep separate tables, use LAZY fetch
Question 3: Is it accessed from parent or child more often?
Parent -> Put @JoinColumn on child, @OneToOne(mappedBy) on parent
Child -> Same - FK is always on the child (dependent) side
Decision Guide for One-To-Many
Question 1: Can children exist without parent?
No -> cascade = ALL, orphanRemoval = true
Yes -> cascade = PERSIST, MERGE only (no REMOVE)
Question 2: How many children per parent on average?
< 10 -> List or Set acceptable
10-100 -> Set with batch fetching
> 100 -> Never load the full collection, use pagination queries
Question 3: Do you frequently delete individual children?
Yes -> Use Set (not List) for efficient deletes
No -> Either works
17. Tips and Best Practices
Tip 1: Always Use FetchType.LAZY
// Even @ManyToOne and @OneToOne which default to EAGER:
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)Tip 2: Always Set @JoinColumn with Explicit Names
Hibernate generates FK column names like customer_id_fk_orders which are ugly and inconsistent. Be explicit:
@JoinColumn(name = "customer_id", foreignKey = @ForeignKey(name = "fk_orders_customer_id"))Tip 3: Give Foreign Keys Meaningful Names
Custom FK names make MySQL error messages readable:
CONSTRAINT fk_orders_customer_id FOREIGN KEY (customer_id) REFERENCES users(id)
vs. the Hibernate-generated:
CONSTRAINT FK3re4vhk5c80fmvnvl32...
Tip 4: Initialize Collections in Entity Declaration
@OneToMany(mappedBy = "order")
private List<OrderItem> items = new ArrayList<>(); // Initialize here, not in constructorPrevents NullPointerException when the relationship has no children yet.
Tip 5: Use @ToString Exclusion for Relationships
@ToString(exclude = {"items", "customer"}) // Prevent recursive toStringWithout exclusion, order.toString() calls items.toString() which calls each item.toString() which calls order.toString() -> StackOverflowError.
18. Anti-Patterns and Pitfalls
Pitfall 1: Unidirectional @OneToMany (Creates Extra Join Table)
// BAD: Unidirectional @OneToMany without mappedBy
@Entity
public class Order {
@OneToMany // No mappedBy!
private List<OrderItem> items;
}
/*
* JPA sees no FK column to use, so it creates a JOIN TABLE:
* CREATE TABLE orders_items (order_id BIGINT, items_id BIGINT)
*
* This is almost never what you want. You already have order_id in order_items.
* This wastes storage and causes confusing SQL.
*
* FIX: Always use bidirectional with mappedBy, or unidirectional @ManyToOne on OrderItem.
*/Pitfall 2: Calling List.clear() with CascadeType.ALL + orphanRemoval
// This seems harmless but is destructive:
order.getItems().clear();
// With cascade=ALL, orphanRemoval=true:
// Hibernate deletes ALL order items from the database!
// This is correct behavior but often surprises developers.
// Only do this if you intentionally want to delete all items.Pitfall 3: Both Sides Have CascadeType.REMOVE in Bidirectional
// DANGEROUS
@Entity
public class User {
@OneToMany(cascade = CascadeType.REMOVE, mappedBy = "user")
private List<Order> orders;
}
@Entity
public class Order {
@ManyToOne
private User customer;
// If someone deletes an Order, and cascade propagates to User...
// which cascades REMOVE back to Orders...
// you can accidentally delete everything in a cascade chain
}
// RULE: Only put cascade operations on the PARENT side, never on @ManyToOnePitfall 4: Not Using Helper Methods - Out-Of-Sync Bidirectional
// Without helper methods:
OrderItem item = new OrderItem(...);
order.getItems().add(item);
// item.order is still NULL in memory
orderRepository.save(order); // Fails! OrderItem.order_id is null -> NOT NULL violation
// OR: If items is the inverse side (with mappedBy), the add() is IGNORED by JPA entirely.
// The item's order_id never gets set.
// CORRECT: Always use the owning side:
item.setOrder(order); // This sets the FK
order.getItems().add(item); // This is for in-memory consistency onlyContinue to Part 4: Relationships - Many-To-One and Many-To-Many