Overview
Every project so far in this course has had one entity per table, with no relationships between them. Real applications almost never look like that — an order is made of order items, and each order item refers to a product. Modeling this correctly in JPA means deciding, for every relationship, which side owns it, how eagerly the other side should load, and what happens to the children when the parent is saved or deleted. Get those three decisions wrong and you either corrupt data or silently issue hundreds of extra queries — the second half of this tutorial is about exactly that second failure mode.
By the end of this tutorial you will have three entities — `Product`, `Order`, and `OrderItem` — connected by a `@OneToMany`/`@ManyToOne` pair, a service method that assembles a whole order and its items as one unit of work, and a repository query written specifically to avoid the classic "N+1 selects" problem when listing every order along with its items.
- A `Product` entity representing an item in the catalog, with a name and price.
- An `Order` entity holding a `@OneToMany` collection of `OrderItem`, with `CascadeType.ALL` and `orphanRemoval = true`.
- An `OrderItem` entity that is the `@ManyToOne` child of both `Order` and `Product`.
- An `OrderService.createOrder()` method that looks up products and assembles a whole order as one atomic save.
- A repository query using `JOIN FETCH` to load every order and its items in a single SQL statement.
- A working understanding of `FetchType.LAZY` vs. `FetchType.EAGER` and the N+1 problem they relate to.
Prerequisites
- This course's Task Manager REST API project — `@Entity`, `JpaRepository`, and a REST controller.
- Basic relational database concepts — primary keys, foreign keys, and what a JOIN does.
- Java collections — `List<T>` and the enhanced `for` loop.
- `BigDecimal` basics, used here for monetary values instead of `double` to avoid floating-point rounding errors.
- A Spring Boot 3.x project with the Spring Web, Spring Data JPA, and H2 Database starters.
Project Structure
The project follows the same layered package layout as the rest of this course: `src/main/java/com/programinds/orders/model/` holds `Product`, `Order`, and `OrderItem`; `repository/` holds one `JpaRepository` per entity; `service/` holds `OrderService`, the only place that assembles a multi-entity order as a single operation; `dto/` holds `OrderItemRequest`; and `controller/` holds `OrderController`.
Three entities form a simple parent-child relationship: `Order` is the parent, `OrderItem` is its child, and `Product` is referenced by `OrderItem` but never owned by it — deleting an order should delete its items, but must never delete the products those items referred to. That distinction is exactly what `CascadeType.ALL` on `Order.items` and the deliberate absence of any cascade on `OrderItem.product` are for, both defined in Step 2.
Step 1: Define the Product Entity
`Product` has no relationship annotations of its own in this project — it is referenced by `OrderItem`, not the other way around, so it stays a simple, standalone entity. `price` is typed as `BigDecimal`, not `double`, because binary floating-point numbers cannot represent most decimal fractions exactly, which makes `double` unsafe for money — `BigDecimal` represents decimal values exactly and is the conventional choice for currency in Java.
package com.programinds.orders.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entitypublic class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
// BigDecimal, not double: binary floating-point cannot represent most decimal // fractions exactly, which makes double unsafe for currency. BigDecimal // represents decimal values exactly and is the standard choice for money in Java. private BigDecimal price;
public Product() { }
public Product(String name, BigDecimal price) { this.name = name; this.price = price; }
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }}Step 2: Define Order and OrderItem With JPA Relationships
`Order.items` is the inverse side of the relationship, declared with `mappedBy = "order"` pointing at the field on `OrderItem` that actually owns the foreign key. `CascadeType.ALL` means saving or deleting an `Order` cascades to every `OrderItem` in its list automatically — you never call `orderItemRepository.save()` yourself in Step 4. `orphanRemoval = true` goes one step further: if an item is ever removed from `order.getItems()` without being deleted directly, JPA deletes the now-orphaned row for you. `OrderItem.product`, by contrast, has no cascade at all — deleting an order item must never delete the product it referred to.
package com.programinds.orders.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;import java.util.ArrayList;import java.util.List;
@Entitypublic class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private LocalDateTime orderDate = LocalDateTime.now();
// Inverse side of the relationship: mappedBy points at OrderItem.order below, // which owns the actual foreign key column. CascadeType.ALL means saving or // deleting this Order automatically saves or deletes every item in this list // — no separate call to an OrderItemRepository is ever needed. orphanRemoval // means removing an item from this list (without deleting it directly) also // deletes its row, so items list and database rows can never drift apart. @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderItem> items = new ArrayList<>();
public Long getId() { return id; }
public LocalDateTime getOrderDate() { return orderDate; }
public List<OrderItem> getItems() { return items; }
// Keeps both sides of the bidirectional relationship in sync in one call — // forgetting to also call item.setOrder(this) is the single most common JPA // relationship bug, so it is handled here once instead of at every call site. public void addItem(OrderItem item) { items.add(item); item.setOrder(this); }}package com.programinds.orders.model;
import jakarta.persistence.*;
@Entitypublic class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
// Owning side of the Order relationship: this @JoinColumn is the actual // foreign key column ("order_id") that appears in the order_item table. // FetchType.LAZY means loading an OrderItem on its own does not also load // its parent Order unless getOrder() is actually called. @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id", nullable = false) private Order order;
// References a Product but deliberately has NO cascade configuration — the // default, uncascaded JPA behavior. Deleting an OrderItem (or its parent // Order) must never delete the Product it referred to; a product can be // ordered by many different order items over its lifetime. @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_id", nullable = false) private Product product;
private int quantity;
public Long getId() { return id; }
public Order getOrder() { return order; }
public void setOrder(Order order) { this.order = order; }
public Product getProduct() { return product; }
public void setProduct(Product product) { this.product = product; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }}| FetchType.LAZY | FetchType.EAGER | |
|---|---|---|
| When the related row loads | Only when the getter is actually called | Immediately, every time the owning entity loads |
| Query cost for a single entity | One query now, a second query later if needed | One (larger) query, always |
| Risk when listing many entities | Can trigger the N+1 problem (Step 6) if the getter is called in a loop | Loads data that may never be used, even when the getter is never called |
| Default for `@ManyToOne`/`@OneToOne` | Not the default — EAGER is | The JPA specification default |
| Used in this project | Yes, explicitly set on both `@ManyToOne` fields | — |
Both `@ManyToOne` fields above set `fetch = FetchType.LAZY` explicitly, overriding JPA's own default of `EAGER` for that annotation. This is a deliberate, common override: `@OneToMany`/`@ManyToMany` already default to `LAZY`, and setting `@ManyToOne`/`@OneToOne` to match keeps loading behavior predictable everywhere in the entity graph, rather than eagerly pulling in a parent row every single time a child loads.
Step 3: Create the Repositories
Three plain repositories, one per entity — none of them need custom methods yet beyond what `JpaRepository` already generates. `OrderRepository` gets its one custom query in Step 6, once there is an actual N+1 problem to solve.
// repository/ProductRepository.javapublic interface ProductRepository extends JpaRepository<Product, Long> {}
// repository/OrderRepository.javapublic interface OrderRepository extends JpaRepository<Order, Long> { // findAllWithItems() is added in Step 6, once the N+1 problem it solves is introduced.}
// repository/OrderItemRepository.java// Rarely used directly — CascadeType.ALL on Order.items means OrderService// (Step 4) almost never needs to save or delete an OrderItem on its own.public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {}Step 4: Assemble an Order in the Service Layer
`createOrder()` is marked `@Transactional`, which matters here specifically because it makes multiple database operations — one lookup per product, one insert for the order, one insert per item — behave as a single atomic unit. If a product id in the request does not exist, `orElseThrow()` throws, `@Transactional` rolls back everything that already happened in this method, and no partial order is ever left in the database. `order.addItem(item)` from Step 2 is what keeps both sides of the relationship in sync; `orderRepository.save(order)` alone, thanks to `CascadeType.ALL`, is enough to insert every item too.
package com.programinds.orders.service;
import com.programinds.orders.dto.OrderItemRequest;import com.programinds.orders.model.*;import com.programinds.orders.repository.OrderRepository;import com.programinds.orders.repository.ProductRepository;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;
import java.util.List;import java.util.NoSuchElementException;
@Servicepublic class OrderService {
private final OrderRepository orderRepository; private final ProductRepository productRepository;
public OrderService(OrderRepository orderRepository, ProductRepository productRepository) { this.orderRepository = orderRepository; this.productRepository = productRepository; }
// @Transactional makes every product lookup and every insert below into one // atomic unit: if any product id is invalid, the whole method rolls back and // no half-created order is ever left in the database. @Transactional public Order createOrder(List<OrderItemRequest> itemRequests) { Order order = new Order();
for (OrderItemRequest req : itemRequests) { Product product = productRepository.findById(req.getProductId()) .orElseThrow(() -> new NoSuchElementException("Product not found: " + req.getProductId()));
OrderItem item = new OrderItem(); item.setProduct(product); item.setQuantity(req.getQuantity()); order.addItem(item); // Sets both sides of the relationship (Step 2); order.items and item.order stay consistent }
// CascadeType.ALL on Order.items means this single save() also inserts // every OrderItem in the list — no separate OrderItemRepository call needed. return orderRepository.save(order); }}Step 5: Expose Orders via a REST Controller
`OrderController` stays thin, the same way every controller in this course has — it accepts a request, delegates to a service method, and shapes the HTTP response. The `POST` endpoint accepts a plain `List<OrderItemRequest>`, deliberately not the `Order` entity itself, for the same DTO-boundary reason introduced in the Task Manager project: a client should describe what it wants (products and quantities), not construct the entity graph directly.
package com.programinds.orders.controller;
import com.programinds.orders.dto.OrderItemRequest;import com.programinds.orders.model.Order;import com.programinds.orders.repository.OrderRepository;import com.programinds.orders.service.OrderService;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController@RequestMapping("/api/orders")public class OrderController {
private final OrderService orderService; private final OrderRepository orderRepository;
public OrderController(OrderService orderService, OrderRepository orderRepository) { this.orderService = orderService; this.orderRepository = orderRepository; }
@PostMapping // POST /api/orders public ResponseEntity<Order> create(@RequestBody List<OrderItemRequest> items) { Order order = orderService.createOrder(items); // Step 4 assembles the whole order as one transaction return ResponseEntity.status(HttpStatus.CREATED).body(order); }
@GetMapping // GET /api/orders public List<Order> getAll() { return orderRepository.findAllWithItems(); // Defined in Step 6, avoiding the N+1 problem below }}Step 6: Avoid the N+1 Problem When Listing Orders
If `getAll()` had simply called the inherited `orderRepository.findAll()`, Hibernate would run one query to fetch every order, and then — the moment anything touches `order.getItems()` on a lazy collection, such as when Jackson serializes the response to JSON — one additional query per order to fetch its items. Ten orders means eleven queries total: one plus N. That is the N+1 problem, and it gets dramatically worse as the order count grows.
package com.programinds.orders.repository;
import com.programinds.orders.model.Order;import org.springframework.data.jpa.repository.JpaRepository;import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface OrderRepository extends JpaRepository<Order, Long> {
// LEFT JOIN FETCH pulls every Order and its items back in a single SQL // statement — a proper SQL JOIN — instead of one query per order's lazy // collection. DISTINCT is required because a JOIN produces one result row // per item, which would otherwise duplicate each Order once per item it has. @Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items") List<Order> findAllWithItems();}A plain `JOIN FETCH` would silently drop any order that has zero items, since an inner join only returns rows with a match on both sides. `LEFT JOIN FETCH` keeps every order in the result, with an empty `items` list for the ones that have none — which is almost always the behavior a "list all orders" endpoint actually wants.
Complete Code
Here is the remaining DTO and application entry point, completing the project. Run it with `./mvnw spring-boot:run` and create a couple of products directly through `ProductRepository` (or a small `CommandLineRunner`) before posting an order.
package com.programinds.orders.dto;
public class OrderItemRequest {
private Long productId; private int quantity;
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }}package com.programinds.orders;
import com.programinds.orders.repository.ProductRepository;import com.programinds.orders.model.Product;import org.springframework.boot.CommandLineRunner;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.annotation.Bean;
import java.math.BigDecimal;
@SpringBootApplicationpublic class OrdersApplication { public static void main(String[] args) { SpringApplication.run(OrdersApplication.class, args); }
// Seeds two products at startup purely so this tutorial's Sample Run has // real product ids (1 and 2) to reference — not something a production app would do. @Bean CommandLineRunner seedProducts(ProductRepository productRepository) { return args -> { productRepository.save(new Product("Wireless Mouse", new BigDecimal("19.99"))); productRepository.save(new Product("Mechanical Keyboard", new BigDecimal("89.50"))); }; }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a `Customer` entity with a `@OneToMany` to `Order`, and decide deliberately whether that relationship should cascade deletes the way `Order.items` does, or stay uncascaded the way `OrderItem.product` does.
- Catch `NoSuchElementException` in a `@ControllerAdvice` and return a `404 Not Found` with a structured error body instead of the default `500`.
- Compute and store an order total by summing `item.getProduct().getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))` across `order.getItems()`.
- Add `@ManyToMany` between `Product` and a new `Category` entity, and compare the extra join-table complexity against the `@OneToMany`/`@ManyToOne` pair used here.
- Enable `spring.jpa.properties.hibernate.generate_statistics=true` and inspect the log output to see the query count difference between `findAll()` and `findAllWithItems()` firsthand.
Summary
You modeled a genuine multi-entity relationship in JPA: `Order` owns its `OrderItem`s with `CascadeType.ALL` and `orphanRemoval`, while `OrderItem` only references — never owns — the `Product` it points at, and `OrderService.createOrder()` assembles the whole graph as one atomic, transactional operation. The `LEFT JOIN FETCH` query you wrote in Step 6 is the standard fix for the N+1 problem, and recognizing when a lazy `@OneToMany`/`@ManyToOne` relationship needs one is a skill that applies to nearly every JPA-backed Spring Boot application beyond this tutorial.