LearnAI ToolsCareerPractice BuildsPlayContact
HibernateIntermediate~2 hours

Order Management System

Model Orders, Customers, and OrderItems with one-to-many relationships.

One-to-ManyCascadingTransactions

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.

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

EntityPurposeKey Fields / Mapping
CustomerOne row per customerid (PK), name, email, orders (@OneToMany, mappedBy = "customer", no cascade)
OrderOne row per placed orderid (PK), orderDate, customer (@ManyToOne), items (@OneToMany, CascadeType.ALL + orphanRemoval)
OrderItemOne row per line item within an orderid (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.

Why NOT cascade Customer to Order

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.

RelationshipCascade SettingEffect
Order -> OrderItemCascadeType.ALL, orphanRemoval = trueSaving/deleting an Order saves/deletes all its items; removing an item from the list deletes it
Customer -> OrderNone (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();
}
}
Generated SQL on Success

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"))
));
Result

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();
Result

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();
Result

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.