Back to Entity Mapping: Relationships
Q1Question 1 of 5

How do you connect two tables with @OneToMany?

Key Concepts
  • @ManyToOne on the child entity is the owning side that controls the foreign key column in the database
  • @OneToMany(mappedBy) on the parent entity is the inverse side used for navigation only
  • @JoinColumn(name = "account_id") specifies which FK column in the child table references the parent
  • mappedBy = "account" tells JPA that the Trade.account field owns the relationship
  • Always synchronize both sides with helper methods: addTrade() sets both the list and the FK
  • Setting only the inverse side (account.getTrades().add(trade)) does NOT set the FK in the database
  • Unidirectional @OneToMany without @ManyToOne creates an unnecessary join table – avoid it

Definition

@ManyToOne on the child with @JoinColumn is the owning side that controls the FK; @OneToMany(mappedBy) on the parent is the inverse side for navigation only – always set both sides with helper methods.
  • @ManyToOne – placed on the child entity (Trade). Points to the parent (Account). This is the owning side that controls the FK column.
  • @JoinColumn – specifies the FK column name in the child table: @JoinColumn(name = "account_id").
  • @OneToMany(mappedBy) – placed on the parent entity (Account). Creates the inverse side for navigation. mappedBy = "account" tells JPA the Trade.account field owns the relationship.
  • Owning side – the side that controls SQL generation. Only changes to the owning side are persisted.
  • Helper methods – addTrade() and removeTrade() synchronize both sides of the bidirectional relationship.

At National Bank of India, Account (parent) has many Trades (children). In the database, the trades table has an account_id FK column pointing to accounts. In Java, Trade has @ManyToOne Account account (the trade knows which account it belongs to – owning side). Account has @OneToMany List trades (the account can see its trades – inverse side). mappedBy tells JPA: "Trade.account is the real owner – I am just the mirror."

Understanding the Concepts

What is the owning side vs inverse side?

The owning side is the entity that has the FK column in its database table. In a one-to-many relationship between Account and Trade, the trades table has the account_id FK column. Therefore Trade is the owning side. JPA reads only the owning side for SQL generation.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

// CHILD (Trade): OWNING SIDE – has the FK column
@Entity
@Table(name = "trades")
public class Trade {
    @Id
    @Column(name = "trade_id")
    private String tradeId;

    @ManyToOne(fetch = FetchType.LAZY)  // Many trades belong to ONE account
    @JoinColumn(name = "account_id", nullable = false)  // FK column
    private Account account;

    private String symbol;

    @Column(precision = 15, scale = 2)
    private BigDecimal amount;

    // getters, setters
}
JAVA
package com.bank.model;

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

// PARENT (Account): INVERSE SIDE – no FK column
@Entity
@Table(name = "accounts")
public class Account {
    @Id
    @Column(name = "account_id")
    private String accountId;

    @OneToMany(mappedBy = "account", cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<Trade> trades = new ArrayList<>();
    // mappedBy = "account" → Trade.account field owns this relationship
    // cascade = ALL → persist/merge/remove propagate to trades
    // orphanRemoval = true → removing trade from list deletes it

    @Column(name = "holder_name")
    private String holderName;

    @Column(precision = 15, scale = 2)
    private BigDecimal balance;

    // getters, setters
}

Why are helper methods critical?

Helper methods synchronize both sides of the bidirectional relationship. Without them, the FK column can be NULL because JPA only reads the owning side.

JAVA
package com.bank.model;

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

@Entity
@Table(name = "accounts")
public class Account {
    @Id
    @Column(name = "account_id")
    private String accountId;

