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