← Back to Articles
6/6/2026Admin Post

springboot jpa part3 relationships oto otm

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

  1. Understanding JPA Relationships
  2. One-To-One: The Basics
  3. One-To-One: Foreign Key Approach (Most Common)
  4. One-To-One: Shared Primary Key Approach
  5. One-To-One: Bidirectional
  6. One-To-Many and Many-To-One: The Basics
  7. Bidirectional One-To-Many (The Right Way)
  8. Cascade Types Deep Dive
  9. Fetch Types: LAZY vs EAGER
  10. The N+1 Problem: Deep Dive
  11. N+1 Solutions: JOIN FETCH
  12. N+1 Solutions: EntityGraph
  13. N+1 Solutions: Batch Fetching
  14. List vs Set vs Collection in Relationships
  15. Helper Methods for Bidirectional Consistency
  16. When to Use Which Approach
  17. Tips and Best Practices
  18. 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 fieldName field 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

CascadeTypeOperationWhat Happens
PERSISTsave()Saving parent also saves unsaved children
MERGEmerge()Merging parent also merges detached children
REMOVEdelete()Deleting parent also deletes children
REFRESHrefresh()Refreshing parent also refreshes children from DB
DETACHdetach()Detaching parent also detaches children from session
ALLAll of aboveAll 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 cascade

orphanRemoval 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

RelationshipDefault FetchRecommended
@OneToOneEAGERLAZY
@ManyToOneEAGERLAZY
@OneToManyLAZYLAZY
@ManyToManyLAZYLAZY

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 performance

LAZY 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 Orders

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

  1. Load what you need within the transaction (use JOIN FETCH)
  2. Use DTOs - map to DTO inside service (in transaction), return DTO
  3. 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 children

Why 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: TRACE

Method 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: 25

What 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 = 100

It 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

SolutionQueriesPagination SafeComplexityUse Case
JOIN FETCH1No (for collections)MediumSingle entity or small list fetch
EntityGraph1-2YesLow-MediumRepository-level control
Batch FetchN/batchSizeYesLowDefault baseline for all queries
DTO Projection1YesMediumRead-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 CaseCollection TypeReason
@OneToMany where order mattersList with @OrderByMaintains predictable order
@OneToMany where you frequently delete itemsSetEfficient single-row deletes
@ManyToManySetPrevents duplicates, correct equals/hashCode
Large collectionsSetBetter Hibernate delete behavior
Ordered data (e.g., ranked items)List with @OrderColumnDB-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 constructor

Prevents NullPointerException when the relationship has no children yet.

Tip 5: Use @ToString Exclusion for Relationships

@ToString(exclude = {"items", "customer"})  // Prevent recursive toString

Without 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 @ManyToOne

Pitfall 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 only

Continue to Part 4: Relationships - Many-To-One and Many-To-Many