Command Palette

Search for a command to run...

[Spring Boot Basics] JPA Entity Relationships in Spring Boot: @OneToOne, @OneToMany, @ManyToOne, @ManyToMany, Cascade and FetchType

Up to now the catalogue on a real database has had one entity, Product, with its category stored as a plain String column. Catalogue data is connected, though. A product belongs to a category and carries tags, an order is made of lines that each point at a product, and a customer has exactly one profile. JPA maps each of those connections with one of four annotations, @ManyToOne, @OneToMany, @OneToOne and @ManyToMany, and each one settles the same questions about a foreign key: which table holds it, which Java field writes it, what is loaded with it, and what is saved or deleted along with it.

This article turns the category column into a Category entity, adds tags, orders with lines and customer profiles, and reads the schema Hibernate generates for each relationship on PostgreSQL. Then it runs the mistakes that only show up at runtime: an order line saved with a null foreign key, a tag removal that deletes and re-inserts rows, a cascade that deletes a category, a lazy collection read after its persistence context closed, the N+1 SELECTs behind a list endpoint, and an entity that Jackson cannot finish writing.

Four catalogue tables, Order, OrderLine, Product and Tag, joined by foreign keys drawn as one-to-many, many-to-one and many-to-many connectors

The examples use Spring Boot 4.1.1 and Java 21, on an Initializr project with the web, validation, Spring Data JPA, H2 and PostgreSQL dependencies. Table definitions come from PostgreSQL 18 running in Docker; SQL logs, exceptions and HTTP responses come from H2 unless a block says otherwise. The app runs on port 8128 instead of the default 8080.

The catalogue needs six relationships, and together they use all four annotations:

In the catalogueCardinalityMapped with
Many products belong to one categoryN:1@ManyToOne on Product.category
Many products carry many tagsN:N@ManyToMany on Product.tags
One order has many lines1:N@OneToMany on Order.lines, @ManyToOne on OrderLine.order
Many lines point at one productN:1@ManyToOne on OrderLine.product
Many orders belong to one customerN:1@ManyToOne on Order.customer
One customer has one profile1:1@OneToOne on CustomerProfile.customer and Customer.profile

The classes go into the feature packages from article 21: Category, Tag and Product in product, Order and OrderLine in order, and a new customer package for Customer and CustomerProfile. Each entity gets a JpaRepository interface in its package, as in article 26. This first version leaves every annotation at its defaults. The rest of the article changes them one at a time, and each change comes with the SQL that motivates it.

Category and the @ManyToOne on Product

Category is an ordinary entity with a unique name:

src/main/java/com/example/demo/product/Category.java
package com.example.demo.product;
 
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;
    }
}

In Product, the String column becomes a reference to a Category, and a list of tags appears. This Product is a shorter version of article 26's, without status and with simpler column definitions, which keeps the generated DDL short:

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.util.ArrayList; 
import java.util.List; 
 
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.ManyToOne; 
import jakarta.persistence.Table;
 
@Entity
@Table(name = "products")
public class Product {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false)
    private String name;
 
    @Column(nullable = false, unique = true)
    private String sku;
 
    @Column(nullable = false, precision = 12, scale = 2)
    private BigDecimal price;
 
    @Column(nullable = false)
    private int stock;
 
    private String category; 
    @ManyToOne
    @JoinColumn(name = "category_id") 
    private Category category; 
 
    @ManyToMany
    @JoinTable(name = "product_tags", 
            joinColumns = @JoinColumn(name = "product_id"), 
            inverseJoinColumns = @JoinColumn(name = "tag_id")) 
    private List<Tag> tags = new ArrayList<>(); 
 
    protected Product() {
    }
 
    public Product(String name, String sku, BigDecimal price, int stock, String category) { 
    public Product(String name, String sku, BigDecimal price, int stock, Category category) { 
        this.name = name;
        this.sku = sku;
        this.price = price;
        this.stock = stock;
        this.category = category;
    }
 
    // getters for id, name, sku, price and stock, and setStock(int)
 
    public String getCategory() { 
    public Category getCategory() { 
        return category;
    }
 
    public List<Tag> getTags() { 
        return tags; 
    } 
}
  • @ManyToOne says that many products point at one category. The field holds a Category object; the table will hold its id.
  • @JoinColumn(name = "category_id") names that foreign key column, so the schema does not depend on naming defaults.
  • @ManyToMany with @JoinTable names the table that links products to tags, and its two columns. Tag does not map the other direction.
  • The tags are a List, the type most code reaches for first. The section on Set versus List shows what that costs.

Tag has the same shape as Category:

src/main/java/com/example/demo/product/Tag.java
package com.example.demo.product;
 
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 = "tags")
public class Tag {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, unique = true)
    private String name;
 
    protected Tag() {
    }
 
    public Tag(String name) {
        this.name = name;
    }
 
    public Long getId() {
        return id;
    }
 
    public String getName() {
        return name;
    }
}

Orders, lines and customer profiles

An order belongs to a customer and owns its lines. ORDER is an SQL keyword, so the table is called orders:

src/main/java/com/example/demo/order/Order.java
package com.example.demo.order;
 
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
 
import com.example.demo.customer.Customer;
 
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
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 = "orders")
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne
    @JoinColumn(name = "customer_id")
    private Customer customer;
 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderLine> lines = new ArrayList<>();
 
    protected Order() {
    }
 
    public Order(Customer customer) {
        this.customer = customer;
    }
 
    public BigDecimal total() {
        return lines.stream()
                .map(OrderLine::lineTotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
 
    public Long getId() {
        return id;
    }
 
    public Customer getCustomer() {
        return customer;
    }
 
    public List<OrderLine> getLines() {
        return lines;
    }
}

OrderLine holds both foreign keys of a line, to its order and to its product. unitPrice copies the product's price when the line is created, so a later price change does not rewrite old orders:

src/main/java/com/example/demo/order/OrderLine.java
package com.example.demo.order;
 
import java.math.BigDecimal;
 
import com.example.demo.product.Product;
 
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.ManyToOne;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "order_lines")
public class OrderLine {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne
    @JoinColumn(name = "order_id")
    private Order order;
 
    @ManyToOne
    @JoinColumn(name = "product_id")
    private Product product;
 
    @Column(nullable = false)
    private int quantity;
 
    @Column(nullable = false, precision = 12, scale = 2)
    private BigDecimal unitPrice;
 
    protected OrderLine() {
    }
 
    public OrderLine(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
        this.unitPrice = product.getPrice();
    }
 
    public BigDecimal lineTotal() {
        return unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
 
    void setOrder(Order order) {
        this.order = order;
    }
 
    // getters for id, order, product, quantity and unitPrice
}

The order relationship is mapped from both ends: OrderLine.order with @JoinColumn, and Order.lines with mappedBy = "order", which names the field on the other side. cascade = CascadeType.ALL makes saving an order save its lines too; the section on cascade shows exactly what it does.

The profile is a separate table with one row per customer:

src/main/java/com/example/demo/customer/CustomerProfile.java
package com.example.demo.customer;
 
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.OneToOne;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "customer_profiles")
public class CustomerProfile {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @OneToOne
    @JoinColumn(name = "customer_id")
    private Customer customer;
 
    @Column(nullable = false)
    private String fullName;
 
    private String phone;
 
