Entity Relationships & Associations

@OneToMany & @ManyToOne

18 min Lesson 3 of 13

@OneToMany & @ManyToOne

The @OneToMany / @ManyToOne pair is the most frequently used relationship in any relational domain model. A Customer places many Orders; a Department employs many Employees; a Post has many Comments. Learning to map this relationship correctly — and to avoid the common pitfalls — will serve you in virtually every Spring Boot project you build.

The Domain Model

Throughout this lesson we will use an e-commerce domain. A Customer can place many Order objects, and every Order knows which Customer it belongs to. In the relational database this is represented by a foreign-key column customer_id on the orders table — a single column that points back to the customers table.

Mapping the Many Side: @ManyToOne

The many side — the entity that holds the foreign key — is always the owning side of the relationship. Map it with @ManyToOne plus @JoinColumn:

package com.example.shop.domain; import jakarta.persistence.*; import java.math.BigDecimal; import java.time.Instant; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private Instant placedAt = Instant.now(); @Column(nullable = false, precision = 10, scale = 2) private BigDecimal total; // Owning side — this column lives in the "orders" table @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "customer_id", nullable = false) private Customer customer; // constructors, getters, setters omitted for brevity }

Key decisions on the @ManyToOne:

  • fetch = FetchType.LAZY — do not load the Customer until it is actually accessed. This is the single most important performance setting and is covered in depth in Lesson 6. Always set it explicitly; do not rely on the default.
  • optional = false — tells Hibernate an order always has a customer, allowing it to use an inner join instead of a left outer join when queries involve this path.
  • @JoinColumn(name = "customer_id") — names the foreign-key column. Without this Hibernate would infer a name that may not match your schema conventions.
The owning side controls the foreign key. When you call entityManager.persist(order), Hibernate writes the value of customer_id based on the customer field on the Order entity — not on any collection in Customer. This is the most common source of "I added to the list but nothing was saved" bugs.

Mapping the One Side: @OneToMany

The one side is the inverse side of the relationship. It is mapped with @OneToMany(mappedBy = ...), where mappedBy refers to the field name on the owning entity:

package com.example.shop.domain; import jakarta.persistence.*; import java.util.ArrayList; import java.util.Collections; import java.util.List; @Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(nullable = false, unique = true) private String email; // Inverse side — mappedBy = the field name in Order @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true) private List<Order> orders = new ArrayList<>(); // Convenience methods keep both sides in sync public void addOrder(Order order) { orders.add(order); order.setCustomer(this); // set the owning side too } public void removeOrder(Order order) { orders.remove(order); order.setCustomer(null); } public List<Order> getOrders() { return Collections.unmodifiableList(orders); } // other getters / setters ... }

Critical points on the @OneToMany side:

  • mappedBy = "customer" is mandatory. Without it Hibernate creates a separate join table instead of using the existing foreign key — doubling the schema complexity for no reason.
  • Always initialise the collection (new ArrayList<>()). A null collection causes NullPointerException the first time Hibernate tries to initialise the proxy.
  • Convenience methods (addOrder / removeOrder) synchronise both sides within the same persistence context. Forgetting to set the owning side is the cause of phantom rows and stale in-memory state.
  • Return an unmodifiable view from the getter so callers cannot bypass the convenience methods and break the invariant.

Unidirectional vs Bidirectional

You do not always need both sides. A unidirectional mapping exposes only one navigable direction:

// Unidirectional @ManyToOne only — Order knows its Customer, // but Customer has no collection of Orders. // This is often the right choice when you never navigate // from Customer to its orders in this bounded context. @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "customer_id") private Customer customer;

A unidirectional @OneToMany without mappedBy should almost always be avoided — Hibernate will generate a join table even though a foreign key column would suffice, and it produces inefficient SQL. If you need the collection side only, still define the @ManyToOne on the child entity and use mappedBy on the parent.

Never use a unidirectional @OneToMany without mappedBy in production code. It creates a hidden join table, generates extra INSERT and DELETE statements, and wastes a database index. Always pair it with a @ManyToOne on the child and use mappedBy.

Persisting and Querying

In a typical Spring Data JPA repository pattern:

// Service layer — both sides must be set before persist @Transactional public Order placeOrder(Long customerId, BigDecimal total) { Customer customer = customerRepository.findById(customerId) .orElseThrow(() -> new EntityNotFoundException("Customer not found")); Order order = new Order(); order.setTotal(total); customer.addOrder(order); // sets order.customer AND adds to collection customerRepository.save(customer); // cascades to Order via CascadeType.ALL return order; }

Fetching orders for a customer in a repository:

// Spring Data — derived query on the owning-side field List<Order> findByCustomerId(Long customerId); // JPQL — explicit join @Query("SELECT o FROM Order o WHERE o.customer.id = :customerId ORDER BY o.placedAt DESC") List<Order> findOrdersForCustomer(@Param("customerId") Long customerId);

Performance Trade-offs to Know

The @OneToMany collection is a Hibernate collection proxy that is initialised lazily by default. This means:

  • Accessing customer.getOrders() outside an active transaction will throw a LazyInitializationException. Always load what you need inside the transaction.
  • If you load a list of customers and then access their orders one by one, you trigger the N+1 select problem — one query per customer. This is covered in Lessons 7 and 8.
  • For large collections consider whether you really need @OneToMany on the parent at all, or whether querying from the child with @ManyToOne is sufficient.
Prefer querying from the owning (@ManyToOne) side. A query like SELECT o FROM Order o WHERE o.customer.id = :id uses the indexed foreign-key column and never loads the Customer entity or its collection proxy. It is almost always faster and simpler than navigating a lazy collection.

Summary

The @ManyToOne / @OneToMany pair maps a single foreign-key column to a bidirectional object graph. The many side (@ManyToOne) is the owning side — it controls what Hibernate writes to the database. The one side (@OneToMany(mappedBy = ...)) is the inverse side — it provides convenient navigation but has no effect on persistence unless you also set the owning side. Always use fetch = FetchType.LAZY on @ManyToOne, always initialise your collections, and always write convenience methods that keep both sides in sync. The next lesson covers the other side of the cardinality spectrum: @ManyToMany.