LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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 Orders, and each Order contains many OrderItems, 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 Orders. 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.