00:00

JPA Entity Relationships in Spring Boot

Last updated:

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.

AnnotationMeaningShop exampleStored in the database as
@ManyToOneMany rows point to one rowMany orders belong to one customerA foreign key column in this entity's table
@OneToManyOne row has many rowsOne order has many order linesThe foreign key in the other table
@OneToOneEach row has at most one partnerA customer has one profileA foreign key, or a shared primary key
@ManyToManyRows on both sides link freelyProducts and categoriesA 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.customer instead 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 @JoinColumn or @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 / @OneToMany pair, the @ManyToOne side is always the owner, because the foreign key lives in the "many" table. That is why @ManyToOne has no mappedBy attribute.
Owning side and inverse side of the order to order line relationshipThe OrderLine entity has a ManyToOne field with a JoinColumn named order_id. It is the owning side, and Hibernate writes its value into the order_id foreign key column of the order_lines table. The PurchaseOrder entity has a OneToMany lines collection with mappedBy set to order. It is the inverse side: Hibernate fills it by reading order_lines where order_id matches, and ignores it when writing. The order_id column references the id primary key of purchase_orders. Java objects Database tables PurchaseOrder @OneToMany(mappedBy = "order") List<OrderLine> lines inverse side: a read-only mirror OrderLine @ManyToOne @JoinColumn(name = "order_id") PurchaseOrder order owning side: controls the foreign key purchase_orders id (PK) customer_id status order_lines id (PK) order_id (FK) product_id, quantity writes read back with where order_id = ? references
Figure 1: only the owning side (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.

pom.xml
<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>
Version note: Spring Boot 4.x renamed the web starter to 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:

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-drop lets 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=true runs data.sql after Hibernate has created the tables.
  • open-in-view=false stops 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=debug prints 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:

PurchaseOrder.java
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;
  • @JoinColumn(name = "customer_id") names the foreign key column in purchase_orders.
  • fetch = FetchType.LAZY overrides the default. A @ManyToOne is eager by default, which means the customer would be loaded every time an order is loaded, whether you need it or not.
  • optional = false says every order must have a customer. Hibernate makes the column NOT NULL when 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:

PurchaseOrder.java
@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:

OrderLine.java
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id")
private PurchaseOrder order;
  • mappedBy = "order" must match the field name in OrderLine, not the column name.
  • addLine and removeLine are 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 @OneToMany as 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:

CustomerProfile.java
@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:

Product.java
@ManyToMany
@JoinTable(
        name = "product_categories",
        joinColumns = @JoinColumn(name = "product_id"),
        inverseJoinColumns = @JoinColumn(name = "category_id"))
private Set<Category> categories = new HashSet<>();
  • joinColumns is the column pointing back to this entity, and inverseJoinColumns points to the other one.
  • Use a Set, not a List. Section 10.4 shows the extra SQL a List produces.
  • Because the categories are in a Set, Category needs equals and hashCode that 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:

OrderLine.java
@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

AttributeUsed onDefaultWhat it does
fetchAll fourEAGER for @ManyToOne and @OneToOne; LAZY for @OneToMany and @ManyToManyWhen the related data is loaded
cascadeAll fourNoneWhich operations (save, merge, delete, ...) pass from this entity to the related one
mappedBy@OneToMany, @OneToOne, @ManyToManyNot set (this side owns)Marks the inverse side and names the owning field
optional@ManyToOne, @OneToOnetruefalse means the relationship must always be set
orphanRemoval@OneToMany, @OneToOnefalseDeletes a child row when it is removed from the parent
@JoinColumn(name)Owning sideDerived from the field and key namesNames the foreign key column
@JoinTable(name, ...)Owning side of @ManyToManyDerived from the entity namesNames the join table and its two columns
@MapsId@ManyToOne, @OneToOneNot usedUses 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.

UnidirectionalBidirectional
Mapped onOne entityBoth entities
NavigationOne direction onlyBoth directions
Code to keep in syncNoneHelper methods that set both sides
RiskLowOut-of-sync sides, JSON loops, large collections
Example on this pagePurchaseOrder.customer, Product.categoriesPurchaseOrder.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:

CascadeTypePasses onTypical use
PERSISTSaving a new parent saves new childrenCreating an order with its lines
MERGEMerging a detached parent merges its childrenUpdating from a detached object graph
REMOVEDeleting the parent deletes the childrenChildren that cannot exist alone
REFRESHReloading the parent reloads the childrenRarely needed
DETACHDetaching the parent detaches the childrenRarely needed
ALLAll of the aboveA 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.

ActionCascadeType.REMOVEorphanRemoval = true
Delete the orderLines are deletedLines are deleted
order.removeLine(line)Line row is not deleted; Hibernate only tries to clear its foreign key, which fails on a NOT NULL columnLine 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.REMOVE or ALL on a @ManyToOne or 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.

LAZYEAGER
Default for@OneToMany, @ManyToMany@ManyToOne, @OneToOne
When data loadsOn first access, inside an open sessionAlways, with the owning entity
Main riskLazyInitializationException, N+1 queriesLoading far more data than the use case needs
Can you change it per query?Yes, with a fetch join or entity graphNot 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:

PurchaseOrderRepository.java
@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:

Console
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.

Database tables created by the shop exampleSeven tables. customers has a one to one link to customer_profiles, whose primary key customer_id is also a foreign key to customers. customers has a one to many link to purchase_orders through purchase_orders.customer_id. purchase_orders has a one to many link to order_lines through order_lines.order_id. Each order line also points to one product through order_lines.product_id. products and categories have a many to many link through the product_categories join table, which holds product_id and category_id. customers id (PK) name email customer_profiles customer_id (PK, FK) phone loyalty_tier purchase_orders id (PK) customer_id (FK) status created_at order_lines id (PK) order_id (FK) product_id (FK) quantity unit_price categories id (PK) name product_categories product_id (PK, FK) category_id (PK, FK) products id (PK) sku name price 1 : 1 (shared key) 1 : N 1 : N N : 1 1 : N 1 : N
Figure 2: the tables Hibernate generates for the example. Highlighted columns are foreign keys, and the product_categories join table turns two one-to-many links into the products ↔ categories many-to-many.

9.1 Project Structure

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; }
}

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> {
}

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) {
    }
}

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:

data.sql
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):

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

Terminal
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}]}'
HTTP response (201 Created)
{
  "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:

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

Terminal
curl -X DELETE http://localhost:8080/api/orders/1/lines/2
HTTP response (200 OK)
{
  "id": 1,
  "customerName": "Asha Rao",
  "status": "NEW",
  "lines": [
    { "lineId": 1, "sku": "KB-100", "productName": "Mechanical Keyboard", "quantity": 1, "unitPrice": 89.00 }
  ],
  "total": 89.00
}
Console
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:

HTTP response (404 Not Found)
{
  "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.

Why open-in-view matters: Spring Boot enables 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:

PurchaseOrder.java (wrong)
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:

Console (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:

Console (Set)
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.

PurchaseOrderRepositoryTest.java
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, and clear() empties the persistence context. Without clear(), 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.
Version note: in Spring Boot 4.x, @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 casesPoor 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 integrityReporting queries over large tables (use a JPQL or native query that returns DTOs)

13. Best Practices

  • Make every association lazy, including @ManyToOne and @OneToOne, and load related data per use case with join fetch or an entity graph.
  • Put mappedBy on 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 addLine and removeLine, 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 orphanRemoval for child rows that have no meaning without their parent.
  • Use a Set for @ManyToMany, with equals and hashCode based 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-view so 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.