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:

@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:

This separation is good design — entities live inside the persistence layer; the rest of the application uses immutable DTOs.

Relationships

The four relationship types:

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:

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:

@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.

Query strategies

Repository methods

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.

Native queries

@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:

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

@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

@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:

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

Further Reading