    @OneToMany(mappedBy = "account", cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<Trade> trades = new ArrayList<>();

    private String holderName;
    private BigDecimal balance;

    // HELPER: synchronize BOTH sides
    public void addTrade(Trade trade) {
        trades.add(trade);
        trade.setAccount(this);  // set the owning side!
    }

    public void removeTrade(Trade trade) {
        trades.remove(trade);
        trade.setAccount(null);  // clear the owning side!
    }
}
JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.model.Trade;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class TradeCreationService {

    private final AccountRepository accountRepo;

    public TradeCreationService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    @Transactional
    public void addTradeToAccount(String accountId) {
        Account account = accountRepo.findById(accountId).orElseThrow();

        Trade trade = new Trade();
        trade.setTradeId("TRD-099");
        trade.setSymbol("RELIANCE");
        trade.setAmount(new BigDecimal("245050.00"));

        // CORRECT: use helper that sets BOTH sides
        account.addTrade(trade);
        // trade.setAccount(account) called inside helper
        // INSERT INTO trades (..., account_id) VALUES (..., 'SAV2001')
    }
}

What happens if you only set one side?

Setting only the inverse side (parent's list) without setting the owning side (child's FK) results in a NULL FK or a constraint violation.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.model.Trade;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class TradeMistakeService {

    private final AccountRepository accountRepo;

    public TradeMistakeService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    @Transactional
    public void wrongApproach(String accountId) {
        Account account = accountRepo.findById(accountId).orElseThrow();
        Trade trade = new Trade();
        trade.setTradeId("TRD-100");
        trade.setSymbol("HDFC");
        trade.setAmount(new BigDecimal("150000.00"));

        // WRONG: only set the inverse side
        account.getTrades().add(trade);
        // trade.getAccount() is still null!
        // JPA reads the owning side (Trade.account) for SQL generation
        // INSERT INTO trades (..., account_id) VALUES (..., NULL)
        // Constraint violation: account_id cannot be NULL!
    }

    @Transactional
    public void correctApproach(String accountId) {
        Account account = accountRepo.findById(accountId).orElseThrow();
        Trade trade = new Trade();
        trade.setTradeId("TRD-100");
        trade.setSymbol("HDFC");
        trade.setAmount(new BigDecimal("150000.00"));

        // CORRECT: use helper that sets both sides
        account.addTrade(trade);
        // INSERT INTO trades (..., account_id) VALUES (..., 'SAV2001')
    }
}

What does @JoinColumn configure?

@JoinColumn specifies the FK column details on the owning side.

JAVA
package com.bank.model;

import jakarta.persistence.*;

@Entity
@Table(name = "trades")
public class Trade {
    @Id
    private String tradeId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(
        name = "account_id",          // FK column name in trades table
        nullable = false,              // NOT NULL constraint
        foreignKey = @ForeignKey(name = "fk_trade_account")  // custom FK name
    )
    private Account account;

    private String symbol;
    private java.math.BigDecimal amount;
}

What is unidirectional vs bidirectional?

Three patterns exist for one-to-many relationships. Bidirectional with helper methods is the recommended approach.

JAVA
// Pattern 1: UNIDIRECTIONAL @ManyToOne (Trade → Account)
// Trade has @ManyToOne Account account
// Account does NOT have @OneToMany List<Trade> trades
// Use when: you never need account.getTrades()

// Pattern 2: BIDIRECTIONAL (Trade ↔ Account) – RECOMMENDED
// Trade has @ManyToOne Account account (owning)
// Account has @OneToMany(mappedBy) List<Trade> trades (inverse)
// Use when: you need both account.getTrades() AND trade.getAccount()

// Pattern 3: UNIDIRECTIONAL @OneToMany (Account → trades)
// Account has @OneToMany List<Trade> trades (WITHOUT @ManyToOne on Trade)
// AVOID: JPA creates a join table (accounts_trades) instead of a FK!
// Less efficient, harder to understand
JAVA
package com.bank.model;

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

// AVOID: unidirectional @OneToMany without @ManyToOne
@Entity
@Table(name = "accounts")
public class AccountBad {
    @Id
    private String accountId;

    @OneToMany  // NO mappedBy, NO @ManyToOne on Trade
    private List<Trade> trades = new ArrayList<>();
    // JPA creates: accounts_trades join table!
    // INSERT INTO accounts_trades (account_id, trade_id) VALUES (?, ?)
    // Less efficient than a simple FK column
}

What are cascade and orphanRemoval?

cascade propagates operations from parent to children. orphanRemoval deletes children that are removed from the parent's collection.

JAVA
package com.bank.model;

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

@Entity
@Table(name = "accounts")
public class Account {
    @Id
    private String accountId;

    @OneToMany(mappedBy = "account",
               cascade = CascadeType.ALL,    // persist/merge/remove propagate
               orphanRemoval = true)          // remove from list = DELETE from DB
    private List<Trade> trades = new ArrayList<>();

    private String holderName;
    private java.math.BigDecimal balance;

    public void addTrade(Trade trade) {
        trades.add(trade);
        trade.setAccount(this);
    }