    protected CustomerProfile() {
    }
 
    public CustomerProfile(String fullName, String phone) {
        this.fullName = fullName;
        this.phone = phone;
    }
 
    void setCustomer(Customer customer) {
        this.customer = customer;
    }
 
    // getters for id, customer, fullName and phone
}
src/main/java/com/example/demo/customer/Customer.java
package com.example.demo.customer;
 
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "customers")
public class Customer {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, unique = true)
    private String email;
 
    @OneToOne(mappedBy = "customer", cascade = CascadeType.ALL)
    private CustomerProfile profile;
 
    protected Customer() {
    }
 
    public Customer(String email) {
        this.email = email;
    }
 
    public void setProfile(CustomerProfile profile) {
        this.profile = profile;
        profile.setCustomer(this);
    }
 
    // getters for id, email and profile
}

Customer.setProfile sets the field on both objects. The section on the owning and inverse sides shows why.

Running the catalogue on PostgreSQL and H2

src/main/resources/application.properties
spring.application.name=demo
logging.level.org.hibernate.SQL=debug
src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55428/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=create

Without a datasource URL, Spring Boot starts an in-memory H2 database and Hibernate creates the tables at startup. The postgres profile points at PostgreSQL 18 in Docker instead, and ddl-auto=create drops and recreates the mapped tables on every start, which is fine for reading generated DDL and nothing else; article 31 replaces it with Flyway migrations.

Bash
docker run -d --name sb-a28-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55428:5432 postgres:18
Bash
./gradlew -q bootJar
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --server.port=8128

For the H2 runs, a startup runner seeds the same data every time: three categories (Keyboards, Mice, Accessories), five tags, four products, three customers with profiles, and ten orders of two lines each. Product 1 is the mechanical keyboard KB-01, tagged mechanical, rgb and bestseller. The snippets that call repositories directly ran in a throwaway runner, each inside one transaction unless the text says otherwise; transactions themselves are the subject of article 30.

What each relationship annotation creates in the database

With the postgres profile, Hibernate logs its DDL through org.hibernate.SQL, and psql inside the container shows what PostgreSQL made of it:

Bash
docker exec sb-a28-pg psql -U shop -d shop -c '\d products'

Four rows, one per annotation: @ManyToOne on Product.category next to products.category_id referencing categories, @OneToOne on CustomerProfile.customer next to customer_profiles.customer_id with FK and UNIQUE, @OneToMany(mappedBy) on Order.lines next to order_lines.order_id referencing orders, and @ManyToMany on Product.tags next to the product_tags join table with two NOT NULL foreign keys

@ManyToOne: a foreign key column

The DDL for products, from the log and laid out one column per line:

SQL
create table products (
    price numeric(12,2) not null,
    stock integer not null,
    category_id bigint,
    id bigint generated by default as identity,
    name varchar(255) not null,
    sku varchar(255) not null unique,
    primary key (id)
)
 
alter table if exists products add constraint FKog2rp4qthbtt2lfyhfo32lsw9 foreign key (category_id) references categories
Text
                                    Table "public.products"
   Column    |          Type          | Collation | Nullable |             Default
-------------+------------------------+-----------+----------+----------------------------------
 price       | numeric(12,2)          |           | not null |
 stock       | integer                |           | not null |
 category_id | bigint                 |           |          |
 id          | bigint                 |           | not null | generated by default as identity
 name        | character varying(255) |           | not null |
 sku         | character varying(255) |           | not null |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
    "products_sku_key" UNIQUE CONSTRAINT, btree (sku)
Foreign-key constraints:
    "fkog2rp4qthbtt2lfyhfo32lsw9" FOREIGN KEY (category_id) REFERENCES categories(id)
Referenced by:
    TABLE "product_tags" CONSTRAINT "fk5rk6s19k3risy7q7wqdr41uss" FOREIGN KEY (product_id) REFERENCES products(id)
    TABLE "order_lines" CONSTRAINT "fk5v1oeejtgtf2n3toppm3tkuhh" FOREIGN KEY (product_id) REFERENCES products(id)

The foreign key lives on the many side: every product row stores the id of its category, and categories has no column pointing back. category_id has no not null, because a @ManyToOne is optional by default. Hibernate also lists the columns in its own order, not in the order of the fields. The other three @ManyToOne fields produced the same shape: orders.customer_id, order_lines.order_id and order_lines.product_id, each with its own foreign key.

@OneToOne: a foreign key with a UNIQUE constraint

SQL
create table customer_profiles (
    customer_id bigint unique,
    id bigint generated by default as identity,
    full_name varchar(255) not null,
    phone varchar(255),
    primary key (id)
)
 
alter table if exists customer_profiles add constraint FKnjy1dugyof18fa21tu22fu98j foreign key (customer_id) references customers
Text
                                Table "public.customer_profiles"
   Column    |          Type          | Collation | Nullable |             Default
-------------+------------------------+-----------+----------+----------------------------------
 customer_id | bigint                 |           |          |
 id          | bigint                 |           | not null | generated by default as identity
 full_name   | character varying(255) |           | not null |
 phone       | character varying(255) |           |          |
Indexes:
    "customer_profiles_pkey" PRIMARY KEY, btree (id)
    "customer_profiles_customer_id_key" UNIQUE CONSTRAINT, btree (customer_id)
Foreign-key constraints:
    "fknjy1dugyof18fa21tu22fu98j" FOREIGN KEY (customer_id) REFERENCES customers(id)

A @OneToOne is a @ManyToOne column plus a UNIQUE constraint, and Hibernate 7.4 added unique on its own: the mapping only says @OneToOne and @JoinColumn(name = "customer_id"). That constraint is what makes the relationship one-to-one in the database. The mappedBy side added nothing to its table:

Text
                                 Table "public.customers"
 Column |          Type          | Collation | Nullable |             Default
--------+------------------------+-----------+----------+----------------------------------
 id     | bigint                 |           | not null | generated by default as identity
 email  | character varying(255) |           | not null |
Indexes:
    "customers_pkey" PRIMARY KEY, btree (id)
    "customers_email_key" UNIQUE CONSTRAINT, btree (email)
Referenced by:
    TABLE "customer_profiles" CONSTRAINT "fknjy1dugyof18fa21tu22fu98j" FOREIGN KEY (customer_id) REFERENCES customers(id)
    TABLE "orders" CONSTRAINT "fkpxtb8awmi0dk6smoh2vp1litg" FOREIGN KEY (customer_id) REFERENCES customers(id)

A profile can also share its customer's primary key instead of having a separate foreign key column, with @MapsId; this series does not use it.

@ManyToMany: a join table

SQL
create table product_tags (
    product_id bigint not null,
    tag_id bigint not null
)
 
alter table if exists product_tags add constraint FKpur2885qb9ae6fiquu77tcv1o foreign key (tag_id) references tags
alter table if exists product_tags add constraint FK5rk6s19k3risy7q7wqdr41uss foreign key (product_id) references products
Text
             Table "public.product_tags"
   Column   |  Type  | Collation | Nullable | Default
------------+--------+-----------+----------+---------
 product_id | bigint |           | not null |
 tag_id     | bigint |           | not null |
Foreign-key constraints:
    "fk5rk6s19k3risy7q7wqdr41uss" FOREIGN KEY (product_id) REFERENCES products(id)
    "fkpur2885qb9ae6fiquu77tcv1o" FOREIGN KEY (tag_id) REFERENCES tags(id)

