Back to Entity Mapping: Relationships
Q5Question 5 of 5

Why must you synchronize both sides of a bidirectional relationship?

Key Concepts
  • In a bidirectional association, only the owning side (the FK column) is persisted; the inverse side (mappedBy) is ignored for writes.
  • Updating only the inverse side leaves the foreign key null or wrong in the database.
  • Updating only the owning side leaves the parent's in-memory collection stale within the same transaction.
  • Helper methods (addChild/removeChild) on the parent update both sides atomically to keep the object graph consistent.
  • orphanRemoval and collection-based logic depend on both sides being in sync to work correctly.
  • Return unmodifiable views of collections to prevent callers from bypassing the helper methods.
  • For @ManyToMany, synchronize both collections in the helper methods.

Definition

Only the owning side (the foreign key) is persisted; update both sides via helper methods so the in-memory object graph stays consistent with the database.
  • Owning side -- the side of a bidirectional association that holds the foreign key column, usually the @ManyToOne side.
  • Inverse side -- the mappedBy side; Hibernate ignores it when deciding what to write to the database.
  • Stale graph -- updating only one side leaves the object graph inconsistent: the database and the in-memory model disagree.
  • Helper methods -- methods like addHolding()/removeHolding() on the parent that set both sides together, keeping the graph consistent.
  • Unmodifiable views -- returning Collections.unmodifiableList(holdings) prevents callers from mutating the collection directly.

A portfolio and its holdings at National Bank of India are like a manager and their team. The relationship has two views: the manager's list of reports, and each report's "my manager" field. If you add a report to the manager's list but forget to set that report's manager field, the org chart is self-contradictory. The bank's HR system (the database) only trusts the "my manager" field (the owning side), so the manager's list can be a lie. A single HR form (helper method) updates both views at once.

Understanding the Concepts

Why does JPA have an owning side and an inverse side?

In a @OneToMany(mappedBy = "portfolio") and @ManyToOne pair, the @ManyToOne side owns the relationship because its table holds the portfolio_id foreign key column. The mappedBy side is inverse -- Hibernate ignores it when deciding what to write to the database.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Portfolio {

    @Id
    private String id;

    // INVERSE side -- mappedBy means "Holding.portfolio owns the FK"
    @OneToMany(mappedBy = "portfolio", cascade = CascadeType.ALL)
    private List<Holding> holdings = new ArrayList<>();
}

@Entity
public class Holding {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String symbol;
    private int qty;
    private BigDecimal price;

    // OWNING side -- this table has the portfolio_id FK column
    @ManyToOne
    @JoinColumn(name = "portfolio_id")
    private Portfolio portfolio;
}
TEXT
Database tables:
portfolios: id, owner_name
holdings: id, symbol, qty, price, portfolio_id (FK)

Hibernate writes the FK based on Holding.portfolio (owning side)
Hibernate IGNORES Portfolio.holdings for persistence

What goes wrong if you update only the inverse side?

If you add a Holding to the portfolio's list but never set holding.setPortfolio(portfolio), the owning side's foreign key is never set. Hibernate writes portfolio_id = null, and the holding is not linked to the portfolio in the database.

JAVA
package com.bank.service;

import com.bank.model.Holding;
import com.bank.model.Portfolio;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class BrokenInverseSideDemo {

    private final EntityManager em;

    public BrokenInverseSideDemo(EntityManager em) {
        this.em = em;
    }

    @Transactional
    public void brokenAdd() {
        Portfolio portfolio = em.find(Portfolio.class, "PF-001");
        Holding holding = new Holding("INFY", 200, new BigDecimal("1500.00"));

        // WRONG: only inverse side updated
        portfolio.getHoldings().add(holding);
        em.persist(holding);
        // holding.portfolio is null -> portfolio_id = NULL in the database!
    }
}
TEXT
Expected SQL:
INSERT INTO holdings (symbol, qty, price, portfolio_id) VALUES ('INFY', 200, 1500, NULL)
-- portfolio_id is NULL because the owning side was never set!
-- The holding exists but is not linked to any portfolio

