Defining Entities in JPA
In From SQL to JPA, we observed how the supplied mappings reconstructed an object graph, cascaded an order’s deletion to its lines, and deleted a line removed from its order. Let’s now examine the annotations that specify those policies.
From the JPA specification, we will retain that an entity is a lightweight persistence domain object. Typically, an entity represents a table in a relational database, and each entity instance corresponds to a row in that table.
An entity class must follow these requirements.
In particular, it must be annotated with the jakarta.persistence.Entity annotation.
Let’s review the entity classes for a very simple ecommerce data model.
1import jakarta.persistence.Entity;
2import jakarta.persistence.GeneratedValue;
3import jakarta.persistence.GenerationType;
4import jakarta.persistence.Id;
5import jakarta.persistence.Table;
6
7@Entity
8@Table(name = "products")
9public class Product {
10
11 @Id
12 @GeneratedValue(strategy = GenerationType.IDENTITY)
13 private Long id;
14
15 private String name;
16
17 private double price;
18
19 public Long getId() {
20 return id;
21 }
22
23 public String getName() {
24 return name;
25 }
26
27 public void setName(String name) {
28 this.name = name;
29 }
30
31 public double getPrice() {
32 return price;
33 }
34
35 public void setPrice(double price) {
36 this.price = price;
37 }
38
39}Each property maps to a column.
Primary keys are annotated with the @Id annotation.
1import jakarta.persistence.Entity;
2import jakarta.persistence.FetchType;
3import jakarta.persistence.GeneratedValue;
4import jakarta.persistence.GenerationType;
5import jakarta.persistence.Id;
6import jakarta.persistence.JoinColumn;
7import jakarta.persistence.ManyToOne;
8import jakarta.persistence.Table;
9
10@Entity
11@Table(name = "order_lines")
12public class OrderLine {
13
14 @Id
15 @GeneratedValue(strategy = GenerationType.IDENTITY)
16 private Long id;
17
18 private int quantity;
19
20 @ManyToOne
21 private Product product;
22
23 @ManyToOne(fetch = FetchType.LAZY)
24 @JoinColumn(name = "order_id")
25 private Order order;
26
27 public Long getId() {
28 return id;
29 }
30
31 public int getQuantity() {
32 return quantity;
33 }
34
35 public void setQuantity(int quantity) {
36 this.quantity = quantity;
37 }
38
39 public Product getProduct() {
40 return product;
41 }
42
43 public void setProduct(Product product) {
44 this.product = product;
45 }
46
47 public Order getOrder() {
48 return order;
49 }
50
51 public void setOrder(Order order) {
52 this.order = order;
53 }
54
55}@ManyToOne specifies a single-valued association to another entity class.
For example, an order line belongs to a single order, and is associated with a single product.
The fetch element controls when the association is loaded.
For @ManyToOne, FetchType.EAGER is the default and requires eager fetching; FetchType.LAZY is a hint to defer loading until the association is accessed.
1import jakarta.persistence.CascadeType;
2import jakarta.persistence.Entity;
3import jakarta.persistence.GeneratedValue;
4import jakarta.persistence.GenerationType;
5import jakarta.persistence.Id;
6import jakarta.persistence.OneToMany;
7import jakarta.persistence.Table;
8
9import java.util.ArrayList;
10import java.util.List;
11
12@Entity
13@Table(name = "orders")
14public class Order {
15
16 @Id
17 @GeneratedValue(strategy = GenerationType.IDENTITY)
18 private Long id;
19
20 private String customer;
21
22 @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
23 private List<OrderLine> orderLines = new ArrayList<>();
24
25 public Long getId() {
26 return id;
27 }
28
29 public String getCustomer() {
30 return customer;
31 }
32
33 public void setCustomer(String customer) {
34 this.customer = customer;
35 }
36
37 public List<OrderLine> getOrderLines() {
38 return orderLines;
39 }
40
41 public void setOrderLines(List<OrderLine> orderLines) {
42 this.orderLines.clear();
43 if (orderLines != null) {
44 for (OrderLine ol : orderLines) {
45 addOrderLine(ol);
46 }
47 }
48 }
49
50 public void addOrderLine(OrderLine ol) {
51 ol.setOrder(this);
52 this.orderLines.add(ol);
53 }
54
55 public void removeOrderLine(OrderLine ol) {
56 ol.setOrder(null);
57 this.orderLines.remove(ol);
58 }
59
60}It is common to map @ManyToOne relationships in both directions using the @OneToMany annotation.
In this case, the mappedBy element is added to reference the inverse @ManyToOne relationship.
Here, OrderLine.order is the JPA owning side: its @JoinColumn maps the foreign key in order_lines.
Order.orderLines is the inverse side, identified by mappedBy = "order".
This technical ownership is distinct from the domain composition: the order still controls its lines' lifecycle.
The helper methods keep both Java sides consistent.
The cascade element specifies which persistence operations propagate to the associated entities.
CascadeType.ALL includes persist, merge, remove, refresh, and detach.
In particular, removing an order propagates removal to its lines.
Separately, orphanRemoval = true deletes a line removed from a managed order’s collection when the change is flushed.
It implements our domain rule that a line cannot outlive its membership in an order.
Neither behavior comes automatically from the foreign key, and neither is Java garbage collection.
There is no cascade from OrderLine.product to Product, so deleting an order or removing a line does not delete the referenced products.
The N+1 Query Problem
We mentioned that lazy navigation can turn into many small queries. Here is exactly how that happens.
Suppose we flip two fetch types: OrderLine.product becomes @ManyToOne(fetch = FetchType.LAZY), and Order.orderLines becomes eager, so that loading an order brings all of its lines along with it.
Now suppose we want the total price of one order.
1Order order = entityManager.find(Order.class, orderId);
2
3BigDecimal total = BigDecimal.ZERO;
4for (OrderLine line : order.getOrderLines()) {
5 total = total.add(line.getProduct().getPrice().multiply(BigDecimal.valueOf(line.getQuantity())));
6}entityManager.find(…) already brought back the order together with all of its lines: orderLines is eager, so there is nothing left to fetch there.
But product is lazy, so each OrderLine only holds a proxy for it, not the product itself.
The loop then calls getProduct() on every line in turn, and the first time a given line’s proxy is touched, Hibernate fires off a fresh SELECT * FROM products WHERE id = ? right there.
One query to fetch the order and its lines, plus up to one more per line: in the worst case, where every line references a different product, that is N + 1 queries to price a single order, where a single join would have done.
With three lines in a demo, this is invisible; with an order that has a hundred lines, it silently turns one query into a hundred and one round trips to the database.
This is not a reason to default every association back to FetchType.EAGER: that only moves the extra queries earlier (or replaces them with one large, wasteful join), it does not remove them.
The fix is to fetch what a given use case actually needs, in one query, when we write that query, typically with an explicit JOIN FETCH or an @EntityGraph, both beyond the scope of this introduction.
The rule of thumb for now: never walk a lazy association inside a loop without first checking how it was fetched.