Neither products nor tags got a column. The link is a row in product_tags per product and tag, with a foreign key to each side. With a List, the table has no primary key and nothing that stops the same pair from being stored twice; the section on Set versus List comes back to that.

@OneToMany without mappedBy: a join table you did not ask for

A common first attempt maps only the collection, with no field on the line:

src/main/java/com/example/demo/order/Order.java
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) 
    @OneToMany(cascade = CascadeType.ALL) 
    private List<OrderLine> lines = new ArrayList<>();
src/main/java/com/example/demo/order/OrderLine.java
    @ManyToOne
    @JoinColumn(name = "order_id") 
    private Order order; 

Hibernate 7.4 did not put a foreign key on order_lines. It created a ninth table:

Text
               List of tables
 Schema |       Name        | Type  | Owner
--------+-------------------+-------+-------
 public | categories        | table | shop
 public | customer_profiles | table | shop
 public | customers         | table | shop
 public | order_lines       | table | shop
 public | orders            | table | shop
 public | orders_lines      | table | shop
 public | product_tags      | table | shop
 public | products          | table | shop
 public | tags              | table | shop
(9 rows)
SQL
create table orders_lines (
    lines_id bigint not null unique,
    order_id bigint not null
)
 
alter table if exists orders_lines add constraint FK6odyxaaebqswyy8yhp9wtfsn4 foreign key (lines_id) references order_lines
alter table if exists orders_lines add constraint FKahmp9mk9umvxbsm32o7m2pt5q foreign key (order_id) references orders
Text
                              Table "public.order_lines"
   Column   |     Type      | Collation | Nullable |             Default
------------+---------------+-----------+----------+----------------------------------
 quantity   | integer       |           | not null |
 unit_price | numeric(12,2) |           | not null |
 id         | bigint        |           | not null | generated by default as identity
 product_id | bigint        |           |          |
Indexes:
    "order_lines_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "fk5v1oeejtgtf2n3toppm3tkuhh" FOREIGN KEY (product_id) REFERENCES products(id)
Referenced by:
    TABLE "orders_lines" CONSTRAINT "fk6odyxaaebqswyy8yhp9wtfsn4" FOREIGN KEY (lines_id) REFERENCES order_lines(id)

The name is the owning table plus the field, orders_lines, with order_id and lines_id columns; unique on lines_id keeps a line in one order. Saving an order with two lines, back on H2:

Java
Order order = new Order(customer);
order.getLines().add(new OrderLine(keyboard, 1));
order.getLines().add(new OrderLine(mouse, 2));
orderRepository.save(order);
Text
insert into orders (customer_id,id) values (?,default)
insert into order_lines (product_id,quantity,unit_price,id) values (?,?,?,default)
insert into order_lines (product_id,quantity,unit_price,id) values (?,?,?,default)
insert into orders_lines (order_id,lines_id) values (?,?)
insert into orders_lines (order_id,lines_id) values (?,?)

Five INSERTs: the first three when save ran, the two join table rows at commit. Every order now costs one extra row per line, in a table the model never mentions. With mappedBy, as mapped at the start of the article, the same order takes three INSERTs, which the next section shows.

Owning side vs inverse side in a bidirectional relationship

A bidirectional relationship describes one foreign key column with two Java fields: OrderLine.order and Order.lines both say which order a line belongs to. The table has one order_id column, so JPA writes it from one of the two fields. That field is the owning side: the side with @JoinColumn, which for a @OneToMany paired with a @ManyToOne is always the @ManyToOne. The field with mappedBy is the inverse side. Hibernate uses it to read the relationship and ignores it when writing.

Adding a line only to order.getLines()

With the mapping from the start of the article, cascade = CascadeType.ALL and a nullable order_id:

Java
Order order = new Order(customer);
OrderLine line = new OrderLine(keyboard, 1);
order.getLines().add(line);
orderRepository.save(order);
Text
insert into orders (customer_id,id) values (?,default)
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)

Both rows were written and nothing failed. After the commit, a JdbcTemplate query read the new line back:

Java
jdbcTemplate.queryForList("select id, order_id, product_id, quantity from order_lines where id = (select max(id) from order_lines)");
Text
[{ID=23, ORDER_ID=null, PRODUCT_ID=1, QUANTITY=1}]

Loading order 12 again in a new transaction and calling getLines().size() printed lines of order 12: 0. The cascade inserted the line because it was in the collection, but the INSERT took order_id from OrderLine.order, which nobody had set. The order and the line are both in the database and not connected.

With nullable = false on the join column, which the next section adds, the same four lines fail inside save:

Text
org.springframework.dao.DataIntegrityViolationException: could not execute statement [NULL not allowed for column "ORDER_ID"; SQL statement:
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default) [23502-240]] [insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)]; SQL [insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)]; constraint [ORDER_ID]

The check that fired is the database's: the chain ends in H2's JdbcSQLIntegrityConstraintViolationException. An error is better than a disconnected row, but neither is the intended result.

A helper method that keeps both sides in sync

The fix is to make the collection impossible to change without the owning field. Order gets two methods that update both sides together:

src/main/java/com/example/demo/order/Order.java
    public Order(Customer customer) {
        this.customer = customer;
    }
 
    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() {

OrderLine.setOrder is package-private, so code outside com.example.demo.order cannot set one side without the other. Customer.setProfile follows the same pattern for the @OneToOne. The same order of two lines, through the helper:

Java
Order order = new Order(customer);
order.addLine(new OrderLine(keyboard, 1));
order.addLine(new OrderLine(mouse, 2));
orderRepository.save(order);
Text
insert into orders (customer_id,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)

Three INSERTs, with order_id bound this time, against the five of the unidirectional mapping.

Two panels. Left: order.getLines().add(line) leaves OrderLine.order null; flush reads OrderLine.order and ignores the mappedBy collection, so the row gets order_id NULL, reloading order 12 finds 0 lines, and with nullable = false the insert fails with NULL not allowed for column ORDER_ID. Right: order.addLine(line) sets both fields, three INSERTs, and GET /api/orders/11 returns both lines

@ManyToOne done right: LAZY, optional = false and a NOT NULL column

The defaults of @ManyToOne are fetch = FetchType.EAGER and optional = true. The first loads more than a request needs; the second allows exactly the disconnected row from the previous section.

What the default EAGER fetch loads

productRepository.findById(1L), with Product.category at its default:

Text
select p1_0.id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 left join categories c1_0 on c1_0.id=p1_0.category_id where p1_0.id=?

productRepository.findAll() did not join. It ran the query for the products, then one SELECT per distinct category to satisfy EAGER:

Text
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?

Eager associations also chain. Loading one order, then reading its lines, with every @ManyToOne and @OneToOne at its default:

Text
select o1_0.id,c1_0.id,c1_0.email,p1_0.id,p1_0.full_name,p1_0.phone from orders o1_0 left join customers c1_0 on c1_0.id=o1_0.customer_id left join customer_profiles p1_0 on c1_0.id=p1_0.customer_id where o1_0.id=?
select l1_0.order_id,l1_0.id,p1_0.id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,l1_0.quantity,l1_0.unit_price from order_lines l1_0 left join products p1_0 on p1_0.id=l1_0.product_id left join categories c1_0 on c1_0.id=p1_0.category_id where l1_0.order_id=?

The order pulled in its customer and the customer's profile; the lines pulled in their products and each product's category. None of that was asked for by the code that loaded the order.

LAZY, optional = false and nullable = false

src/main/java/com/example/demo/product/Product.java
    @ManyToOne
    @JoinColumn(name = "category_id") 
    @ManyToOne(fetch = FetchType.LAZY, optional = false) 
    @JoinColumn(name = "category_id", nullable = false) 
    private Category category;
src/main/java/com/example/demo/order/OrderLine.java
    @ManyToOne
    @JoinColumn(name = "order_id") 
    @ManyToOne(fetch = FetchType.LAZY, optional = false) 
    @JoinColumn(name = "order_id", nullable = false) 
    private Order order;
 
    @ManyToOne
    @JoinColumn(name = "product_id") 
    @ManyToOne(fetch = FetchType.LAZY, optional = false) 
    @JoinColumn(name = "product_id", nullable = false) 
    private Product product;

Order.customer gets the same two changes. @OneToOne accepts the same attributes, so CustomerProfile.customer gets them as well, and the mappedBy side, Customer.profile, gets fetch = FetchType.LAZY; the section on fetch types checks what that last change does:

src/main/java/com/example/demo/customer/CustomerProfile.java
    @OneToOne
    @JoinColumn(name = "customer_id") 
    @OneToOne(fetch = FetchType.LAZY, optional = false) 
    @JoinColumn(name = "customer_id", nullable = false) 
    private Customer customer;
src/main/java/com/example/demo/customer/Customer.java
    @OneToOne(mappedBy = "customer", cascade = CascadeType.ALL) 
    @OneToOne(mappedBy = "customer", cascade = CascadeType.ALL, fetch = FetchType.LAZY) 
    private CustomerProfile profile;

Each setting has its own job:

  • fetch = FetchType.LAZY loads the category when it is first used instead of with every product.
  • optional = false states in the mapping that a product always has a category.
  • nullable = false is what reaches the schema. On PostgreSQL, category_id became not null:
Text
 category_id | bigint                 |           | not null |

The same findById(1L), then product.getCategory().getName():

Text
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?

The first statement no longer touches categories; the second ran only when getName() was called. findAll() became the one select ... from products p1_0 statement, with no SELECT per category.

Set vs List for @ManyToMany

A List without an index column is what Hibernate calls a bag: an unordered collection that may contain duplicates. The join table of a bag has no primary key, as \d product_tags showed, so Hibernate has no way to address one of its rows.

Removing one tag from a List

Product 1 had the tags [mechanical, rgb, bestseller]. Removing rgb inside a transaction:

Java
Product keyboard = productRepository.findById(1L).orElseThrow();
Tag rgb = tagRepository.findByName("rgb").orElseThrow();
keyboard.getTags().remove(rgb);

The SQL at flush:

Text
delete from product_tags where product_id=?
insert into product_tags (product_id,tag_id) values (?,?)
insert into product_tags (product_id,tag_id) values (?,?)

Hibernate deleted every row of product 1 and inserted the two tags that were left. The work depends on how many tags the product keeps, not on the one that was removed.

Removing one tag from a Set

src/main/java/com/example/demo/product/Product.java
import java.math.BigDecimal;
import java.util.ArrayList; 
import java.util.List; 
import java.util.HashSet; 
import java.util.Set; 
 
// ...
 
    @ManyToMany
    @JoinTable(name = "product_tags",
            joinColumns = @JoinColumn(name = "product_id"),
            inverseJoinColumns = @JoinColumn(name = "tag_id"))
    private List<Tag> tags = new ArrayList<>(); 
    private Set<Tag> tags = new HashSet<>(); 
 
// ...
 
    public List<Tag> getTags() { 
    public Set<Tag> getTags() { 
        return tags;
    }

The same three lines of code, and the flush:

Text
delete from product_tags where product_id=? and tag_id=?

One statement for one removed tag. The schema changed as well: with a Set, Hibernate declared both columns the primary key, so the database itself refuses a duplicate pair:

Text
             Table "public.product_tags"
   Column   |  Type  | Collation | Nullable | Default
------------+--------+-----------+----------+---------
 product_id | bigint |           | not null |
 tag_id     | bigint |           | not null |
Indexes:
    "product_tags_pkey" PRIMARY KEY, btree (product_id, tag_id)
Foreign-key constraints:
    "fk5rk6s19k3risy7q7wqdr41uss" FOREIGN KEY (product_id) REFERENCES products(id)
    "fkpur2885qb9ae6fiquu77tcv1o" FOREIGN KEY (tag_id) REFERENCES tags(id)

Tag does not override equals and hashCode. Inside one persistence context a row is represented by a single Java object, so the rgb returned by tagRepository was the object already in the set, and remove found it. A HashSet that mixes entities loaded in different persistence contexts needs one of the equals and hashCode implementations from article 26, id-based or on a natural key such as the unique tag name.

Order.lines stays a List. With mappedBy, every line is a row with its own id, and removing one deletes that row, as the section on orphanRemoval shows.

MultipleBagFetchException with two List collections

Bags have a second cost. Still with List<Tag>, a repository method that fetches the lines, their products and the products' tags in one query:

src/main/java/com/example/demo/order/OrderRepository.java
    @Query("select o from Order o left join fetch o.lines l left join fetch l.product p left join fetch p.tags")
    List<Order> findAllWithLinesAndTags();

The application started normally. Calling the method threw:

Text
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.demo.product.Product.tags, com.example.demo.order.Order.lines]

One SQL join over two collections multiplies their rows, and a bag cannot tell a copy produced by the join from a duplicate that really is in the collection, so Hibernate refuses to build it. With tags as a Set, the same JPQL ran as a single statement:

Text
select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,t1_0.product_id,t1_1.id,t1_1.name,l1_0.quantity,l1_0.unit_price from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id left join products p1_0 on p1_0.id=l1_0.product_id left join product_tags t1_0 on p1_0.id=t1_0.product_id left join tags t1_1 on t1_1.id=t1_0.tag_id

Cascade types and orphanRemoval

A cascade repeats an EntityManager operation on the parent for the entities in one of its associations. Each type names the operation it repeats:

Cascade typeRepeats on the associated entities
PERSISTpersist: a new parent makes its new children persistent
MERGEmerge: copying a detached parent back into the persistence context copies its children too
REMOVEremove: deleting the parent deletes the children
REFRESH, DETACHreloading the parent from the database, or detaching it
ALLall of the above

Spring Data's save decides which operation runs, as article 26 showed: SimpleJpaRepository.save calls EntityManager.persist for a new entity and EntityManager.merge for any other. A new order therefore needs PERSIST to take its lines along, and an order saved again after it was detached needs MERGE. ALL covers both, and brings REMOVE with them.

Saving an order and its lines with one save call

OrderService.place builds the order through the helper method and calls save once. The stock rule comes from article 21, now applied to entities, and the method runs in one transaction:

src/main/java/com/example/demo/order/OrderService.java
package com.example.demo.order;
 
import java.util.List;
 
import com.example.demo.customer.Customer;
import com.example.demo.customer.CustomerNotFoundException;
import com.example.demo.customer.CustomerRepository;
import com.example.demo.product.Product;
import com.example.demo.product.ProductService;
 
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
public class OrderService {
 
    private final OrderRepository orders;
    private final CustomerRepository customers;
    private final ProductService productService;
 
    public OrderService(OrderRepository orders, CustomerRepository customers, ProductService productService) {
        this.orders = orders;
        this.customers = customers;
        this.productService = productService;
    }
 
    @Transactional
    public Order place(Long customerId, List<OrderItem> items) {
        Customer customer = customers.findById(customerId)
                .orElseThrow(() -> new CustomerNotFoundException(customerId));
        Order order = new Order(customer);
        for (OrderItem item : items) {
            Product product = productService.reserveStock(item.productId(), item.quantity());
            order.addLine(new OrderLine(product, item.quantity()));
        }
        return orders.save(order);
    }
 
    @Transactional(readOnly = true)
    public Order findById(Long id) {
        return orders.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
    }
 
    @Transactional(readOnly = true)
    public List<Order> findAll() {
        return orders.findAll();
    }
}
src/main/java/com/example/demo/order/OrderItem.java
package com.example.demo.order;
 
public record OrderItem(Long productId, int quantity) {
}
src/main/java/com/example/demo/product/ProductService.java
    public Product reserveStock(Long id, int quantity) {
        Product product = findById(id);
        if (product.getStock() < quantity) {
            throw new InsufficientStockException(product.getSku(), product.getStock(), quantity);
        }
        product.setStock(product.getStock() - quantity);
        return product;
    }

The SQL of one POST /api/customers/1/orders with two lines; the controller behind it is in the section on fetch types:

Text
select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
insert into orders (customer_id,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)
update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?

One save wrote the order and both lines: PERSIST reached the lines because addLine had put them in the collection. The two UPDATEs are the stock changes, found by dirty checking at commit. The customer_profiles SELECT is explained in the section on fetch types. Without the cascade:

src/main/java/com/example/demo/order/Order.java
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) 
    @OneToMany(mappedBy = "order") 
    private List<OrderLine> lines = new ArrayList<>();