What goes wrong if you update only the owning side?

If you set holding.setPortfolio(portfolio) but never add it to the portfolio's list, the database is correct but the in-memory parent list is stale. Within the same transaction, iterating portfolio.getHoldings() misses the new holding.

JAVA
@Transactional
public void brokenOwningSideOnly() {
    Portfolio portfolio = em.find(Portfolio.class, "PF-001");
    Holding holding = new Holding("TCS", 50, new BigDecimal("3200.00"));

    // WRONG: only owning side updated
    holding.setPortfolio(portfolio);
    em.persist(holding);
    // Database: portfolio_id = 'PF-001' -- correct!
    // In-memory: portfolio.getHoldings() does NOT include the new holding

    int count = portfolio.getHoldings().size();
    // count is WRONG -- misses the new holding until the context is refreshed
}
TEXT
Expected behavior:
- Database FK: portfolio_id = 'PF-001' (correct)
- In-memory list: portfolio.getHoldings() does NOT include the new holding
- Any logic that counts holdings or calculates totals is WRONG
- After the context closes and reloads, the holding appears -- confusing!

How do helper methods fix this?

Helper methods on the parent update both sides atomically. Callers use portfolio.addHolding(h) and both views are always consistent.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

@Entity
public class Portfolio {

    @Id
    private String id;

    @OneToMany(mappedBy = "portfolio", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Holding> holdings = new ArrayList<>();

    public void addHolding(Holding h) {
        holdings.add(h);          // inverse side (in-memory list)
        h.setPortfolio(this);     // owning side (FK) -- THIS gets persisted
    }

    public void removeHolding(Holding h) {
        holdings.remove(h);       // triggers orphanRemoval
        h.setPortfolio(null);     // clear owning side
    }

    public List<Holding> getHoldings() {
        return Collections.unmodifiableList(holdings);
    }
}
JAVA
package com.bank.service;

import com.bank.model.Holding;
import com.bank.model.Portfolio;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class CorrectRelationshipService {

    private final EntityManager em;

    public CorrectRelationshipService(EntityManager em) {
        this.em = em;
    }

    @Transactional
    public void addHoldingCorrectly() {
        Portfolio portfolio = em.find(Portfolio.class, "PF-001");
        Holding holding = new Holding("HDFC", 100, new BigDecimal("1650.00"));

        portfolio.addHolding(holding); // both sides updated!
        // FK: portfolio_id = 'PF-001' (owning side set)
        // In-memory: portfolio.getHoldings() includes the new holding
    }
}
TEXT
Expected SQL:
INSERT INTO holdings (symbol, qty, price, portfolio_id) VALUES ('HDFC', 100, 1650, 'PF-001')
-- portfolio_id is correctly set because the helper method set the owning side
-- In-memory list is consistent because the helper added to the collection

What problems does inconsistent state cause?

Beyond wrong FK values and stale collections, inconsistent state causes orphanRemoval to misfire, equals/hashCode to behave unexpectedly, and confusing bugs where the database differs from what the code sees.

TEXT
Problems from inconsistent state:
1. NULL foreign key -- holding not linked to portfolio in DB
2. Stale collection -- counts, totals, validations are wrong in the same TX
3. orphanRemoval misfire -- collection state inconsistent, wrong deletes
4. equals/hashCode issues -- Set membership misbehaves
5. Confusing reload -- after TX, reload shows different state than what code saw

How do you handle @ManyToMany synchronization?

For @ManyToMany, both sides have collections. The helper methods must update both collections.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;

@Entity
public class Fund {

    @Id
    private String id;

    @ManyToMany
    @JoinTable(name = "fund_accounts",
        joinColumns = @JoinColumn(name = "fund_id"),
        inverseJoinColumns = @JoinColumn(name = "account_id"))
    private Set<Account> accounts = new HashSet<>();

    public void addAccount(Account a) {
        accounts.add(a);        // owning side
        a.getFunds().add(this); // inverse side
    }

