Hibernate & Entity Mapping

Project: Mapping a Domain Model

18 min Lesson 10 of 13

Project: Mapping a Domain Model

This lesson brings together everything covered in the tutorial by mapping a realistic e-commerce domain from scratch. You will see how each JPA/Hibernate concept — entities, primary keys, column mappings, embeddables, inheritance, and the persistence context — works together in a coherent, production-quality model. Pay attention not just to the annotations, but to the design decisions and the performance trade-offs explained along the way.

The Domain

The system models an online store with the following business concepts:

  • Customer — a registered user with a mailing address and a billing address.
  • Product — a catalog item with a price and stock level; subtypes PhysicalProduct and DigitalProduct are handled through inheritance.
  • Order — placed by a customer, composed of one or more line items.
  • OrderLine — the join between an order and a product, carrying quantity and the unit price at the time of purchase.

This is a small but complete slice of a real domain. It exercises every major mapping technique without becoming unwieldy.

Shared Base Entity

Rather than repeat audit columns on every table, extract them into a mapped superclass. This is not an entity itself — it has no table — but JPA inherits its mappings into every subclass.

package com.example.shop.domain; import jakarta.persistence.*; import java.time.Instant; @MappedSuperclass public abstract class BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @Column(name = "updated_at", nullable = false) private Instant updatedAt; @PrePersist void onPersist() { createdAt = updatedAt = Instant.now(); } @PreUpdate void onUpdate() { updatedAt = Instant.now(); } // getters omitted for brevity }
Why @MappedSuperclass instead of a concrete entity? A mapped superclass contributes columns to its subclass tables but is never queried directly. If you used a concrete entity with TABLE_PER_CLASS inheritance instead, Hibernate would have to union several tables for a polymorphic query — expensive and usually unnecessary for audit fields alone.

Embeddable Address

An address is a value object — it has no identity of its own; it belongs entirely to a customer. Model it as @Embeddable so its columns live in the customers table without a separate join.

