Entity Relationships & Associations

Project: A Connected Domain Model

18 min Lesson 10 of 13

Project: A Connected Domain Model

Everything you have studied so far — @OneToOne, @OneToMany, @ManyToMany, fetch types, the N+1 problem, Join Fetch, entity graphs, cascading, and orphan removal — exists to solve real design problems. In this capstone lesson you will wire all of it together into a coherent, production-ready domain model for a small e-commerce application, and you will reason through the trade-offs at every step.

The Domain

The application manages customers who place orders. Each order contains one or more order lines referencing a product. Every customer owns exactly one shopping cart. Products belong to one or more categories. These five entities cover every relationship type discussed in the tutorial.

Entity Design

Start with the Customer aggregate root. It owns the cart via a one-to-one relationship and holds a collection of orders.

@Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String fullName; @Column(nullable = false, unique = true) private String email; // Owning side: customer holds the FK to cart @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @JoinColumn(name = "cart_id") private ShoppingCart cart; @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List<Order> orders = new ArrayList<>(); // Convenience method keeps both sides in sync public void addOrder(Order order) { orders.add(order); order.setCustomer(this); } public void removeOrder(Order order) { orders.remove(order); order.setCustomer(null); } // getters / setters omitted for brevity }

Notice three deliberate choices: FetchType.LAZY on both associations (we fetch eagerly only when we need to), CascadeType.ALL so the customer lifecycle drives its cart and orders, and orphanRemoval = true so removing an order from the collection deletes its row — you do not need a separate repository call.

@Entity @Table(name = "shopping_carts") public class ShoppingCart { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @JoinColumn(name = "cart_id") // unidirectional; no back-reference needed private List<CartItem> items = new ArrayList<>(); }

The Order entity sits on the many side of the customer relationship and owns a collection of OrderLine items.

@Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "customer_id", nullable = false) private Customer customer; @Enumerated(EnumType.STRING) @Column(nullable = false) private OrderStatus status = OrderStatus.PENDING; @Column(nullable = false) private LocalDateTime placedAt = LocalDateTime.now(); @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List<OrderLine> lines = new ArrayList<>(); public void addLine(OrderLine line) { lines.add(line); line.setOrder(this); } }
@Entity @Table(name = "order_lines") public class OrderLine { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "order_id", nullable = false) private Order order; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "product_id", nullable = false) private Product product; @Column(nullable = false) private int quantity; @Column(nullable = false, precision = 10, scale = 2) private BigDecimal unitPrice; }

Finally, Product participates in a many-to-many relationship with Category. The product side owns the join table.

@Entity @Table(name = "products") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(nullable = false, precision = 10, scale = 2) private BigDecimal price; @ManyToMany(fetch = FetchType.LAZY) @JoinTable( name = "product_categories", joinColumns = @JoinColumn(name = "product_id"), inverseJoinColumns = @JoinColumn(name = "category_id") ) private Set<Category> categories = new HashSet<>(); } @Entity @Table(name = "categories") public class Category { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true) private String name; @ManyToMany(mappedBy = "categories", fetch = FetchType.LAZY) private Set<Product> products = new HashSet<>(); }
Why Set for the many-to-many collection? Using a List on both sides of a many-to-many causes Hibernate to fire a DELETE all / INSERT all on every modification (the "bag" problem). A Set on at least the owning side avoids this by letting Hibernate issue targeted INSERTs and DELETEs.

Querying Without N+1

A common use case: show a customer's order history with each line's product name. A naive repository call that lazily loads orders, then lines, then products produces N+1 queries. Use a named entity graph or a JPQL JOIN FETCH to eliminate them.

// Repository public interface OrderRepository extends JpaRepository<Order, Long> { @EntityGraph(attributePaths = {"lines", "lines.product"}) List<Order> findByCustomerId(Long customerId); }

The entity graph tells Hibernate to left-join-fetch lines and then lines.product in a single SQL query — regardless of the LAZY defaults on those associations. You keep lazy loading for every other access pattern while opting in to eagerness exactly where the use case demands it.

One query per use case. Define a separate repository method (or named entity graph) for each distinct fetch requirement. Do not promote an association to EAGER globally just because one screen needs it — that penalises every other caller.

Service-Layer Transactions

All multi-step operations belong inside a single @Transactional boundary so that partial failures roll back cleanly.

@Service @Transactional public class OrderService { private final CustomerRepository customerRepo; private final ProductRepository productRepo; private final OrderRepository orderRepo; public Order placeOrder(Long customerId, List<Long> productIds, Map<Long, Integer> quantities) { Customer customer = customerRepo.findById(customerId) .orElseThrow(() -> new EntityNotFoundException("Customer not found")); Order order = new Order(); for (Long productId : productIds) { Product product = productRepo.findById(productId) .orElseThrow(() -> new EntityNotFoundException("Product " + productId + " not found")); OrderLine line = new OrderLine(); line.setProduct(product); line.setQuantity(quantities.get(productId)); line.setUnitPrice(product.getPrice()); order.addLine(line); } customer.addOrder(order); // No explicit save needed — cascade propagates from customer to order to lines customerRepo.save(customer); return order; } }
Do not call save() on child entities separately when cascade is already configured. Calling orderRepo.save(order) after customerRepo.save(customer) is redundant and can confuse Hibernate's dirty-checking state machine, occasionally causing duplicate inserts or stale-entity exceptions.

Running the Full Stack Locally

Wire the entities with a minimal application.properties targeting an H2 in-memory database so you can iterate fast without a running MySQL instance:

spring.datasource.url=jdbc:h2:mem:shop;DB_CLOSE_DELAY=-1 spring.datasource.driver-class-name=org.h2.Driver spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.h2.console.enabled=true

With ddl-auto=create-drop Hibernate generates all tables from your entity metadata on startup and drops them on shutdown. Combined with show-sql=true you can inspect every generated statement and verify that your join tables, foreign keys, and indexes match your mental model before writing a single migration file.

Summary

A well-designed connected domain model applies every relationship tool in context: @OneToOne with lazy loading and full cascade for tightly owned aggregates; bidirectional @OneToMany / @ManyToOne with orphan removal for parent-child collections; @ManyToMany with a Set on the owning side to avoid the bag problem; and entity graphs or JOIN FETCH queries scoped to individual use cases to eliminate N+1 without global EAGER loading. The service layer wraps mutations in a single @Transactional boundary and lets cascade carry persistence through the object graph. These patterns together produce a codebase that is readable, testable, and efficient under real workloads.