    public void removeAccount(Account a) {
        accounts.remove(a);
        a.getFunds().remove(this);
    }
}
TEXT
Expected behavior:
- addAccount() updates both collections
- The join table row is created based on the owning side
- Both in-memory views are consistent

What about equals and hashCode with bidirectional relationships?

Implement equals()/hashCode() on a stable business key, not the generated id. Before the id is assigned (pre-persist), two different entities would have the same hash (null), causing Set issues.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.util.Objects;

@Entity
public class Holding {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String symbol;

    @ManyToOne
    @JoinColumn(name = "portfolio_id")
    private Portfolio portfolio;

    // Use business key (symbol + portfolio) instead of generated id
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Holding h)) return false;
        return Objects.equals(symbol, h.symbol)
            && Objects.equals(portfolio, h.portfolio);
    }

    @Override
    public int hashCode() {
        return Objects.hash(symbol);
    }
}
TEXT
Best practices for equals/hashCode:
1. Use a stable business key (e.g., symbol + portfolio)
2. Do NOT use the generated id (null before persist)
3. hashCode should use only immutable fields
4. Consistent equals/hashCode prevents Set membership bugs

What should a senior engineer know?

A senior engineer makes the owning-side setter package-private and exposes only helper methods on the aggregate root. They return unmodifiable collection views. They implement equals/hashCode on a business key. They know that orphanRemoval only works correctly when the collection state is consistent.

TEXT
Senior checklist:
1. Always use helper methods for bidirectional associations
2. Make the owning-side setter package-private
3. Return unmodifiable collection views
4. Implement equals/hashCode on a business key, not the generated id
5. orphanRemoval depends on consistent collection state
6. For @ManyToMany, synchronize both collections
7. Test both add and remove paths in integration tests

Visual Overview

DIAGRAM
  Bidirectional relationship:

  Portfolio (inverse/mappedBy)    Holding (owning/FK)
  ┌──────────────────────┐      ┌──────────────────────┐
  │ List<Holding> holdings│◄────►│ Portfolio portfolio   │
  │ (in-memory view)     │      │ (portfolio_id FK)     │
  └──────────────────────┘      └──────────────────────┘
           │                              │
     Hibernate IGNORES           Hibernate WRITES FK
     for persistence             based on this field

  Helper method addHolding(h):
  ┌──────────────────────────────────────┐
  │ 1. holdings.add(h)  -- inverse side  │
  │ 2. h.setPortfolio(this) -- owning    │
  │    Both views consistent             │
  └──────────────────────────────────────┘
DIAGRAM
  What goes wrong without helper:

  Only inverse side:              Only owning side:
  portfolio.add(holding)          holding.setPortfolio(portfolio)
       │                               │
       ▼                               ▼
  DB: portfolio_id = NULL         DB: portfolio_id = correct
  Memory: list has holding        Memory: list MISSING holding
  BOTH ARE WRONG!                 DB correct, memory stale

Key Takeaway

In a bidirectional relationship, two fields model the same link, but only the owning side (the one with the foreign key column) determines what Hibernate writes to the database. The inverse side marked with mappedBy is ignored for persistence. If you update only the inverse side, the FK is null. If you update only the owning side, the database is correct but the in-memory list is stale within the same transaction.

Helper methods on the aggregate root solve both problems by updating both sides atomically. addHolding() adds to the collection (inverse side) and sets the portfolio reference (owning side). removeHolding() removes from the collection and nulls the reference. Callers use only these helpers, and the collection getter returns an unmodifiable view to prevent direct mutation.

At National Bank of India, Rajesh's portfolio service had both failure modes in production: holdings saved with a null portfolio_id, and a wrong portfolio total within a request because the list was stale. After routing every mutation through helper methods, the database foreign key and the in-memory object graph always agree. For @ManyToMany he synchronizes both collections, and he implements equals/hashCode on a stable business key rather than the generated id.

Remember one sentence: always synchronize both sides of a bidirectional relationship through helper methods on the aggregate root, because Hibernate persists only the owning side and the inverse side's list can lie.

---

Share
Use ← → arrow keys to navigate