Overview
An order-management domain is where one-to-many mappings and cascading stop being abstract JPA trivia and start mattering for correctness: a `Customer` places many `Order`s, and each `Order` contains many `OrderItem`s, but the two `@OneToMany` relationships in that chain should not behave the same way. Saving a new order should automatically save its line items — they have no independent existence outside the order that contains them — but deleting a customer should never silently cascade into deleting every order they ever placed. Hibernate's `CascadeType` is the mechanism for making that distinction precise instead of accidental.
By the end of this tutorial you will have three mapped entities — `Customer`, `Order`, and `OrderItem` — connected by two `@OneToMany`/`@ManyToOne` pairs with deliberately different cascade configurations, plus a transactional method that creates an order and all of its items as a single atomic unit of work: either everything commits together, or nothing does.
- A `Customer` entity with a `@OneToMany` collection of orders that does NOT cascade removes.
- An `Order` entity that is the `@ManyToOne` child of `Customer` and the `@OneToMany` parent of `OrderItem`.
- An `OrderItem` entity mapped with `CascadeType.ALL` and `orphanRemoval = true` from `Order`.
- A `placeOrder()` method that creates an order with multiple items inside one transaction.
- A rollback demonstration showing that a mid-transaction failure leaves the database unchanged.
- HQL and `session.get()` calls proving the cascades behave exactly as configured.
Prerequisites
- Everything from the Student-Course Enrollment System project — `@Entity`, `@Id`, basic HQL.
- Java classes and collections — `List`, in particular, since order matters for a customer's order history.
- What an ORM is, and specifically what "cascading" means: propagating an operation on a parent entity to its related child entities.
- Transaction basics — commit vs. rollback, and why grouping related writes into one transaction matters.
- Basic JPA annotations from earlier in this course: `@OneToMany`, `@ManyToOne`, `@JoinColumn`.
Project Structure
Three entities form a parent-child-grandchild chain: `Customer` is the top-level parent, `Order` is both a child of `Customer` and a parent of `OrderItem`, and `OrderItem` is the leaf. Every `@OneToMany` in this project is paired with a `@ManyToOne` on the other side — Hibernate does not infer bidirectional relationships automatically, so both annotations, plus a `mappedBy` on the "many" side's owner, have to be written explicitly.
| Entity | Purpose | Key Fields / Mapping |
|---|---|---|
| Customer | One row per customer | id (PK), name, email, orders (@OneToMany, mappedBy = "customer", no cascade) |
| Order | One row per placed order | id (PK), orderDate, customer (@ManyToOne), items (@OneToMany, CascadeType.ALL + orphanRemoval) |
| OrderItem | One row per line item within an order | id (PK), productName, quantity, price, order (@ManyToOne, owning side) |
The owning side of a `@OneToMany`/`@ManyToOne` pair is always the `@ManyToOne` side — that is where the foreign key column physically lives (`orders.customer_id`, `order_items.order_id`). `@OneToMany` almost always needs `mappedBy` pointing back at that field, exactly like the inverse side of `@ManyToMany` in the previous project; without it, Hibernate would create an unnecessary extra join table instead of using the foreign key column that is already there.
Step 1: Map the Customer Entity
`Customer.orders` is the inverse side of a `@OneToMany`, mapped back to the `customer` field that Step 2 declares on `Order`. Deliberately, no `CascadeType` is set here at all — the default JPA behavior for an uncascaded relationship, where saving or deleting a `Customer` has no automatic effect on their `Order`s. That is not an oversight; it is the point of this whole project, explained fully in Step 4.
import jakarta.persistence.*;import java.util.ArrayList;import java.util.List;
@Entity@Table(name = "customers")public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private String name;
@Column(nullable = false, unique = true) private String email;
// Inverse side, mappedBy "customer" (the @ManyToOne field on Order, Step 2). // No CascadeType here on purpose: removing a Customer must never silently // remove their entire order history — see the callout in Step 4. @OneToMany(mappedBy = "customer") private List<Order> orders = new ArrayList<>();
public Customer() {}
public Customer(String name, String email) { this.name = name; this.email = email; }
public Long getId() { return id; } public String getName() { return name; } public String getEmail() { return email; } public List<Order> getOrders() { return orders; }}Step 2: Map the Order Entity
`Order` sits in the middle of the chain, so it plays both roles at once: `customer` is a `@ManyToOne` (the owning side of the relationship with `Customer`, holding the actual `customer_id` foreign key column), while `items` is a `@OneToMany` (the inverse side of the relationship with `OrderItem`, whose owning `@ManyToOne` comes in Step 3). `FetchType.LAZY` on `customer` avoids loading a customer's full record every time an order is fetched when only the order's own fields are needed.
import jakarta.persistence.*;import java.time.LocalDate;import java.util.ArrayList;import java.util.List;
@Entity@Table(name = "orders")public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private LocalDate orderDate;
// Owning side of the Customer relationship: this is where the customer_id // foreign key column actually lives. LAZY avoids fetching the full Customer // row every time an Order is loaded, when most operations only need the order itself. @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id", nullable = false) private Customer customer;
// Inverse side of the relationship with OrderItem — cascade and orphanRemoval // are set here (not on Customer.orders above) because an order's line items // genuinely cannot exist without the order; see Step 4 for the full reasoning. @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderItem> items = new ArrayList<>();
public Order() {}
public Order(Customer customer, LocalDate orderDate) { this.customer = customer; this.orderDate = orderDate; }
public Long getId() { return id; } public LocalDate getOrderDate() { return orderDate; } public Customer getCustomer() { return customer; } public List<OrderItem> getItems() { return items; }
// Helper that keeps both sides of the Order/OrderItem relationship in sync, // the same pattern Student.enrollIn() used in the previous project. public void addItem(OrderItem item) { items.add(item); item.setOrder(this); }}Step 3: Map the OrderItem Entity
`OrderItem` is the owning side of its relationship with `Order`, so it carries the `@JoinColumn` for `order_id`. It has a `setOrder()` setter — unlike most entities in this course, which keep every field immutable after construction — specifically so `Order.addItem()` in Step 2 can wire the back-reference without needing a full second constructor argument list.
import jakarta.persistence.*;import java.math.BigDecimal;
@Entity@Table(name = "order_items")public class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private String productName;
private int quantity;
@Column(nullable = false) private BigDecimal price; // BigDecimal, not double: money must never lose precision to floating-point rounding
// Owning side: order_id foreign key column lives here. LAZY again, for the // same reason as Order.customer — loading one OrderItem should not force // its parent Order to load too unless something actually asks for it. @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id", nullable = false) private Order order;
public OrderItem() {}
public OrderItem(String productName, int quantity, BigDecimal price) { this.productName = productName; this.quantity = quantity; this.price = price; }
public Long getId() { return id; } public String getProductName() { return productName; } public int getQuantity() { return quantity; } public BigDecimal getPrice() { return price; } public Order getOrder() { return order; } public void setOrder(Order order) { this.order = order; } // Package-visible-in-spirit: only Order.addItem() should call this}Step 4: Choose Cascade Types Deliberately
The two `@OneToMany` relationships in this project use opposite cascade strategies, and that contrast is the actual lesson. `Order.items` uses `CascadeType.ALL` plus `orphanRemoval = true`: persisting or deleting an `Order` automatically persists or deletes every `OrderItem` in its `items` list, and removing an item from the list (without deleting it explicitly) deletes that row too, because a line item genuinely has no meaning detached from its order. `Customer.orders`, by contrast, sets no cascade at all: an application that deleted a `Customer` and had that silently cascade-delete their entire order history would be destroying financial records as a side effect of an unrelated action — exactly the kind of accidental data loss cascading is meant to prevent, not cause.
Deleting a customer account (e.g. for GDPR-style account closure) should be a separate, explicit decision about what happens to their historical orders — archive them, anonymize them, or reassign them — never an automatic side effect of `session.remove(customer)`. Leaving `Customer.orders` uncascaded forces that decision to be made deliberately in application code instead of happening by accident.
| Relationship | Cascade Setting | Effect |
|---|---|---|
| Order -> OrderItem | CascadeType.ALL, orphanRemoval = true | Saving/deleting an Order saves/deletes all its items; removing an item from the list deletes it |
| Customer -> Order | None (default) | Saving/deleting a Customer has no automatic effect on their Orders |
Step 5: Create an Order Atomically
`placeOrder()` wraps the whole operation — creating the `Order`, adding every `OrderItem`, and persisting the result — in one transaction. Because `items` cascades with `CascadeType.ALL`, a single `session.persist(order)` is enough to also insert every `OrderItem` in its list; there is no need to call `session.persist()` on each item separately. If anything throws between `beginTransaction()` and `commit()`, the `catch` block calls `rollback()` and nothing — not the order, not any of its items — is left in the database, which is exactly what "atomically" means here.
import org.hibernate.Session;import org.hibernate.Transaction;import java.math.BigDecimal;import java.time.LocalDate;import java.util.List;
public Order placeOrder(Customer customer, List<OrderItem> lineItems) { Session session = HibernateUtil.openSession(); Transaction transaction = null; try { transaction = session.beginTransaction(); // Everything below succeeds or fails together
Order order = new Order(customer, LocalDate.now()); for (OrderItem item : lineItems) { order.addItem(item); // Wires both sides of the Order <-> OrderItem relationship (Step 2's helper) }
session.persist(order); // CascadeType.ALL means every OrderItem in order.items is persisted too — no extra calls needed
transaction.commit(); // Only now does anything actually reach the database return order; } catch (RuntimeException e) { if (transaction != null) { transaction.rollback(); // Undo everything attempted in this transaction; the database is left unchanged } throw e; // Re-throw so the caller knows order creation failed } finally { session.close(); }}Click Run to see what this code prints.
Complete Code
The three entities and the transactional `placeOrder()` method, assembled from every step above.
@Entity@Table(name = "customers")public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private String name;
@Column(nullable = false, unique = true) private String email;
@OneToMany(mappedBy = "customer") // No cascade: deleting a Customer never cascades to their Orders private List<Order> orders = new ArrayList<>();
public Customer() {} public Customer(String name, String email) { this.name = name; this.email = email; } public Long getId() { return id; } public String getName() { return name; } public String getEmail() { return email; } public List<Order> getOrders() { return orders; }}
@Entity@Table(name = "orders")public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private LocalDate orderDate;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id", nullable = false) private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderItem> items = new ArrayList<>();
public Order() {} public Order(Customer customer, LocalDate orderDate) { this.customer = customer; this.orderDate = orderDate; } public Long getId() { return id; } public LocalDate getOrderDate() { return orderDate; } public Customer getCustomer() { return customer; } public List<OrderItem> getItems() { return items; } public void addItem(OrderItem item) { items.add(item); item.setOrder(this); }}
@Entity@Table(name = "order_items")public class OrderItem { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private String productName;
private int quantity;
@Column(nullable = false) private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id", nullable = false) private Order order;
public OrderItem() {} public OrderItem(String productName, int quantity, BigDecimal price) { this.productName = productName; this.quantity = quantity; this.price = price; } public Long getId() { return id; } public String getProductName() { return productName; } public int getQuantity() { return quantity; } public BigDecimal getPrice() { return price; } public Order getOrder() { return order; } public void setOrder(Order order) { this.order = order; }}Sample Run
Three operations against the schema above: placing an order with two items, removing one item from an existing order (triggering `orphanRemoval`), and confirming that deleting a customer leaves their orders untouched.
// 1. Place an order with two line items (Step 5)Order order = placeOrder(customer, List.of( new OrderItem("Wireless Mouse", 2, new BigDecimal("499.00")), new OrderItem("USB-C Cable", 1, new BigDecimal("299.00"))));Click Run to see what this code prints.
// 2. Remove one item from the order's collection and commit — orphanRemoval// deletes the OrderItem row even though it was never explicitly session.remove()'d.transaction = session.beginTransaction();Order managedOrder = session.get(Order.class, order.getId());managedOrder.getItems().removeIf(item -> item.getProductName().equals("USB-C Cable"));transaction.commit();Click Run to see what this code prints.
// 3. Delete the customer directly (no cascade configured on Customer.orders) —// this fails with a foreign key constraint violation while orders still reference them,// which is the uncascaded relationship doing exactly its job: refusing a silent side effect.transaction = session.beginTransaction();Customer managedCustomer = session.get(Customer.class, customer.getId());session.remove(managedCustomer);transaction.commit();Click Run to see what this code prints.
Extend This Project
- Add an `orderTotal()` method on `Order` that sums `quantity * price` across `items`, and write an HQL query that returns the same total using `SUM`.
- Add an `OrderStatus` enum field (`PENDING`, `SHIPPED`, `DELIVERED`, `CANCELLED`) to `Order` and update `placeOrder()` to set it explicitly.
- Add a `deactivateCustomer()` method that sets an `active` flag to `false` instead of deleting the row, sidestepping the foreign-key conflict from the Sample Run entirely.
- Wrap `placeOrder()` in a stock-check that throws a custom checked exception (and triggers a rollback) if any item's quantity exceeds available inventory.
- Add a `@OneToMany` from `Order` to a new `Payment` entity, applying the same cascade-vs-no-cascade reasoning from Step 4 to decide whether payments should cascade-delete with their order.
Summary
You mapped a two-level `@OneToMany`/`@ManyToOne` chain — `Customer` to `Order` to `OrderItem` — and used two different `CascadeType` configurations deliberately: full cascading with `orphanRemoval` where child records have no independent meaning, and no cascading at all where an automatic side effect would be dangerous. The transactional `placeOrder()` method you wrote is the pattern every multi-entity write in a real Hibernate application follows: one `Session`, one `Transaction`, and a `catch`/`rollback` that guarantees partial writes never reach the database.