package com.example.shop.domain; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; @Embeddable public class Address { @Column(name = "street", length = 200) private String street; @Column(name = "city", length = 100) private String city; @Column(name = "country", length = 2) // ISO 3166-1 alpha-2 private String country; @Column(name = "postal_code", length = 20) private String postalCode; // no-arg constructor required by JPA protected Address() {} public Address(String street, String city, String country, String postalCode) { this.street = street; this.city = city; this.country = country; this.postalCode = postalCode; } // getters, equals, hashCode omitted for brevity }

Customer Entity

The Customer entity embeds two Address instances. Because both map to the same embeddable class, you must use @AttributeOverrides to give their columns distinct names.

package com.example.shop.domain; import jakarta.persistence.*; import java.util.ArrayList; import java.util.List; @Entity @Table(name = "customers", uniqueConstraints = @UniqueConstraint(name = "uq_customer_email", columnNames = "email")) public class Customer extends BaseEntity { @Column(name = "email", nullable = false, length = 254) private String email; @Column(name = "display_name", nullable = false, length = 100) private String displayName; @Embedded @AttributeOverrides({ @AttributeOverride(name = "street", column = @Column(name = "mail_street")), @AttributeOverride(name = "city", column = @Column(name = "mail_city")), @AttributeOverride(name = "country", column = @Column(name = "mail_country")), @AttributeOverride(name = "postalCode", column = @Column(name = "mail_postal_code")) }) private Address mailingAddress; @Embedded @AttributeOverrides({ @AttributeOverride(name = "street", column = @Column(name = "bill_street")), @AttributeOverride(name = "city", column = @Column(name = "bill_city")), @AttributeOverride(name = "country", column = @Column(name = "bill_country")), @AttributeOverride(name = "postalCode", column = @Column(name = "bill_postal_code")) }) private Address billingAddress; @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List<Order> orders = new ArrayList<>(); protected Customer() {} public Customer(String email, String displayName, Address mailingAddress, Address billingAddress) { this.email = email; this.displayName = displayName; this.mailingAddress = mailingAddress; this.billingAddress = billingAddress; } public void placeOrder(Order order) { orders.add(order); order.setCustomer(this); } }
Bidirectional relationships: always sync both sides. The placeOrder helper method keeps orders and order.customer consistent. If you set only one side, the persistence context — which is the source of truth until flush — will hold an inconsistent object graph and you will see confusing behaviour.

Product Hierarchy — Joined Inheritance

Physical and digital products share catalog attributes but need separate columns. JOINED inheritance puts the shared columns in products and the subtype-specific columns in physical_products / digital_products. Polymorphic queries stay clean; reads cost one extra join per subtype.

// --- Product.java --- @Entity @Table(name = "products") @Inheritance(strategy = InheritanceType.JOINED) @DiscriminatorColumn(name = "product_type", discriminatorType = DiscriminatorType.STRING) public abstract class Product extends BaseEntity { @Column(name = "name", nullable = false, length = 200) private String name; @Column(name = "price_cents", nullable = false) private long priceCents; // store money as integer cents — never double @Column(name = "active", nullable = false) private boolean active = true; protected Product() {} public Product(String name, long priceCents) { this.name = name; this.priceCents = priceCents; } } // --- PhysicalProduct.java --- @Entity @Table(name = "physical_products") @DiscriminatorValue("PHYSICAL") public class PhysicalProduct extends Product { @Column(name = "weight_grams", nullable = false) private int weightGrams; @Column(name = "stock_qty", nullable = false) private int stockQty; protected PhysicalProduct() {} public PhysicalProduct(String name, long priceCents, int weightGrams, int stockQty) { super(name, priceCents); this.weightGrams = weightGrams; this.stockQty = stockQty; } } // --- DigitalProduct.java --- @Entity @Table(name = "digital_products") @DiscriminatorValue("DIGITAL") public class DigitalProduct extends Product { @Column(name = "download_url", nullable = false, length = 500) private String downloadUrl; @Column(name = "file_size_mb") private int fileSizeMb; protected DigitalProduct() {} public DigitalProduct(String name, long priceCents, String downloadUrl, int fileSizeMb) { super(name, priceCents); this.downloadUrl = downloadUrl; this.fileSizeMb = fileSizeMb; } }
Money as long cents, never double. Floating-point arithmetic is not safe for financial calculations. Storing the price as integer cents (or using BigDecimal) prevents rounding errors from silently corrupting totals.

Order and OrderLine

An Order owns its OrderLine items. The line is a dependent object — it makes no sense outside its parent order — so model it with CascadeType.ALL and orphanRemoval = true.

@Entity @Table(name = "orders") public class Order extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "customer_id", nullable = false, foreignKey = @ForeignKey(name = "fk_orders_customer")) private Customer customer; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 20) private OrderStatus status = OrderStatus.PENDING; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List<OrderLine> lines = new ArrayList<>(); protected Order() {} public Order(Customer customer) { this.customer = customer; } public void addLine(Product product, int qty) { lines.add(new OrderLine(this, product, qty, product.getPriceCents())); } void setCustomer(Customer customer) { this.customer = customer; } } // Enum (store as string so schema changes do not break existing rows) public enum OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED } // --- OrderLine.java --- @Entity @Table(name = "order_lines") public class OrderLine extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "order_id", nullable = false, foreignKey = @ForeignKey(name = "fk_order_lines_order")) private Order order; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "product_id", nullable = false, foreignKey = @ForeignKey(name = "fk_order_lines_product")) private Product product; @Column(name = "quantity", nullable = false) private int quantity; @Column(name = "unit_price_cents", nullable = false) private long unitPriceCents; // snapshot at time of purchase protected OrderLine() {} OrderLine(Order order, Product product, int quantity, long unitPriceCents) { this.order = order; this.product = product; this.quantity = quantity; this.unitPriceCents = unitPriceCents; } }
Snapshot pricing on OrderLine. The unitPriceCents is copied from the product at the moment the line is created, not stored as a foreign key to a price table. This is a fundamental e-commerce pattern: a customer\'s receipt must always reflect what they paid, regardless of future price changes.

Wiring It Together in a Spring Boot Service

With the model in place, a service method can create a complete order in a single transaction. Hibernate handles the cascade — persisting the Order automatically persists all its OrderLine children.

@Service @Transactional public class OrderService { private final EntityManager em; public OrderService(EntityManager em) { this.em = em; } public Long placeOrder(Long customerId, Map<Long, Integer> productQtyMap) { Customer customer = em.find(Customer.class, customerId); if (customer == null) { throw new EntityNotFoundException("Customer " + customerId); } Order order = new Order(customer); customer.placeOrder(order); // keeps both sides of the relationship in sync productQtyMap.forEach((productId, qty) -> { Product product = em.find(Product.class, productId); if (product == null) { throw new EntityNotFoundException("Product " + productId); } order.addLine(product, qty); }); em.persist(order); // cascades to all OrderLines automatically return order.getId(); } }

Schema Generation Verification

Set spring.jpa.hibernate.ddl-auto=create-drop in your test application.properties and boot the application against an H2 in-memory database. Inspect the generated DDL — you should see the customers table with eight address columns, the products/physical_products/digital_products join, and explicit foreign-key constraint names matching what you declared. Correct DDL before writing a single query.

Named constraints pay off at 3 a.m. Declaring @ForeignKey(name = "fk_orders_customer") means your database error messages and migration scripts use human-readable names instead of auto-generated ones like FK3j2jx.... It is a small annotation with outsized operational value.

Summary

A well-mapped domain model is the foundation every JPA application is built on. The patterns demonstrated here — a @MappedSuperclass for audit columns, @Embeddable for value objects, JOINED inheritance for product subtypes, bidirectional @OneToMany/@ManyToOne with explicit cascade, and price snapshots on line items — are all standard idioms you will encounter and apply in real production codebases. With this tutorial complete, you have the mapping vocabulary to model almost any business domain confidently.