| name | jpa-patterns |
| description | JPA/Hibernate optimization patterns, entity design, and query performance for Spring Data |
JPA Patterns and Optimization
Entity Design
Base Entity with Auditing
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@CreatedDate
@Column(updatable = false)
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@Version
private Long version;
}
Entity Relationships
@Entity
@Table(name = "orders")
public class Order extends BaseEntity {
@Column(nullable = false)
private String customerId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status = OrderStatus.DRAFT;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal totalAmount = BigDecimal.ZERO;
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
recalculateTotal();
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
recalculateTotal();
}
private void recalculateTotal() {
this.totalAmount = items.stream()
.map(OrderItem::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order other)) return false;
return getId() != null && getId().equals(other.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@Entity
@Table(name = "order_items")
public class OrderItem extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id")
private Order order;
@Column(nullable = false)
private String productId;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal unitPrice;
public BigDecimal getSubtotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}
Query Optimization
Avoiding N+1 Queries
List<Order> orders = orderRepository.findAll();
orders.forEach(o -> o.getItems().size());
public interface OrderRepository extends JpaRepository<Order, String> {
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") OrderStatus status);
@EntityGraph(attributePaths = {"items"})
List<Order> findByStatus(OrderStatus status);
}
Projection for Read-Only Queries
public interface OrderSummary {
String getId();
String getCustomerId();
OrderStatus getStatus();
BigDecimal getTotalAmount();
Instant getCreatedAt();
}
public interface OrderRepository extends JpaRepository<Order, String> {
List<OrderSummary> findSummaryByCustomerId(String customerId);
@Query("""
SELECT new com.app.dto.OrderStatsDto(
o.status,
COUNT(o),
SUM(o.totalAmount)
)
FROM Order o
WHERE o.createdAt >= :since
GROUP BY o.status
""")
List<OrderStatsDto> getOrderStats(@Param("since") Instant since);
}
Pagination
@GetMapping
public ResponseEntity<Page<OrderDto>> listOrders(
@RequestParam(required = false) OrderStatus status,
@ParameterObject Pageable pageable) {
var orders = orderRepository.findByStatus(status, pageable);
return ResponseEntity.ok(orders.map(orderMapper::toDto));
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
var resolver = new PageableHandlerMethodArgumentResolver();
resolver.setMaxPageSize(100);
resolver.setFallbackPageable(PageRequest.of(0, 20, Sort.by("createdAt").descending()));
resolvers.add(resolver);
}
}
Batch Operations
@Transactional
public void importProducts(List<CreateProductRequest> requests) {
var batchSize = 50;
var products = new ArrayList<Product>(batchSize);
for (int i = 0; i < requests.size(); i++) {
products.add(productMapper.toEntity(requests.get(i)));
if (products.size() >= batchSize) {
productRepository.saveAll(products);
entityManager.flush();
entityManager.clear();
products.clear();
}
}
if (!products.isEmpty()) {
productRepository.saveAll(products);
}
}
Specifications for Dynamic Queries
public class OrderSpecifications {
public static Specification<Order> byCustomer(String customerId) {
return (root, query, cb) ->
customerId == null ? null : cb.equal(root.get("customerId"), customerId);
}
public static Specification<Order> byStatus(OrderStatus status) {
return (root, query, cb) ->
status == null ? null : cb.equal(root.get("status"), status);
}
public static Specification<Order> createdAfter(Instant after) {
return (root, query, cb) ->
after == null ? null : cb.greaterThanOrEqualTo(root.get("createdAt"), after);
}
public static Specification<Order> amountBetween(BigDecimal min, BigDecimal max) {
return (root, query, cb) -> {
if (min == null && max == null) return null;
if (min != null && max != null) return cb.between(root.get("totalAmount"), min, max);
if (min != null) return cb.greaterThanOrEqualTo(root.get("totalAmount"), min);
return cb.lessThanOrEqualTo(root.get("totalAmount"), max);
};
}
}
public Page<OrderDto> searchOrders(OrderSearchRequest request, Pageable pageable) {
var spec = Specification.where(byCustomer(request.customerId()))
.and(byStatus(request.status()))
.and(createdAfter(request.fromDate()))
.and(amountBetween(request.minAmount(), request.maxAmount()));
return orderRepository.findAll(spec, pageable).map(orderMapper::toDto);
}
Performance Checklist
- Lazy Loading -- Default
FetchType.LAZY for all @ManyToOne and @OneToMany
- Fetch Joins -- Use
JOIN FETCH or @EntityGraph to load related data in one query
- Projections -- Use interface or DTO projections for read-only views
- Pagination -- Always paginate list queries with reasonable defaults
- Batch Operations -- Configure batch size for bulk inserts/updates
- Indexes -- Add
@Index annotations for frequently queried columns
- Second-Level Cache -- Enable for stable reference data
- Read-Only Transactions -- Use
@Transactional(readOnly = true) for queries
- Query Logging -- Enable SQL logging in development to detect N+1 issues
- Connection Pool -- Tune HikariCP settings for expected load