JPA (Java Persistence API) is the standard ORM specification; Hibernate is the dominant implementation. Spring Boot uses Hibernate by default. ORMs solve the boilerplate of mapping rows to objects but introduce their own complexity: N+1 queries, lazy loading exceptions, transaction boundaries.
This page covers the patterns that make JPA/Hibernate sustainable in production, and the cases where dropping to JDBC is the right answer.
A basic entity:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private BigDecimal amount;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
// getters, setters, no-arg constructor required by JPA
}
Note the requirements: no-arg constructor, mutable fields. This is why records cannot be JPA entities directly.
Records are immutable; JPA needs mutability. Use the pattern:
This separation is good design — entities live inside the persistence layer; the rest of the application uses immutable DTOs.
The four relationship types:
@OneToOne: one-to-one (rare in practice)@OneToMany: one parent, many children (common)@ManyToOne: inverse of OneToMany; many children point to one parent@ManyToMany: many-to-many via join tableFor OneToMany, the choice of mappedBy (the reverse side declares the foreign key) vs. join table is real. mappedBy is the default and usually right.
The single biggest source of pain in JPA.
@OneToMany(fetch = FetchType.LAZY): the children are not loaded until accessed.
@OneToMany(fetch = FetchType.EAGER): loaded immediately.
The right default is LAZY for collections, EAGER for @ManyToOne. Eager-loading collections produces unbounded query expansion ("LazyInitializationException" being the lesser evil).
The "LazyInitializationException": accessing a lazy collection outside the persistence context (e.g., after the transaction has closed). Common cause: returning entities from controllers.
The fix: don't return entities. Map to DTOs inside the transaction.
The classic ORM failure mode:
List<Order> orders = repository.findAll();
for (Order order : orders) {
System.out.println(order.getCustomer().getName()); // triggers a query per order
}
Each access to getCustomer() produces a query. 1 query for the orders + N queries for customers = N+1.
JOIN FETCH in a query:
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
EntityGraph:
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findAllWithCustomerAndItems();
Batch fetching:
@OneToMany(fetch = FetchType.LAZY)
@BatchSize(size = 50)
private List<OrderItem> items;
Batches lazy fetches into 50-at-a-time queries. Reduces N+1 to N/50 queries.
Spring Data parses method names:
List<Order> findByStatus(OrderStatus status);
List<Order> findByStatusAndAmountGreaterThan(OrderStatus status, BigDecimal amount);
Useful for simple queries. The parsing has limits; complex queries get unreadable.
@Query annotations@Query("SELECT o FROM Order o WHERE o.status = ?1 AND o.amount > ?2")
List<Order> findExpensiveByStatus(OrderStatus status, BigDecimal amount);
JPQL (object-oriented query language). Handles most cases.
@Query(value = "SELECT * FROM orders WHERE complex_condition", nativeQuery = true)
List<Order> findByComplexCondition();
Raw SQL. Useful when JPQL is insufficient. Sacrifices ORM portability.
Programmatic query construction:
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> order = cq.from(Order.class);
cq.where(cb.equal(order.get("status"), OrderStatus.PENDING));
List<Order> result = em.createQuery(cq).getResultList();
Verbose. JPA includes type-safe Criteria via metamodel classes (generated by annotation processing). For dynamic queries, sometimes the right tool; otherwise QueryDSL is more readable.
@Transactional for service methods@Service
public class OrderService {
@Transactional
public Order createOrder(CreateOrderRequest request) {
// multiple JPA operations within one transaction
}
}
Spring's @Transactional opens a transaction at method entry, commits at exit, rolls back on exception.
The right level: a service method is one transaction. Smaller transactions don't compose; larger transactions hold locks unnecessarily.
@Transactional on controllersTransaction at the wrong level — should be at the service layer where the business operation lives.
@Transactional(readOnly = true)
public List<Order> findActive() { /* ... */ }
Hibernate optimizes for read-only (skip dirty checking, etc.). Use it when you're not modifying.
JPA is poor for:
For these, raw JDBC (or Spring's JdbcTemplate, or jOOQ) is often clearer and faster. Use the right tool for the job; don't force JPA where it doesn't fit.
cascade = ALL on relationships can cause unexpected deletes.