JPA and Hibernate Patterns
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.
Entity design
A basic entity:
```java
@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 as DTOs, not entities
Records are immutable; JPA needs mutability. Use the pattern:
- Entities: traditional JavaBean style with mutability
- DTOs: records, used at API and service boundaries
- Mapping between them: manually or with MapStruct
This separation is good design — entities live inside the persistence layer; the rest of the application uses immutable DTOs.
Relationships
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 table
For OneToMany, the choice of `mappedBy` (the reverse side declares the foreign key) vs. join table is real. `mappedBy` is the default and usually right.
Lazy vs. eager loading
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 N+1 problem
The classic ORM failure mode:
```java
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.
Solutions
**JOIN FETCH in a query:**
```java
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
```
**EntityGraph:**
```java
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findAllWithCustomerAndItems();
```
**Batch fetching:**
```java
@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.
Query strategies
Repository methods
Spring Data parses method names:
```java
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
```java
@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.
Native queries
```java
@Query(value = "SELECT * FROM orders WHERE complex_condition", nativeQuery = true)
List<Order> findByComplexCondition();
```
Raw SQL. Useful when JPQL is insufficient. Sacrifices ORM portability.
Criteria API
Programmatic query construction:
```java
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.
Transaction management
`@Transactional` for service methods
```java
@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.
Transaction boundaries match service boundaries
The right level: a service method is one transaction. Smaller transactions don't compose; larger transactions hold locks unnecessarily.
Avoid `@Transactional` on controllers
Transaction at the wrong level — should be at the service layer where the business operation lives.
Read-only transactions
```java
@Transactional(readOnly = true)
public List<Order> findActive() { /* ... */ }
```
Hibernate optimizes for read-only (skip dirty checking, etc.). Use it when you're not modifying.
When to drop to JDBC
JPA is poor for:
- Complex aggregations and reporting queries
- Bulk operations (DELETE/UPDATE many rows)
- Streaming large result sets
- Performance-critical paths where ORM overhead matters
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.
Common failure patterns
- **Returning entities from controllers.** LazyInitializationException; tight coupling between persistence and API.
- **Eager loading collections.** Unbounded query expansion.
- **Open Session in View (OSIV).** Anti-pattern that hides N+1 problems by keeping the session open during view rendering. Disable.
- **Cascading too aggressively.** `cascade = ALL` on relationships can cause unexpected deletes.
- **Mutable equals/hashCode based on database ID.** Before persistence, ID is null; objects are inequal to themselves after save. Use natural keys or UUID strategies.
- **Using JPA for analytics queries.** Wrong tool; use raw SQL.
Further Reading
- [JdbcBestPractices](JdbcBestPractices) — When raw JDBC is the answer
- [SpringBootFundamentals](SpringBootFundamentals) — Spring Data conventions
- [JavaRecordsAndSealedClasses](JavaRecordsAndSealedClasses) — Records as DTOs
- [ImmutableDataPatterns](ImmutableDataPatterns) — Why DTOs should be immutable
- [Java Hub](JavaHub) — Cluster index