JPA / Hibernate Patterns
Entity Design
@Entity
@Table(name = "orders",
indexes = @Index(name = "idx_orders_customer", columnList = "customer_id,created_at"))
@EntityListeners(AuditingEntityListener.class)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_seq", allocationSize = 50)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status = OrderStatus.PENDING;
@CreatedDate
@Column(updatable = false)
private Instant createdAt;
@Version
private Long version; // optimistic locking
// Bidirectional helper methods
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
}
Solving N+1 with JOIN FETCH
// BAD — triggers N+1: one query for orders, N for each customer
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class).getResultList();
// GOOD — single JOIN query
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items WHERE o.status = :status")
List<Order> findWithDetails(@Param("status") OrderStatus status);
// GOOD — @BatchSize for collections
@OneToMany(mappedBy = "order")
@BatchSize(size = 20)
private List<OrderItem> items;
// GOOD — @EntityGraph
@EntityGraph(attributePaths = {"customer", "items", "items.product"})
Optional<Order> findById(Long id);
Projections & DTOs
// Interface projection — no entity loaded
public interface OrderSummary {
Long getId();
String getCustomerName();
BigDecimal getTotalAmount();
OrderStatus getStatus();
}
@Query("SELECT o.id AS id, c.name AS customerName, o.totalAmount AS totalAmount, o.status AS status " +
"FROM Order o JOIN o.customer c WHERE o.createdAt > :since")
List<OrderSummary> findSummariesSince(@Param("since") Instant since);
// Class projection (constructor expression)
@Query("SELECT new com.example.dto.OrderDto(o.id, c.name, o.totalAmount) FROM Order o JOIN o.customer c")
List<OrderDto> findOrderDtos();
Criteria API (Type-Safe Queries)
public List<Order> search(OrderSearchRequest req) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> query = cb.createQuery(Order.class);
Root<Order> root = query.from(Order.class);
root.fetch("customer", JoinType.LEFT);
List<Predicate> predicates = new ArrayList<>();
if (req.status() != null) predicates.add(cb.equal(root.get("status"), req.status()));
if (req.since() != null) predicates.add(cb.greaterThan(root.get("createdAt"), req.since()));
if (req.minAmount() != null) predicates.add(cb.ge(root.get("totalAmount"), req.minAmount()));
query.where(predicates.toArray(Predicate[]::new))
.orderBy(cb.desc(root.get("createdAt")));
return em.createQuery(query).setMaxResults(req.limit()).getResultList();
}
Transactions
@Service
@Transactional(readOnly = true)
public class OrderService {
@Transactional // overrides class-level readOnly
public Order placeOrder(PlaceOrderRequest req) {
Order order = new Order();
order.setCustomer(customerRepo.getReferenceById(req.customerId()));
req.items().forEach(item -> order.addItem(buildItem(item)));
return orderRepo.save(order);
}
// Requires existing transaction — throws if none
@Transactional(propagation = Propagation.MANDATORY)
public void recalculateTotals(Order order) { ... }
// New independent transaction
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditLog(Long orderId, String action) { ... }
}
Second-Level Cache
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product { ... }
@OneToMany
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private Set<Tag> tags;
spring.jpa.properties:
hibernate.cache.use_second_level_cache: true
hibernate.cache.use_query_cache: true
hibernate.cache.region.factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
Auditing
@Configuration
@EnableJpaAuditing
public class JpaConfig {}
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
@CreatedDate @Column(updatable = false) protected Instant createdAt;
@LastModifiedDate protected Instant updatedAt;
@CreatedBy @Column(updatable = false) protected String createdBy;
@LastModifiedBy protected String updatedBy;
}
Key Rules
- Always use
FetchType.LAZY for associations; eager loading is almost always wrong
- Use
JOIN FETCH or @EntityGraph to load associations when needed — not FetchType.EAGER
- Use
getReferenceById instead of findById when you only need a proxy for a foreign key
@Version for optimistic locking — prevents lost updates in concurrent scenarios
- Never call
save() on an entity already managed by the current session — it's a no-op at best, confusing at worst
- Use sequence generators with
allocationSize > 1 to reduce DB roundtrips for ID generation