the same addLine calls followed by orderRepository.save(order) produced one INSERT:

Text
insert into orders (customer_id,id) values (?,default)

No exception, and the transaction committed. The lines were in the collection and had their order set, but nothing asked Hibernate to persist them, so they never reached the database.

Removing a line with and without orphanRemoval

With cascade = CascadeType.ALL restored, removing the first line of order 1 inside a transaction:

Java
Order order = orderRepository.findById(1L).orElseThrow();
OrderLine first = order.getLines().get(0);
order.removeLine(first);
entityManager.flush();
Text
update order_lines set order_id=?,product_id=?,quantity=?,unit_price=? where id=?
Text
org.hibernate.exception.ConstraintViolationException: could not execute statement [NULL not allowed for column "ORDER_ID"; SQL statement:
update order_lines set order_id=?,product_id=?,quantity=?,unit_price=? where id=? [23502-240]] [update order_lines set order_id=?,product_id=?,quantity=?,unit_price=? where id=?]

removeLine set OrderLine.order to null, and a change on the owning side is an UPDATE of the foreign key, which NOT NULL rejected. With the nullable order_id of the first mapping, the same UPDATE succeeded and the line stayed in order_lines attached to no order. orphanRemoval = true declares that a line taken out of the collection has no reason to exist:

src/main/java/com/example/demo/order/Order.java
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) 
    private List<OrderLine> lines = new ArrayList<>();
Text
delete from order_lines where id=?

Order 1 had two lines, and select count(*) from order_lines where order_id = 1 returned 1 afterwards. orphanRemoval fits children that exist only as part of their parent, like order lines. A tag taken off a product is a different case: the tag still exists for other products.

Why CascadeType.REMOVE on @ManyToOne or @ManyToMany is dangerous

ALL includes REMOVE, and cascade = CascadeType.ALL tends to get copied onto every association:

src/main/java/com/example/demo/product/Product.java
    @ManyToOne(fetch = FetchType.LAZY, optional = false) 
    @ManyToOne(fetch = FetchType.LAZY, optional = false, cascade = CascadeType.ALL) 
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;
 
    @ManyToMany
    @ManyToMany(cascade = CascadeType.ALL) 
    @JoinTable(name = "product_tags",

A new product, Webcam 1080p, in a new Webcams category with a new streaming tag, then productRepository.deleteById(webcamId):

Text
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
select t1_0.product_id,t1_1.id,t1_1.name from product_tags t1_0 join tags t1_1 on t1_1.id=t1_0.tag_id where t1_0.product_id=?
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
delete from product_tags where product_id=?
delete from tags where id=?
delete from products where id=?
delete from categories where id=?

The category and tag names, read with JdbcTemplate before the delete:

Text
categories: [Keyboards, Mice, Accessories, Webcams]
tags: [wireless, bestseller, usb-c, rgb, mechanical, streaming]

and after it:

Text
categories: [Keyboards, Mice, Accessories]
tags: [wireless, bestseller, usb-c, rgb, mechanical]

Deleting a product deleted a category and a tag, without a warning, because no other row referenced them. The same delete on a Gaming mouse in the shared Mice category with the shared wireless tag got as far as the tag. Its DELETE statements, then the exception:

Text
delete from product_tags where product_id=?
delete from tags where id=?
Text
org.springframework.dao.DataIntegrityViolationException: could not execute statement [Referential integrity constraint violation: "FKPUR2885QB9AE6FIQUU77TCV1O: PUBLIC.PRODUCT_TAGS FOREIGN KEY(TAG_ID) REFERENCES PUBLIC.TAGS(ID) (CAST(1 AS BIGINT))"; SQL statement:
delete from tags where id=? [23503-240]] [delete from tags where id=?]; SQL [delete from tags where id=?]; constraint [FKPUR2885QB9AE6FIQUU77TCV1O]

So the outcome depends on the data: the delete either removes rows that the rest of the catalogue still expects, or fails on a foreign key. A category or a tag has a life of its own, and cascading from the many side to the one side, or across a @ManyToMany, removes it with the first product that goes. Without those two cascades, deleting the webcam ran only:

Text
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
delete from product_tags where product_id=?
delete from products where id=?

and both lists still ended with Webcams and streaming.

FetchType defaults and LazyInitializationException

The default fetch type of each annotation

Jakarta Persistence makes the single-valued associations eager and the collections lazy:

AnnotationDefault fetchWhat it did in this catalogue
@ManyToOneEAGERfindById joined categories; findAll() added one SELECT per distinct category
@OneToOneEAGERcustomerRepository.findAll() added one SELECT per customer for the profile
@OneToManyLAZYorder.getLines() ran its own SELECT on first use
@ManyToManyLAZYproduct.getTags() ran its own SELECT on first use

The @OneToOne row, from customerRepository.findAll() with the first mapping:

Text
select c1_0.id,c1_0.email from customers c1_0
select cp1_0.id,c1_0.id,c1_0.email,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 left join customers c1_0 on c1_0.id=cp1_0.customer_id where cp1_0.customer_id=?
select cp1_0.id,c1_0.id,c1_0.email,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 left join customers c1_0 on c1_0.id=cp1_0.customer_id where cp1_0.customer_id=?
select cp1_0.id,c1_0.id,c1_0.email,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 left join customers c1_0 on c1_0.id=cp1_0.customer_id where cp1_0.customer_id=?

The section on @ManyToOne put fetch = FetchType.LAZY on both sides of this relationship. On Customer.profile, the mappedBy side, it did not make the profile lazy: customerRepository.findById(1L) ran two statements before any code touched the profile,

Text
select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?

and findAll() still ran one extra SELECT per customer. A customers row has no column that says whether a profile exists, so Hibernate queries customer_profiles to choose between null and a lazy reference. That is the SELECT in the POST of the cascade section. When a profile is rarely needed with its customer, map the relationship only on CustomerProfile and look the profile up by customer id.

The order endpoints

The endpoints use the URLs from article 15 and map entities to DTOs in the controller, as article 21 settled. The request carries a list of lines, and the response nests one record per line:

src/main/java/com/example/demo/order/PlaceOrderRequest.java
package com.example.demo.order;
 
import java.util.List;
 
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
 
public record PlaceOrderRequest(@NotEmpty List<@Valid Line> lines) {
 
    public record Line(@NotNull Long productId, @NotNull @Positive Integer quantity) {
    }
}
src/main/java/com/example/demo/order/OrderResponse.java
package com.example.demo.order;
 
import java.math.BigDecimal;
import java.util.List;
 
public record OrderResponse(Long id, Long customerId, List<OrderLineResponse> lines, BigDecimal total) {
 
    public record OrderLineResponse(Long productId, int quantity, BigDecimal unitPrice, BigDecimal lineTotal) {
 
        static OrderLineResponse from(OrderLine line) {
            return new OrderLineResponse(line.getProduct().getId(), line.getQuantity(),
                    line.getUnitPrice(), line.lineTotal());
        }
    }
 
    public static OrderResponse from(Order order) {
        return new OrderResponse(order.getId(), order.getCustomer().getId(),
                order.getLines().stream().map(OrderLineResponse::from).toList(), order.total());
    }
}
src/main/java/com/example/demo/order/OrderController.java
package com.example.demo.order;
 
import java.net.URI;
import java.util.List;
 
import jakarta.validation.Valid;
 
import org.springframework.http.ResponseEntity;
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.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
 
@RestController
public class OrderController {
 
    private final OrderService service;
 
    public OrderController(OrderService service) {
        this.service = service;
    }
 
    @PostMapping("/api/customers/{customerId}/orders")
    public ResponseEntity<OrderResponse> place(@PathVariable Long customerId,
                                               @Valid @RequestBody PlaceOrderRequest request) {
        List<OrderItem> items = request.lines().stream()
                .map(line -> new OrderItem(line.productId(), line.quantity()))
                .toList();
        Order order = service.place(customerId, items);
        URI location = ServletUriComponentsBuilder.fromCurrentContextPath()
                .path("/api/orders/{id}")
                .buildAndExpand(order.getId())
                .toUri();
        return ResponseEntity.created(location).body(OrderResponse.from(order));
    }
 
    @GetMapping("/api/orders/{id}")
    public OrderResponse findById(@PathVariable Long id) {
        return OrderResponse.from(service.findById(id));
    }
 
    @GetMapping("/api/orders")
    public List<OrderResponse> findAll() {
        return service.findAll().stream().map(OrderResponse::from).toList();
    }
}

OrderResponse.from runs in the controller, after OrderService.findById has returned, and it reads order.getLines().

LazyInitializationException with open-in-view=false

Article 26 switched spring.jpa.open-in-view off and named LazyInitializationException as the reason. This subsection and the next run the order endpoint with the setting off and with its default, true, to show what it changes. Started with --spring.jpa.open-in-view=false:

Bash
curl -s -i http://localhost:8128/api/orders/1
Text
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:15 GMT
Connection: close
 
{"timestamp":"2026-09-13T09:25:15.863Z","status":500,"error":"Internal Server Error","path":"/api/orders/1"}

The log, run with logging.level.org.springframework.orm.jpa=debug and logging.level.org.springframework.web.servlet.DispatcherServlet=debug and trimmed to the relevant lines without their timestamps:

Text
o.s.web.servlet.DispatcherServlet        : GET "/api/orders/1", parameters={}
o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [com.example.demo.order.OrderService.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
o.s.orm.jpa.JpaTransactionManager        : Opened new EntityManager [SessionImpl(651741364<open>)] for JPA transaction
org.hibernate.SQL                        : select o1_0.id,o1_0.customer_id from orders o1_0 where o1_0.id=?
o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(651741364<open>)]
o.s.orm.jpa.JpaTransactionManager        : Closing JPA EntityManager after transaction
o.s.web.servlet.DispatcherServlet        : Failed to complete request: org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)
Text
org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)
	at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:664) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
	at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:239) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
	at org.hibernate.collection.spi.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:624) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
	at org.hibernate.collection.spi.AbstractPersistentCollection.read(AbstractPersistentCollection.java:149) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
	at org.hibernate.collection.spi.PersistentBag.iterator(PersistentBag.java:419) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
	at com.example.demo.order.OrderResponse.from(OrderResponse.java:18) ~[!/:0.0.1-SNAPSHOT]
	at com.example.demo.order.OrderController.findById(OrderController.java:41) ~[!/:0.0.1-SNAPSHOT]

The transaction of findById closed its EntityManager at commit, and the order left the service with lines still unloaded. Line 18 of OrderResponse was the first use of the collection, and there was no session left to load it with. Line 17 did not fail: order.getCustomer().getId() read the id of the lazy customer reference without a query. No @ExceptionHandler covers the exception, so Spring Boot's error handling answered 500. Lazy loading needs an open persistence context; where a transaction starts and ends is the subject of article 30.

What open-in-view=true hides

The same request with the default setting returned 200 and the expected JSON. The log shows how:

Text
o.s.web.servlet.DispatcherServlet        : GET "/api/orders/1", parameters={}
o.j.s.OpenEntityManagerInViewInterceptor : Opening JPA EntityManager in OpenEntityManagerInViewInterceptor
o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [com.example.demo.order.OrderService.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
org.hibernate.SQL                        : select o1_0.id,o1_0.customer_id from orders o1_0 where o1_0.id=?
o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(1177722833<open>)]
o.s.orm.jpa.JpaTransactionManager        : Not closing pre-bound JPA EntityManager after transaction
org.hibernate.SQL                        : select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
o.j.s.OpenEntityManagerInViewInterceptor : Closing JPA EntityManager in OpenEntityManagerInViewInterceptor
o.s.web.servlet.DispatcherServlet        : Completed 200 OK

OpenEntityManagerInViewInterceptor opened an EntityManager before the controller ran and closed it at the end of the request. The transaction committed, Not closing pre-bound JPA EntityManager after transaction kept that EntityManager alive, and the second SELECT ran from OrderResponse.from in the controller, after the commit. Nothing fails with the default, and every lazy association the web layer touches becomes a query sent from the controller or from Jackson. The rest of this article runs with spring.jpa.open-in-view=false, as article 26 left it.

Fetching the lines in the repository

The fix is to load what the endpoint needs before the service returns:

src/main/java/com/example/demo/order/OrderRepository.java
    @EntityGraph(attributePaths = "lines")
    Optional<Order> findWithLinesById(Long id);