    public void removeTrade(Trade trade) {
        trades.remove(trade);
        trade.setAccount(null);
        // orphanRemoval: DELETE FROM trades WHERE trade_id = ?
    }
}
JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.model.Trade;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class CascadeDemo {

    private final AccountRepository accountRepo;

    public CascadeDemo(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    @Transactional
    public void cascadePersist() {
        Account account = new Account();
        account.setAccountId("SAV2001");
        account.setHolderName("Rajesh Kumar");
        account.setBalance(new BigDecimal("500000.00"));

        Trade t1 = new Trade("TRD-001", "RELIANCE", new BigDecimal("100000"));
        Trade t2 = new Trade("TRD-002", "HDFC", new BigDecimal("200000"));
        account.addTrade(t1);
        account.addTrade(t2);

        accountRepo.save(account);
        // CascadeType.ALL propagates persist:
        // INSERT INTO accounts VALUES ('SAV2001', 'Rajesh Kumar', 500000)
        // INSERT INTO trades VALUES ('TRD-001', 'RELIANCE', 100000, 'SAV2001')
        // INSERT INTO trades VALUES ('TRD-002', 'HDFC', 200000, 'SAV2001')
        // ONE save() call persists parent + all children!
    }
}

What should a senior engineer know?

Senior engineers should understand these nuances about entity relationships:

  • Fetch strategy interaction. @ManyToOne(fetch = LAZY) prevents N+1 at the entity level, but JPQL SELECT t FROM Trade t still fires N+1 if you access t.getAccount() in a loop. Use JOIN FETCH or @EntityGraph to load relationships in a single query.
  • Batch size for collections. @BatchSize(size = 50) on @OneToMany loads children in batches of 50 instead of one query per parent. This is a Hibernate-specific optimization that mitigates N+1 without changing the query.
  • equals and hashCode. In bidirectional relationships, avoid using the collection side in equals()/hashCode(). Use the business key or the database ID to prevent infinite recursion and Hibernate proxy issues.
  • Ordering collections. Use @OrderBy("amount DESC") on @OneToMany for database-level ordering or @OrderColumn for a persistent index column. Do not rely on insertion order.
  • Bidirectional toString danger. If both Account and Trade include each other in toString(), you get a StackOverflowError. Exclude the parent from the child's toString() or use @ToString.Exclude with Lombok.
JAVA
package com.bank.model;

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

@Entity
@Table(name = "trades")
public class Trade {
    @Id
    private String tradeId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "account_id")
    private Account account;

    // Use business key for equals/hashCode, NOT the collection
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Trade t)) return false;
        return tradeId != null && tradeId.equals(t.tradeId);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(tradeId);
    }
}

What are common mistakes with relationships?

The three most common mistakes are: not setting both sides, forgetting fetch = FetchType.LAZY on @ManyToOne, and using unidirectional @OneToMany without @ManyToOne.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "trades")
public class Trade {
    @Id
    private String tradeId;

    // WRONG: default EAGER on @ManyToOne
    // @ManyToOne
    // @JoinColumn(name = "account_id")
    // private Account account;
    // 200 trades = 200 Account SELECTs (N+1)

    // CORRECT: explicit LAZY
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "account_id", nullable = false)
    private Account account;

    private String symbol;
    private BigDecimal amount;
}

Visual Overview

DIAGRAM
  Bidirectional @OneToMany / @ManyToOne:

  accounts table              trades table
  ┌───────────────┐          ┌───────────────────┐
  │ account_id PK │─────────┤ trade_id    PK   │
  │ holder_name   │          │ symbol           │
  │ balance       │    ┌────┤ account_id  FK   │
  └───────────────┘    │    │ amount           │
                         │    └───────────────────┘
                         │
  Java mapping:          │
  Account (INVERSE)      │    Trade (OWNING)
  @OneToMany(mappedBy)   │    @ManyToOne + @JoinColumn
  List<Trade> trades  ───┘    Account account

  Helper method flow:
  account.addTrade(trade)
    │
    ├── trades.add(trade)      (inverse side)
    └── trade.setAccount(this)  (owning side → FK set!)

Key Takeaway

JPA connects two tables using @ManyToOne on the child entity (owning side, controls the FK column) and @OneToMany(mappedBy) on the parent entity (inverse side, navigation only). @JoinColumn(name = "account_id") specifies the FK column. mappedBy = "account" tells JPA that the Trade.account field owns the relationship. JPA reads only the owning side for SQL generation.

At National Bank of India, Trade has @ManyToOne(fetch = LAZY) Account account with @JoinColumn(name = "account_id"). Account has @OneToMany(mappedBy = "account", cascade = ALL, orphanRemoval = true). The addTrade() helper synchronizes both sides: it adds to the list (inverse) and calls trade.setAccount(this) (owning). Without the helper, the FK column is NULL because JPA only reads the owning side.

Three relationship patterns exist: unidirectional @ManyToOne (simplest, child knows parent), bidirectional @ManyToOne + @OneToMany(mappedBy) (both navigate, standard), and unidirectional @OneToMany without @ManyToOne (creates an unnecessary join table, avoid it). Always use bidirectional with helper methods for production code, and always set fetch = FetchType.LAZY on @ManyToOne to prevent N+1 queries.

Remember one sentence: the child's @ManyToOne with @JoinColumn owns the FK, the parent's @OneToMany(mappedBy) is just a mirror, and helper methods must set both sides.

---

Share
Use ← → arrow keys to navigate