LearnAI ToolsCareerPractice BuildsPlayContact
Spring BootIntermediate~2 hours

Multi-Entity Data Model

Model related entities (e.g. Orders and Products) with JPA relationships.

Entity RelationshipsJPARepositories

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.

What You'll Build
  • 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.

model/Product.java
package com.programinds.orders.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
public 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.

model/Order.java
package com.programinds.orders.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
public 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);
}
}
model/OrderItem.java
package com.programinds.orders.model;
import jakarta.persistence.*;
@Entity
public 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.LAZYFetchType.EAGER
When the related row loadsOnly when the getter is actually calledImmediately, every time the owning entity loads
Query cost for a single entityOne query now, a second query later if neededOne (larger) query, always
Risk when listing many entitiesCan trigger the N+1 problem (Step 6) if the getter is called in a loopLoads data that may never be used, even when the getter is never called
Default for `@ManyToOne`/`@OneToOne`Not the default — EAGER isThe JPA specification default
Used in this projectYes, 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.java
public interface ProductRepository extends JpaRepository<Product, Long> {
}
// repository/OrderRepository.java
public 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.

service/OrderService.java
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;
@Service
public 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.

controller/OrderController.java
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.

repository/OrderRepository.java (updated)
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();
}
Why LEFT, Not Just JOIN

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.

dto/OrderItemRequest.java
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;
}
}
OrdersApplication.java
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;
@SpringBootApplication
public 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

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.