src/main/java/com/example/demo/order/OrderService.java
    @Transactional(readOnly = true)
    public Order findById(Long id) {
        return orders.findById(id).orElseThrow(() -> new OrderNotFoundException(id)); 
        return orders.findWithLinesById(id).orElseThrow(() -> new OrderNotFoundException(id)); 
    }

findWithLinesById is a derived query from article 27, where the words between find and By are free text, and @EntityGraph adds the lines to it. With open-in-view=false, the request ran one statement and completed with 200:

Text
select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id where o1_0.id=?

The N+1 query problem

A lazy collection that works still has a cost when it is read in a loop. The picture follows Order.lines through this section and the previous one.

Four steps over time: findAll() loads ten orders with every lines collection unloaded in 1 SELECT; mapping each order inside the persistence context loads the lines one order at a time for 11 SELECTs; touching lines after the context closed throws LazyInitializationException and returns HTTP 500; a query with left join fetch o.lines loads all of them in 1 SELECT

Ten orders, eleven SELECT statements

GET /api/orders with OrderService.findAll calling orders.findAll(), the controller mapping every order, and open-in-view at its default true, the setting under which this goes unnoticed:

Text
select o1_0.id,o1_0.customer_id from orders o1_0
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?

Between GET "/api/orders" and Completed 200 OK, the log held 11 org.hibernate.SQL lines: one query for the ten orders, then one for the lines of each order as OrderResponse.from reached it. That is the N+1 problem, one query for the list and N more for an association of its elements. No product SELECT appears, because line.getProduct().getId() reads the id of a lazy reference.

Fix 1: JOIN FETCH in a @Query

src/main/java/com/example/demo/order/OrderRepository.java
    @Query("select o from Order o left join fetch o.lines order by o.id")
    List<Order> findAllWithLines();
src/main/java/com/example/demo/order/OrderService.java
    @Transactional(readOnly = true)
    public List<Order> findAll() {
        return orders.findAll(); 
        return orders.findAllWithLines(); 
    }

With open-in-view=false, the same request:

Text
select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id order by o1_0.id

One statement. The join returns one row per line, twenty rows for ten orders, yet the JSON array held 10 orders: Hibernate 7 removes the duplicate parents of a fetch join itself, so the query needs no distinct. left join keeps orders that have no lines; a plain join fetch would drop them.

Fix 2: @EntityGraph

The same result without writing JPQL, by annotating the inherited findAll:

src/main/java/com/example/demo/order/OrderRepository.java
package com.example.demo.order;
 
import java.util.List;
import java.util.Optional;
 
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
 
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @Query("select o from Order o left join fetch o.lines order by o.id")
    List<Order> findAllWithLines();
 
    @Override
    @EntityGraph(attributePaths = "lines") 
    List<Order> findAll(); 
 
    @EntityGraph(attributePaths = "lines")
    Optional<Order> findWithLinesById(Long id);
}

orders.findAll(), mapped to the same DTOs:

Text
select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id

Again one statement, generated from the entity graph as a left join. The difference is scope: @EntityGraph on findAll applies to every caller of findAll, while findAllWithLines is a separate method that only the list endpoint calls. Batch fetching and the rest of N+1 tuning belong to the Advanced course.

Returning entities with bidirectional relationships as JSON

What Jackson 3 does with the Order entity

A throwaway endpoint that returns the entity itself, run while OrderService.findById still used the plain orders.findById(id) and open-in-view was at its default, so lazy loading could happen while Jackson wrote:

Java
@GetMapping("/lab/orders/{id}/entity")
Order entity(@PathVariable Long id) {
    return service.findById(id);
}
Bash
curl -s -i http://localhost:8128/lab/orders/1/entity

The headers and the start of the body:

Text
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:12 GMT
 
{"customer":{"email":"an@example.com","hibernateLazyInitializer":{},"id":1,"profile":{"fullName":"Nguyen Van An","phone":"0901000001","customer":{"email":"an@example.com","hibernateLazyInitializer":{},"id":1,"profile":{"fullName":"Nguyen Van An","phone":"0901000001",

The body is 33,682 bytes and never reaches the order's own fields. It breaks here:

Text
"phone":"0901000001","customer":{"email":"an@example.com","hibernateLazyInitializer":}}}}}}}}}}}}}

The last 500 characters are all closing braces, and Python's json.loads rejects the body with Expecting value: line 1 column 33183 (char 33182). The application log:

Text
.w.s.m.s.DefaultHandlerExceptionResolver : Ignoring exception, response committed already: org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Document nesting depth (501) exceeds the maximum allowed (500, from `StreamWriteConstraints.getMaxNestingDepth()`)
.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Document nesting depth (501) exceeds the maximum allowed (500, from `StreamWriteConstraints.getMaxNestingDepth()`)]
o.s.web.servlet.DispatcherServlet        : Completed 200 OK
  • Alphabetical order decided the path. Jackson 3 writes class properties alphabetically, as article 18 measured, so customer came before id and lines. "lines" does not occur anywhere in the body.
  • The cycle was Customer.profile and CustomerProfile.customer. "customer" occurs 250 times and "profile" 249 times. Jackson followed the pair until the document was 501 levels deep; StreamWriteConstraints.defaults().getMaxNestingDepth() returns 500 in Jackson 3.1.5.
  • The status was already sent. By the time the limit was hit the response had been committed, so Spring could only log response committed already. The client got a 200 with invalid JSON.
  • hibernateLazyInitializer is Hibernate's. Order.customer is a lazy proxy, and one of the proxy class's getters was serialized as an empty object. A plain JsonMapper.builder().build() in Jackson 3.1.5 reports SerializationFeature.FAIL_ON_EMPTY_BEANS as disabled.

With open-in-view=false the same endpoint answered 500 instead:

Text
.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Could not initialize proxy [com.example.demo.customer.Customer#1] - no session]

Either way, what the client receives is decided by the entity graph and by Hibernate internals rather than by an API contract, which is the argument article 18 made against exposing entities.

OrderResponse with nested line DTOs

OrderResponse and its nested OrderLineResponse from the section on fetch types are that contract: they copy ids and values, so there is no back-reference for Jackson to follow and no proxy to serialize. With the fetching repository methods and open-in-view=false:

Bash
curl -s -i http://localhost:8128/api/orders/1
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 178
Date: Sun, 13 Sep 2026 09:25:19 GMT
 
{"id":1,"customerId":1,"lines":[{"productId":1,"quantity":1,"unitPrice":89.90,"lineTotal":89.90},{"productId":2,"quantity":2,"unitPrice":24.50,"lineTotal":49.00}],"total":138.90}

The request logged exactly one SQL statement, the left join from findWithLinesById. Placing an order through the POST endpoint:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1},{"productId":2,"quantity":2}]}' http://localhost:8128/api/customers/1/orders
Text
HTTP/1.1 201
Location: http://localhost:8128/api/orders/11
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:19 GMT
 
{"id":11,"customerId":1,"lines":[{"productId":1,"quantity":1,"unitPrice":89.90,"lineTotal":89.90},{"productId":2,"quantity":2,"unitPrice":24.50,"lineTotal":49.00}],"total":138.90}

GET /api/orders/11 then returned the same body with Content-Length: 179, both lines included.

JPA relationship annotations at a glance

