JPA Entity Relationships in Spring Boot
JPA entity relationships describe how your entities point to each other: an order belongs to a customer, an order holds several lines, and a product can sit in many categories. In the database these links are foreign keys and join tables. In Spring Boot you describe them with a few annotations, and Hibernate writes the SQL. Getting them right decides how many queries run, what is saved or deleted together with a parent, and whether your API fails at runtime.
This tutorial covers the four relationship annotations, the owning side and mappedBy, unidirectional and bidirectional mappings, cascading and orphan removal, lazy and eager loading, and a complete order example on Spring Boot 4.1 with Hibernate 7. It ends with the pitfalls that catch most teams and how to test your mappings with @DataJpaTest.
1. What Are JPA Entity Relationships?
An entity relationship (also called an association) is a field in one entity whose type is another entity, or a collection of them. Instead of storing a raw customerId number, a PurchaseOrder holds a Customer object. JPA maps that field to a foreign key column or a join table, so you work with objects and the database still stores plain keys.
There are four kinds of relationship. The name describes the cardinality: how many rows on each side can be linked.
| Annotation | Meaning | Shop example | Stored in the database as |
|---|---|---|---|
@ManyToOne | Many rows point to one row | Many orders belong to one customer | A foreign key column in this entity's table |
@OneToMany | One row has many rows | One order has many order lines | The foreign key in the other table |
@OneToOne | Each row has at most one partner | A customer has one profile | A foreign key, or a shared primary key |
@ManyToMany | Rows on both sides link freely | Products and categories | A separate join table with two foreign keys |
@ManyToOne and @OneToMany are two views of the same foreign key. An order_lines.order_id column can be seen from the line ("my order") or from the order ("my lines"). You can map one view or both, as section 6 explains.
If entities, repositories and the persistence context are new to you, read the Spring Data JPA overview and the JPA and Hibernate tutorial first. This page builds on both.
2. Why Relationship Mapping Matters
You could store plain ids and write every join by hand. Mapping the relationship gives you four things:
- Navigation in code.
order.getCustomer().getName()works without writing SQL. - Saving related data together. With cascading, saving an order also saves its lines.
- Integrity. The mapping defines foreign key constraints (generated by Hibernate or written in your migrations), so an order line cannot point to a missing order.
- Queries across entities. JPQL can say
join o.customerinstead of matching id columns.
Every mapping is also a decision about when data is loaded and what is saved or deleted with it. Wrong choices show up later as extra queries, huge object graphs or rows deleted by accident.
3. How JPA Manages Relationships: Owning Side and mappedBy
Hibernate keeps every entity it loads or saves in the persistence context, a per-transaction cache of managed objects. When the transaction flushes, Hibernate compares each managed entity with the snapshot it took when loading it (this is called dirty checking) and writes SQL for whatever changed. Relationships follow one extra rule: only the owning side is written.
- The owning side is the field whose mapping holds the foreign key, through
@JoinColumnor@JoinTable. Changing it changes the database. - The inverse side is marked with
mappedBy, which names the owning field on the other entity. It is a read-only mirror. Hibernate fills it when loading, and ignores it when writing. - In a
@ManyToOne/@OneToManypair, the@ManyToOneside is always the owner, because the foreign key lives in the "many" table. That is why@ManyToOnehas nomappedByattribute.
OrderLine.order) writes the order_id foreign key. The lines collection is filled by reading that column back.The practical consequence: if you add a line to order.lines but never set line.order, Hibernate has nothing to write into order_id. Because that column is NOT NULL in our mapping, the insert fails. If the column allowed nulls, you would silently get an orphaned row. The fix is a pair of helper methods that always update both sides, shown in section 5.2.
4. Setup
Create a project with Spring Initializr or add these dependencies to pom.xml. The example uses the H2 in-memory database so it runs without any installation.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
spring-boot-starter-webmvc. The old spring-boot-starter-web still works but is deprecated. Boot 4 also splits test support into per-technology modules, so @DataJpaTest now comes from spring-boot-starter-data-jpa-test. On Spring Boot 3.x, use spring-boot-starter-web and spring-boot-starter-test instead. The relationship annotations themselves (jakarta.persistence.*) are the same in both versions.Then configure the database and SQL logging in application.properties:
spring.application.name=shop
spring.datasource.url=jdbc:h2:mem:shop
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.defer-datasource-initialization=true
spring.jpa.open-in-view=false
logging.level.org.hibernate.SQL=debug
ddl-auto=create-droplets Hibernate create the tables from your entities at startup. It is fine for learning. In production, manage the schema with database migrations using Flyway.defer-datasource-initialization=truerunsdata.sqlafter Hibernate has created the tables.open-in-view=falsestops Spring from keeping the database session open while the web response is written. Section 10.1 explains why that matters.logging.level.org.hibernate.SQL=debugprints every SQL statement, which is the best way to see what your mappings really do.
5. Relationship Annotations
Each subsection shows one mapping from the shop example. The full classes are in section 9.
5.1 @ManyToOne
Use @ManyToOne on the "many" side, the entity whose table holds the foreign key. Many orders belong to one customer, so PurchaseOrder gets the field:
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;
@JoinColumn(name = "customer_id")names the foreign key column inpurchase_orders.fetch = FetchType.LAZYoverrides the default. A@ManyToOneis eager by default, which means the customer would be loaded every time an order is loaded, whether you need it or not.optional = falsesays every order must have a customer. Hibernate makes the columnNOT NULLwhen it generates the schema and can use an inner join when it fetches the customer.
5.2 @OneToMany
The inverse view of the same foreign key lives on PurchaseOrder. An order owns its lines, so it also cascades saves to them and deletes lines that are removed from the list:
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
// Keeps both sides of the relationship in sync
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public void removeLine(OrderLine line) {
lines.remove(line);
line.setOrder(null);
}
The owning side is the order field in OrderLine:
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id")
private PurchaseOrder order;
mappedBy = "order"must match the field name inOrderLine, not the column name.addLineandremoveLineare the only way to change the list.getLines()returns a read-only copy, so no caller can update one side and forget the other.- Without
mappedBy(and without a@JoinColumn), Hibernate treats a@OneToManyas a separate, unidirectional relationship and creates an extra join table for it. That is rarely what you want.
5.3 @OneToOne
A customer has exactly one profile with extra details. The cleanest mapping puts the relationship on the profile and lets the profile share the customer's primary key with @MapsId:
@Id
private Long id; // same value as the customer's id
@OneToOne(fetch = FetchType.LAZY, optional = false)
@MapsId
@JoinColumn(name = "customer_id")
private Customer customer;
The customer_profiles table gets one column, customer_id, that is both its primary key and a foreign key to customers. There is no separate id to keep in sync, and you load a profile with customerProfileRepository.findById(customerId).
Notice that Customer has no profile field. An inverse @OneToOne (with mappedBy) cannot be loaded lazily in Hibernate unless you enable bytecode enhancement, because Hibernate must check the other table to know whether the field should be null or a proxy. Leaving it out avoids an extra query every time a customer is loaded.
5.4 @ManyToMany
A product can be in several categories and a category holds many products. The link is stored in a join table, which @JoinTable names:
@ManyToMany
@JoinTable(
name = "product_categories",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "category_id"))
private Set<Category> categories = new HashSet<>();
joinColumnsis the column pointing back to this entity, andinverseJoinColumnspoints to the other one.- Use a
Set, not aList. Section 10.4 shows the extra SQL aListproduces. - Because the categories are in a
Set,CategoryneedsequalsandhashCodethat stay stable before and after it is saved. The pattern in section 9 compares ids and returns a constant hash code. - No cascade. Deleting a product must never delete a category that other products still use.
5.5 Many-to-Many with Extra Columns: Use a Join Entity
Orders and products are also a many-to-many pair: an order contains many products, and a product appears in many orders. But this link carries data of its own, the quantity and the price paid. A plain @ManyToMany join table cannot hold extra columns, so the link becomes an entity with two @ManyToOne fields:
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id")
private PurchaseOrder order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id")
private Product product;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal unitPrice;
Links tend to gain data over time, so many teams start with a join entity from day one. OrderLine uses its own generated id. A composite key built with @EmbeddedId and @MapsId is the alternative when the pair of foreign keys should be the primary key.
5.6 Attribute Reference
| Attribute | Used on | Default | What it does |
|---|---|---|---|
fetch | All four | EAGER for @ManyToOne and @OneToOne; LAZY for @OneToMany and @ManyToMany | When the related data is loaded |
cascade | All four | None | Which operations (save, merge, delete, ...) pass from this entity to the related one |
mappedBy | @OneToMany, @OneToOne, @ManyToMany | Not set (this side owns) | Marks the inverse side and names the owning field |
optional | @ManyToOne, @OneToOne | true | false means the relationship must always be set |
orphanRemoval | @OneToMany, @OneToOne | false | Deletes a child row when it is removed from the parent |
@JoinColumn(name) | Owning side | Derived from the field and key names | Names the foreign key column |
@JoinTable(name, ...) | Owning side of @ManyToMany | Derived from the entity names | Names the join table and its two columns |
@MapsId | @ManyToOne, @OneToOne | Not used | Uses the related entity's id as this entity's primary key |
6. Unidirectional vs Bidirectional Relationships
A unidirectional relationship is mapped on one entity only. A bidirectional one is mapped on both, with mappedBy on the inverse side. The database table is the same in both cases. The difference is only in your Java code.
| Unidirectional | Bidirectional | |
|---|---|---|
| Mapped on | One entity | Both entities |
| Navigation | One direction only | Both directions |
| Code to keep in sync | None | Helper methods that set both sides |
| Risk | Low | Out-of-sync sides, JSON loops, large collections |
| Example on this page | PurchaseOrder.customer, Product.categories | PurchaseOrder.lines and OrderLine.order |
Start with a unidirectional @ManyToOne. Add the inverse @OneToMany only when the parent really manages its children, as an order manages its lines, and the collection stays small. That is why Customer has no orders list: a long-time customer may have thousands of orders, and loading them all to show one is wasteful. Fetch a customer's orders with a paged repository query instead.
7. Cascade Types and orphanRemoval
Cascading passes an operation from a parent entity to its related entities. It is set per relationship with CascadeType values:
| CascadeType | Passes on | Typical use |
|---|---|---|
PERSIST | Saving a new parent saves new children | Creating an order with its lines |
MERGE | Merging a detached parent merges its children | Updating from a detached object graph |
REMOVE | Deleting the parent deletes the children | Children that cannot exist alone |
REFRESH | Reloading the parent reloads the children | Rarely needed |
DETACH | Detaching the parent detaches the children | Rarely needed |
ALL | All of the above | A parent that fully owns its children |
orphanRemoval = true is related but different. It deletes a child when the child is removed from the parent's collection, even if the parent itself stays.
| Action | CascadeType.REMOVE | orphanRemoval = true |
|---|---|---|
| Delete the order | Lines are deleted | Lines are deleted |
order.removeLine(line) | Line row is not deleted; Hibernate only tries to clear its foreign key, which fails on a NOT NULL column | Line row is deleted |
Two rules keep cascading safe:
- Cascade only from a parent to children it owns: order to lines, never order line to product.
- Never put
CascadeType.REMOVEorALLon a@ManyToOneor a@ManyToMany. The target is shared, so deleting one order line could delete a product that other orders still reference.
8. FetchType.LAZY vs FetchType.EAGER
The fetch type decides when related data is loaded. Lazy loading waits until you first touch the field. Hibernate puts a placeholder there, called a proxy for a single entity or an uninitialized collection, and runs the query on first access. Eager loading fetches the related data every time the entity is loaded.
| LAZY | EAGER | |
|---|---|---|
| Default for | @OneToMany, @ManyToMany | @ManyToOne, @OneToOne |
| When data loads | On first access, inside an open session | Always, with the owning entity |
| Main risk | LazyInitializationException, N+1 queries | Loading far more data than the use case needs |
| Can you change it per query? | Yes, with a fetch join or entity graph | Not reliably: queries still load it even when they don't need it |
The last row decides it: make every relationship lazy, then load exactly what each use case needs. To show an order with its customer, lines and products, the repository uses one JPQL query with join fetch:
@Query("""
select o from PurchaseOrder o
join fetch o.customer
left join fetch o.lines l
left join fetch l.product
where o.id = :id
""")
Optional<PurchaseOrder> findWithDetailsById(@Param("id") Long id);
Hibernate turns it into a single SQL statement with three joins:
select po1_0.id, po1_0.created_at, po1_0.customer_id, c1_0.id, c1_0.email, c1_0.name,
l1_0.order_id, l1_0.id, l1_0.product_id, p1_0.id, p1_0.name, p1_0.price, p1_0.sku,
l1_0.quantity, l1_0.unit_price, po1_0.status
from purchase_orders po1_0
join customers c1_0 on c1_0.id=po1_0.customer_id
left join order_lines l1_0 on po1_0.id=l1_0.order_id
left join products p1_0 on p1_0.id=l1_0.product_id
where po1_0.id=?
Without the fetch joins, reading the lines and then each line's product would run one extra query per line. That pattern is known as the N+1 problem. Spring Data's @EntityGraph annotation is another way to request the same joins on a repository method.
9. A Complete Example: Customers, Orders and Products
The example is a small shop API built with JPA entity relationships. A customer places an order for one or more products, and the API can read the order back and remove a line from it. It uses every mapping from section 5, and all of the code below compiles and runs on Spring Boot 4.1 with Java 21.
product_categories join table turns two one-to-many links into the products ↔ categories many-to-many.9.1 Project Structure
src/main/java/com/javabytechie/shop
├── ShopApplication.java
├── catalog
│ ├── Category.java
│ ├── Product.java
│ └── ProductRepository.java
├── common
│ ├── ApiExceptionHandler.java
│ └── ResourceNotFoundException.java
├── customer
│ ├── Customer.java
│ ├── CustomerProfile.java
│ ├── CustomerProfileRepository.java
│ └── CustomerRepository.java
└── order
├── OrderController.java
├── OrderLine.java
├── OrderRequest.java
├── OrderResponse.java
├── OrderService.java
├── OrderStatus.java
├── PurchaseOrder.java
└── PurchaseOrderRepository.java
src/main/resources
├── application.properties
└── data.sql
9.2 The Entities
The table is called purchase_orders because ORDER is a reserved word in SQL. Every entity has a protected no-argument constructor, which JPA requires, and a public constructor for your own code.
Use the tabs to switch between the seven entity classes.
package com.javabytechie.shop.customer;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@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;
protected Customer() {
// required by JPA
}
public Customer(String name, String email) {
this.name = name;
this.email = email;
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
}
package com.javabytechie.shop.customer;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "customer_profiles")
public class CustomerProfile {
@Id
private Long id; // same value as the customer's id
@OneToOne(fetch = FetchType.LAZY, optional = false)
@MapsId
@JoinColumn(name = "customer_id")
private Customer customer;
private String phone;
private String loyaltyTier;
protected CustomerProfile() {
}
public CustomerProfile(Customer customer, String phone, String loyaltyTier) {
this.customer = customer;
this.phone = phone;
this.loyaltyTier = loyaltyTier;
}
public Long getId() { return id; }
public Customer getCustomer() { return customer; }
public String getPhone() { return phone; }
public String getLoyaltyTier() { return loyaltyTier; }
}
package com.javabytechie.shop.catalog;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
protected Category() {
}
public Category(String name) {
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Category other)) return false;
return id != null && id.equals(other.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
package com.javabytechie.shop.catalog;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String sku;
@Column(nullable = false)
private String name;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@ManyToMany
@JoinTable(
name = "product_categories",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "category_id"))
private Set<Category> categories = new HashSet<>();
protected Product() {
}
public Product(String sku, String name, BigDecimal price) {
this.sku = sku;
this.name = name;
this.price = price;
}
public void addCategory(Category category) {
categories.add(category);
}
public void removeCategory(Category category) {
categories.remove(category);
}
public Long getId() { return id; }
public String getSku() { return sku; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public Set<Category> getCategories() { return Set.copyOf(categories); }
}
package com.javabytechie.shop.order;
public enum OrderStatus {
NEW, PAID, SHIPPED, CANCELLED
}
package com.javabytechie.shop.order;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.javabytechie.shop.customer.Customer;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status = OrderStatus.NEW;
@Column(nullable = false)
private Instant createdAt = Instant.now();
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
protected PurchaseOrder() {
}
public PurchaseOrder(Customer customer) {
this.customer = customer;
}
// Keeps both sides of the relationship in sync
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public void removeLine(OrderLine line) {
lines.remove(line);
line.setOrder(null);
}
public BigDecimal total() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public Long getId() { return id; }
public Customer getCustomer() { return customer; }
public OrderStatus getStatus() { return status; }
public Instant getCreatedAt() { return createdAt; }
public List<OrderLine> getLines() { return List.copyOf(lines); }
}
package com.javabytechie.shop.order;
import java.math.BigDecimal;
import com.javabytechie.shop.catalog.Product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "order_lines")
public class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id")
private PurchaseOrder order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id")
private Product product;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal unitPrice;
protected OrderLine() {
}
public OrderLine(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
this.unitPrice = product.getPrice(); // copy the price at the time of ordering
}
void setOrder(PurchaseOrder order) {
this.order = order;
}
public BigDecimal subtotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
public Long getId() { return id; }
public PurchaseOrder getOrder() { return order; }
public Product getProduct() { return product; }
public int getQuantity() { return quantity; }
public BigDecimal getUnitPrice() { return unitPrice; }
}
OrderLine copies the product price when the line is created. If the product price changes next week, existing orders keep the price the customer actually paid.
9.3 Repositories
package com.javabytechie.shop.customer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerRepository extends JpaRepository<Customer, Long> {
}
package com.javabytechie.shop.customer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerProfileRepository extends JpaRepository<CustomerProfile, Long> {
}
package com.javabytechie.shop.catalog;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku);
}
package com.javabytechie.shop.order;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
// Loads the order, its customer, its lines and each line's product in one query
@Query("""
select o from PurchaseOrder o
join fetch o.customer
left join fetch o.lines l
left join fetch l.product
where o.id = :id
""")
Optional<PurchaseOrder> findWithDetailsById(@Param("id") Long id);
}
9.4 DTOs, Service and Controller
The API never returns entities. Request and response bodies are records, and OrderResponse.from copies the data it needs while the transaction is still open.
package com.javabytechie.shop.order;
import java.util.List;
public record OrderRequest(Long customerId, List<Item> items) {
public record Item(String sku, int quantity) {
}
}
package com.javabytechie.shop.order;
import java.math.BigDecimal;
import java.util.List;
public record OrderResponse(Long id, String customerName, OrderStatus status,
List<Line> lines, BigDecimal total) {
public record Line(Long lineId, String sku, String productName,
int quantity, BigDecimal unitPrice) {
}
static OrderResponse from(PurchaseOrder order) {
List<Line> lines = order.getLines().stream()
.map(l -> new Line(l.getId(), l.getProduct().getSku(), l.getProduct().getName(),
l.getQuantity(), l.getUnitPrice()))
.toList();
return new OrderResponse(order.getId(), order.getCustomer().getName(),
order.getStatus(), lines, order.total());
}
}
package com.javabytechie.shop.common;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, Object id) {
super(resource + " not found: " + id);
}
}
package com.javabytechie.shop.common;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
package com.javabytechie.shop.order;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.javabytechie.shop.catalog.Product;
import com.javabytechie.shop.catalog.ProductRepository;
import com.javabytechie.shop.common.ResourceNotFoundException;
import com.javabytechie.shop.customer.Customer;
import com.javabytechie.shop.customer.CustomerRepository;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final PurchaseOrderRepository orderRepository;
private final CustomerRepository customerRepository;
private final ProductRepository productRepository;
public OrderService(PurchaseOrderRepository orderRepository,
CustomerRepository customerRepository,
ProductRepository productRepository) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
this.productRepository = productRepository;
}
@Transactional
public OrderResponse placeOrder(OrderRequest request) {
Customer customer = customerRepository.findById(request.customerId())
.orElseThrow(() -> new ResourceNotFoundException("Customer", request.customerId()));
PurchaseOrder order = new PurchaseOrder(customer);
for (OrderRequest.Item item : request.items()) {
Product product = productRepository.findBySku(item.sku())
.orElseThrow(() -> new ResourceNotFoundException("Product", item.sku()));
order.addLine(new OrderLine(product, item.quantity()));
}
PurchaseOrder saved = orderRepository.save(order); // cascades to the order lines
log.info("Placed order {} with {} lines for customer {}",
saved.getId(), saved.getLines().size(), customer.getId());
return OrderResponse.from(saved);
}
@Transactional(readOnly = true)
public OrderResponse getOrder(Long orderId) {
return orderRepository.findWithDetailsById(orderId)
.map(OrderResponse::from)
.orElseThrow(() -> new ResourceNotFoundException("Order", orderId));
}
@Transactional
public OrderResponse removeLine(Long orderId, Long lineId) {
PurchaseOrder order = orderRepository.findWithDetailsById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order", orderId));
OrderLine line = order.getLines().stream()
.filter(l -> l.getId().equals(lineId))
.findFirst()
.orElseThrow(() -> new ResourceNotFoundException("Order line", lineId));
order.removeLine(line); // orphanRemoval deletes the row at commit
log.info("Removed line {} from order {}", lineId, orderId);
return OrderResponse.from(order);
}
}
package com.javabytechie.shop.order;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse placeOrder(@RequestBody OrderRequest request) {
return orderService.placeOrder(request);
}
@GetMapping("/{orderId}")
public OrderResponse getOrder(@PathVariable Long orderId) {
return orderService.getOrder(orderId);
}
@DeleteMapping("/{orderId}/lines/{lineId}")
public OrderResponse removeLine(@PathVariable Long orderId, @PathVariable Long lineId) {
return orderService.removeLine(orderId, lineId);
}
}
package com.javabytechie.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
Every "not found" case, whether customer, product, order or order line, throws the same ResourceNotFoundException and returns 404 Not Found as a ProblemDetail body.
9.5 Sample Data
The seed script inserts one customer, two categories and two products, and links them through the join table:
INSERT INTO customers (name, email) VALUES ('Asha Rao', 'asha@example.com');
INSERT INTO categories (name) VALUES ('Electronics');
INSERT INTO categories (name) VALUES ('Accessories');
INSERT INTO products (sku, name, price) VALUES ('KB-100', 'Mechanical Keyboard', 89.00);
INSERT INTO products (sku, name, price) VALUES ('MS-200', 'Wireless Mouse', 25.50);
INSERT INTO product_categories (product_id, category_id)
SELECT p.id, c.id FROM products p, categories c WHERE p.sku = 'KB-100' AND c.name = 'Electronics';
INSERT INTO product_categories (product_id, category_id)
SELECT p.id, c.id FROM products p, categories c WHERE p.sku = 'MS-200' AND c.name IN ('Electronics', 'Accessories');
At startup Hibernate creates these tables from the entities (output shortened):
create table customer_profiles (customer_id bigint not null, loyalty_tier varchar(255), phone varchar(255), primary key (customer_id))
create table order_lines (quantity integer not null, unit_price numeric(10,2) not null, id bigint generated by default as identity, order_id bigint not null, product_id bigint not null, primary key (id))
create table product_categories (category_id bigint not null, product_id bigint not null, primary key (category_id, product_id))
create table purchase_orders (created_at timestamp(6) with time zone not null, customer_id bigint not null, id bigint generated by default as identity, status enum ('CANCELLED','NEW','PAID','SHIPPED') not null, primary key (id))
alter table if exists customer_profiles add constraint FK... foreign key (customer_id) references customers
alter table if exists order_lines add constraint FK... foreign key (order_id) references purchase_orders
alter table if exists order_lines add constraint FK... foreign key (product_id) references products
alter table if exists product_categories add constraint FK... foreign key (category_id) references categories
alter table if exists product_categories add constraint FK... foreign key (product_id) references products
alter table if exists purchase_orders add constraint FK... foreign key (customer_id) references customers
Each mapping is visible in this output: @MapsId made customer_id the profile's primary key, @JoinTable created product_categories with a composite key, and every @JoinColumn became a NOT NULL foreign key because of optional = false.
9.6 Run It
Start the application with ./mvnw spring-boot:run and place an order for one keyboard and two mice:
curl -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerId":1,"items":[{"sku":"KB-100","quantity":1},{"sku":"MS-200","quantity":2}]}'
{
"id": 1,
"customerName": "Asha Rao",
"status": "NEW",
"lines": [
{ "lineId": 1, "sku": "KB-100", "productName": "Mechanical Keyboard", "quantity": 1, "unitPrice": 89.00 },
{ "lineId": 2, "sku": "MS-200", "productName": "Wireless Mouse", "quantity": 2, "unitPrice": 25.50 }
],
"total": 140.00
}
The SQL log shows the cascade at work. The service saved only the order, yet both lines were inserted:
select c1_0.id,c1_0.email,c1_0.name from customers c1_0 where c1_0.id=?
select p1_0.id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.sku=?
select p1_0.id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.sku=?
insert into purchase_orders (created_at,customer_id,status,id) values (?,?,?,default)
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)
INFO c.javabytechie.shop.order.OrderService : Placed order 1 with 2 lines for customer 1
Now remove the mouse line. The service only takes it out of the list, and orphanRemoval turns that into a delete:
curl -X DELETE http://localhost:8080/api/orders/1/lines/2
{
"id": 1,
"customerName": "Asha Rao",
"status": "NEW",
"lines": [
{ "lineId": 1, "sku": "KB-100", "productName": "Mechanical Keyboard", "quantity": 1, "unitPrice": 89.00 }
],
"total": 89.00
}
select po1_0.id, ... from purchase_orders po1_0 join customers c1_0 ... where po1_0.id=?
INFO c.javabytechie.shop.order.OrderService : Removed line 2 from order 1
delete from order_lines where id=?
The delete runs after the log message because Hibernate writes changes at commit time. A missing order returns the shared 404 response:
{
"detail": "Order not found: 99",
"instance": "/api/orders/99",
"status": 404,
"title": "Not Found"
}
10. Common Pitfalls and How to Fix Them
10.1 LazyInitializationException
org.hibernate.LazyInitializationException means code touched a lazy field after the session that loaded the entity was closed. With spring.jpa.open-in-view=false, that happens as soon as an entity leaves a @Transactional service method and a controller or JSON serializer reads its relationships.
How to fix it: load what the use case needs with a fetch join (section 8), and convert entities to DTOs inside the transactional method, as OrderService does with OrderResponse.from. Don't "fix" it by switching relationships to EAGER or by enabling hibernate.enable_lazy_load_no_trans. Both hide the problem and add queries everywhere. The transaction management tutorial explains where transaction boundaries sit.
spring.jpa.open-in-view by default and logs a warning about it at startup. It keeps a session open for the whole web request, which makes lazy errors disappear but lets the view layer run hidden queries and holds a database connection longer. Turning it off makes data access explicit.10.2 Infinite Recursion When Returning Entities as JSON
If a controller returns a PurchaseOrder entity, the JSON serializer writes the order, each line, each line's order, that order's lines again, and so on, until the request fails with a recursion, nesting-depth or lazy loading error.
How to fix it: return records such as OrderResponse that contain only the fields the client needs. Jackson annotations like @JsonManagedReference / @JsonBackReference or @JsonIgnore can break the loop, but they tie your API format to your database model. (In Jackson 3, used by Spring Boot 4, these annotations keep their com.fasterxml.jackson.annotation package.)
10.3 Updating Only the Inverse Side
This looks right but writes nothing to the foreign key:
public void addLine(OrderLine line) {
lines.add(line); // line.order is never set, so order_id has no value
}
Because order_id is NOT NULL, the insert fails with a constraint violation. How to fix it: set the owning side in the same helper method (line.setOrder(this)), and keep the collection private so the helpers are the only way in.
10.4 Using a List for @ManyToMany
Hibernate treats a List without an index column as a "bag", a collection that allows duplicates and has no way to identify one row. To remove one category from a product, it deletes every join row for that product and inserts the rest again. This is the SQL Hibernate 7 runs to remove one of three categories from a List:
delete from product_categories where product_id=?
insert into product_categories (product_id,category_id) values (?,?)
insert into product_categories (product_id,category_id) values (?,?)
With a Set, as in Product, the same change is one targeted statement:
delete from product_categories where product_id=? and category_id=?
10.5 Broken equals and hashCode
Generated equals and hashCode methods, such as those from Lombok's @Data, include every field. Across a bidirectional relationship they recurse into a StackOverflowError, and because the hash code changes when the id is assigned, an entity can get lost inside a HashSet. How to fix it: keep the default identity-based methods unless the entity goes into a Set. If it does, compare by id and return a constant hash code, as Category does, and never include relationship fields.
10.6 Cascading Deletes to Shared Data
cascade = CascadeType.ALL on OrderLine.product or Product.categories lets one delete reach a product or category that other rows still use. With foreign keys the delete fails; without them, data silently disappears. How to fix it: cascade only from parents to the children they own (section 7).
11. Testing Entity Relationships with @DataJpaTest
@DataJpaTest starts only the JPA part of the application (entities, repositories and an embedded database) and runs each test in a transaction that is rolled back at the end. It is the fastest way to check that cascades, orphan removal and fetch joins behave as you expect. The Spring Boot testing tutorial covers the other test slices.
package com.javabytechie.shop.order;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import com.javabytechie.shop.catalog.Product;
import com.javabytechie.shop.customer.Customer;
@DataJpaTest
class PurchaseOrderRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private PurchaseOrderRepository orderRepository;
@Test
void savingAnOrderCascadesToItsLines() {
Customer customer = entityManager.persist(new Customer("Ravi Menon", "ravi@example.com"));
Product monitor = entityManager.persist(new Product("MN-300", "27-inch Monitor", new BigDecimal("219.00")));
Product cable = entityManager.persist(new Product("CB-010", "HDMI Cable", new BigDecimal("9.50")));
PurchaseOrder order = new PurchaseOrder(customer);
order.addLine(new OrderLine(monitor, 1));
order.addLine(new OrderLine(cable, 2));
Long orderId = orderRepository.save(order).getId(); // only the order is saved explicitly
entityManager.flush();
entityManager.clear(); // force the next read to hit the database
PurchaseOrder reloaded = orderRepository.findWithDetailsById(orderId).orElseThrow();
assertThat(reloaded.getLines()).hasSize(2);
assertThat(reloaded.total()).isEqualByComparingTo("238.00");
}
@Test
void removingALineFromTheCollectionDeletesTheRow() {
Customer customer = entityManager.persist(new Customer("Meera Iyer", "meera@example.com"));
Product monitor = entityManager.persist(new Product("MN-301", "24-inch Monitor", new BigDecimal("159.00")));
Product cable = entityManager.persist(new Product("CB-011", "USB-C Cable", new BigDecimal("12.00")));
PurchaseOrder order = new PurchaseOrder(customer);
order.addLine(new OrderLine(monitor, 1));
order.addLine(new OrderLine(cable, 1));
Long orderId = orderRepository.save(order).getId();
entityManager.flush();
entityManager.clear();
PurchaseOrder loaded = orderRepository.findWithDetailsById(orderId).orElseThrow();
assertThat(loaded.getLines()).hasSize(2); // before
OrderLine cableLine = loaded.getLines().stream()
.filter(l -> l.getProduct().getSku().equals("CB-011"))
.findFirst().orElseThrow();
Long cableLineId = cableLine.getId();
loaded.removeLine(cableLine);
entityManager.flush();
entityManager.clear();
PurchaseOrder after = orderRepository.findWithDetailsById(orderId).orElseThrow();
assertThat(after.getLines()).hasSize(1); // after
assertThat(entityManager.find(OrderLine.class, cableLineId)).isNull(); // the row is gone
}
}
flush()sends the pending SQL, andclear()empties the persistence context. Withoutclear(), the next read would return the same Java objects from memory and the test would prove nothing about the database.- The second test checks the state before the change (two lines) and after it (one line, and the removed row no longer exists).
- Only
orderRepository.save(order)is called. The lines are saved through the cascade, which is exactly what the first test verifies.
@DataJpaTest is in org.springframework.boot.data.jpa.test.autoconfigure and TestEntityManager is in org.springframework.boot.jpa.test.autoconfigure. In Spring Boot 3.x, both are in org.springframework.boot.test.autoconfigure.orm.jpa.12. When to Map a Relationship and When to Avoid It
| Good use cases | Poor use cases |
|---|---|
| A child that cannot exist without its parent (order lines in an order) | A collection that can grow without limit (all orders of a customer) |
| A reference you read often together with the entity (an order's customer) | Links to data owned by another service or database (store the id instead) |
| A small, stable many-to-many set (a product's categories) | A many-to-many link that carries its own data (use a join entity) |
| Rules that need foreign key integrity | Reporting queries over large tables (use a JPQL or native query that returns DTOs) |
13. Best Practices
- Make every association lazy, including
@ManyToOneand@OneToOne, and load related data per use case withjoin fetchor an entity graph. - Put
mappedByon the collection side of a one-to-many pair, so the foreign key is owned by the@ManyToOne. - Keep both sides in sync with helper methods such as
addLineandremoveLine, and expose collections only as read-only copies. - Cascade only from a parent to children it owns, and never cascade a remove to shared data.
- Use
orphanRemovalfor child rows that have no meaning without their parent. - Use a
Setfor@ManyToMany, withequalsandhashCodebased on the id and never on relationship fields. - Turn the link into a join entity as soon as it needs its own columns, such as a quantity or a price.
- Prefer unidirectional mappings, and add the inverse side only when the parent manages a small, bounded set of children.
- Return DTOs from your API, built inside the transaction, instead of serializing entities.
- Turn off
spring.jpa.open-in-viewso data access stays in the service layer. - Watch the SQL log and test your mappings with
@DataJpaTest, especially cascades and orphan removal.
14. Summary
JPA entity relationships let you link Spring Boot entities with @ManyToOne, @OneToMany, @OneToOne and @ManyToMany, while the database keeps plain foreign keys and join tables. The owning side, the one with @JoinColumn or @JoinTable, is the only side Hibernate writes, so keep both sides in sync with helper methods. Make every association lazy, fetch what each use case needs with join fetch, cascade only from parents to the children they own, use a Set or a join entity for many-to-many links, and return DTOs instead of entities. The main recommendation: start with a lazy, unidirectional @ManyToOne, and add anything more only when a real use case needs it.
Frequently Asked Questions (FAQ)
Q1. What is the difference between mappedBy and @JoinColumn?
@JoinColumn goes on the owning side and names the foreign key column that Hibernate writes. mappedBy goes on the inverse side and points to the owning field by its Java name; that side is read-only for the relationship. In a one-to-many pair, @JoinColumn belongs on the @ManyToOne field and mappedBy on the @OneToMany collection.
Q2. Is @ManyToOne lazy by default?
No. @ManyToOne and @OneToOne default to FetchType.EAGER, while @OneToMany and @ManyToMany default to LAZY. Set fetch = FetchType.LAZY on every @ManyToOne and @OneToOne, then load related data with join fetch when a use case needs it.
Q3. Should I use a List or a Set for JPA relationship collections?
Use a Set for @ManyToMany, because with a List Hibernate deletes and re-inserts all join rows whenever one link is removed. A List is fine for a @OneToMany with mappedBy, but Hibernate cannot join fetch two List collections in one query and throws a MultipleBagFetchException. If you need several collections at once, make them sets or load them in separate queries.
Q4. What is the difference between CascadeType.REMOVE and orphanRemoval?
CascadeType.REMOVE deletes the children when the parent entity is deleted. orphanRemoval = true does that too, and also deletes a child as soon as it is removed from the parent's collection. Use orphanRemoval for children that have no meaning on their own, such as order lines.
Q5. Why does my unidirectional @OneToMany create an extra table?
A @OneToMany without mappedBy and without @JoinColumn is mapped through a join table by default, because the owning entity has no column of its own for the foreign key. Either make the relationship bidirectional, with a @ManyToOne on the child and mappedBy on the collection, or add @JoinColumn to the collection so the key goes into the child table. The bidirectional mapping is usually the better choice.
Q6. How do I fix LazyInitializationException in Spring Boot?
The exception means a lazy relationship was read after its session closed. Load the data a use case needs inside a @Transactional service method, using join fetch or an entity graph, and convert the entities to DTOs before the method returns. Avoid switching to FetchType.EAGER or enabling hibernate.enable_lazy_load_no_trans, because both add hidden queries.
Q7. Why do I get infinite recursion when returning JPA entities from a REST controller?
A bidirectional relationship lets the JSON serializer walk from an order to its lines and back to the order without end. Return DTO records that contain only the fields the client needs. @JsonManagedReference / @JsonBackReference or @JsonIgnore also break the loop, but they tie your API format to your entity model.
Q8. Did JPA relationship mapping change in Spring Boot 4?
The relationship annotations are the same jakarta.persistence ones used in Spring Boot 3. Spring Boot 4 upgrades to Hibernate 7 and Jakarta Persistence 3.2, renames the web starter to spring-boot-starter-webmvc, and moves @DataJpaTest into the spring-boot-data-jpa-test module with a new package. Existing entity mappings usually need no changes.