AnnotationDefault fetchOwning sideSchema it producedSettings for this catalogue
@ManyToOneEAGERAlways this sideA foreign key column in its own table, products.category_idfetch = LAZY, optional = false, @JoinColumn(nullable = false), no cascade
@OneToManyLAZYThe child's @ManyToOne, through mappedByNo column with mappedBy; a join table such as orders_lines without itmappedBy, a List is fine, cascade = ALL and orphanRemoval = true for lines that belong to the order, addLine and removeLine helpers
@OneToOneEAGERThe side with @JoinColumn; the other uses mappedByA foreign key column with a UNIQUE constraint, customer_profiles.customer_idfetch = LAZY, optional = false, nullable = false; the mappedBy side still costs a SELECT
@ManyToManyLAZYThe side with @JoinTable; the other uses mappedByA join table with two foreign keys, product_tags, whose primary key is both columns with a SetSet, an explicit @JoinTable, no REMOVE or ALL cascade

FAQ

What is the difference between the owning side and the inverse side?

The owning side is the field JPA reads to write the foreign key: the side with @JoinColumn or @JoinTable, and always the @ManyToOne of a one-to-many pair. The inverse side carries mappedBy and is ignored when writing. Changing only order.getLines() saved a line with order_id NULL; setting OrderLine.order is what fills the column.

Why is the foreign key null after saving a @OneToMany collection?

Because only the collection, the inverse side, was changed. The INSERT takes the foreign key from the child's @ManyToOne, which was still null. Add a helper such as addLine that sets both sides, and nullable = false so that a forgotten side fails with NULL not allowed for column "ORDER_ID" instead of saving a disconnected row.

Should @ManyToOne be LAZY?

In most applications, yes. The default EAGER joined categories into every findById of a product, added one SELECT per category to findAll(), and chained from orders to customers to profiles. With LAZY, the category was loaded only when getName() was called, and an endpoint that needs it can fetch it in its query.

Should I use Set or List for @ManyToMany?

Set. Removing one tag from a List deleted all of the product's rows in product_tags and inserted the others again; with a Set it was one DELETE, the join table got a primary key, and a fetch join over two collections stopped failing with MultipleBagFetchException. A List is fine for a @OneToMany with mappedBy, where every child row has its own id.

How do I fix LazyInitializationException in Spring Boot?

Load what the caller needs while the persistence context is still open, with a JOIN FETCH query or an @EntityGraph on the repository method, and map to DTOs. In Hibernate 7.4 the message reads Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session). Turning open-in-view back on hides the exception but moves the queries into the web layer.

Does JOIN FETCH still need distinct in Hibernate 7?

Not for a fetch join. select o from Order o left join fetch o.lines order by o.id returns one row per line, twenty rows for ten orders, yet the JSON of GET /api/orders held 10 orders: Hibernate 7 removes the duplicate parents of a fetch join itself.

Why does returning a JPA entity from a controller produce broken JSON?

Jackson follows every getter, including both directions of a bidirectional relationship and Hibernate's proxy classes. With Spring Boot 4.1.1 and Jackson 3.1.5, the Order entity went back and forth between Customer.profile and CustomerProfile.customer until it hit the nesting limit of 500, and the client received a 200 with 33,682 bytes of invalid JSON. Return DTOs.

Conclusion

Each relationship annotation is a decision about a foreign key. @ManyToOne puts a column on the many side, @OneToOne is that column with a UNIQUE constraint, @ManyToMany needs a join table, and a @OneToMany without mappedBy creates a join table nobody asked for, at five INSERTs for a two-line order instead of three. In a bidirectional pair only the owning side is written, so a helper method sets both sides and nullable = false turns a forgotten side into an error rather than a row with a NULL foreign key.

For this catalogue that means LAZY and optional = false on every @ManyToOne, a Set for @ManyToMany, cascade = ALL with orphanRemoval only where children belong to their parent, and no cascade towards categories or tags, which a single delete removed without a warning. Collections stay lazy, so each endpoint fetches what it maps: JOIN FETCH or @EntityGraph turned 11 statements into one and made open-in-view=false safe. Entities stay behind DTOs, because Jackson cannot be trusted with a graph that points back at itself.

The next article pages through this data: pagination and sorting with Pageable and Sort, and returning paged results through the API.

Related Posts

[Spring Boot Basics] JPA Auditing in Spring Boot: @CreatedDate, @LastModifiedDate and @CreatedBy

Spring Data JPA auditing on Spring Boot 4.1.1 with PostgreSQL: @EnableJpaAuditing, AuditingEntityListener and a @MappedSuperclass base class, a Flyway migration adding NOT NULL audit columns to a table with rows, the silent nulls without the annotation or the listener, Instant vs LocalDateTime vs OffsetDateTime and what timestamptz stores, when @LastModifiedDate moves and what modifyOnCreate changes, the detached save() that writes null into created_at and @Column(updatable = false), @CreatedBy from an X-User header through AuditorAware, a Clock-backed DateTimeProvider, the bulk and native updates that bypass auditing, and Hibernate @CreationTimestamp and @UpdateTimestamp compared.

[Spring Boot Basics] Validation in Spring Boot: Bean Validation Annotations, @Valid and Custom Validators

Bean Validation in Spring Boot 4.1.1 with Hibernate Validator: spring-boot-starter-validation, @NotNull vs @NotEmpty vs @NotBlank, @Size, @DecimalMin, @Digits, @Email and @Pattern on request DTO records, @Valid on @RequestBody and the default 400, nested objects and lists, @PathVariable and @RequestParam validation and the @Validated 500 trap, validation groups, ValidationMessages.properties and Accept-Language, custom ConstraintValidator and cross-field constraints, and validation in the service layer.

[Spring Boot Basics] Unit Testing in Spring Boot: JUnit 6, AssertJ and Mockito for the Service Layer

Unit testing the service layer of a Spring Boot 4.1.1 application with JUnit, AssertJ and Mockito: what a unit test replaces, the Gradle test task and its report, a new test instance per method proven by identity, @Nested and parameterized display names in JUnit 6, the BigDecimal isEqualTo trap and soft assertions with their failure messages, @Mock with constructor injection versus @InjectMocks passing null, stubbing, verify and ArgumentCaptor, UnnecessaryStubbingException and PotentialStubbingProblem under strict stubs, a fixed Clock, and loading Mockito as a -javaagent to remove the self-attaching warning.

[Spring Boot Basics] Productivity Tools in Spring Boot: DevTools, Lombok and Actuator Basics

Spring Boot DevTools, Lombok and Actuator on Spring Boot 4.1.1: why developmentOnly keeps DevTools out of bootJar, the base and restart classloaders with a measured 0.185 s restart against a 1.488 s cold start, triggering restarts with ./gradlew -t classes, why a Gradle resource build restarts the app anyway, the property defaults DevTools applies and LiveReload deprecated in 4.1.0; what Lombok generates according to javap, @Value and @Builder against Java records with Jackson 3 and @Jacksonized, the @Data entity traps (StackOverflowError, a HashSet that loses an entity, LazyInitializationException, @Builder without a no-args constructor) and the safe subset; Actuator /actuator, /actuator/health with show-details and a 503 DOWN, exposure of /actuator/info with build, git, java and os info, why include=* is dangerous, and securing Actuator next to a securityMatcher("/api/**") chain.