Table of Contents
1. Why This Comparison Matters
Most teams start with simple repository methods, but real systems quickly evolve into advanced search screens, dynamic filters, role-based visibility rules, and multi-entity reporting. At this point, choosing the wrong query style can make your persistence layer hard to maintain.
This article compares three common approaches for complex cases:
- Hibernate Criteria API (programmatic query building)
- Spring Data JPA Specification (composable predicates on top of Criteria)
- JPA
@Query(explicit JPQL/native SQL in repository methods)
2. Quick Definitions
2.1. Hibernate Criteria API
Low-level, type-oriented API to build dynamic queries directly using CriteriaBuilder, CriteriaQuery, and Predicate.
2.2. Spring Data JPA Specification
An abstraction layer over Criteria API using reusable specifications that can be combined with and() / or().
2.3. JPA @Query
Static query strings in repository interfaces using JPQL or native SQL. Best when query shape is stable and explicit.
3. Complex Case Example
Suppose we need a search endpoint for Order with all these optional constraints:
- Customer name contains text (case-insensitive)
- Order status in a list of statuses
- Total amount between min and max
- Created date range
- Only orders with at least one delayed shipment
- Tenant isolation and soft-delete filtering
- Sortable and pageable response
This kind of query is where design differences become clear.
4. Hibernate Criteria Approach
Criteria API gives full control and is very powerful for dynamic queries. It is ideal when you need complex predicate trees and advanced joins.
4.1. Example
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> order = cq.from(Order.class);
Join<Order, Customer> customer = order.join("customer", JoinType.INNER);
Join<Order, Shipment> shipment = order.join("shipments", JoinType.LEFT);
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(order.get("tenantId"), tenantId));
predicates.add(cb.isFalse(order.get("deleted")));
if (keyword != null && !keyword.isBlank()) {
predicates.add(cb.like(cb.lower(customer.get("name")), "%" + keyword.toLowerCase() + "%"));
}
if (statuses != null && !statuses.isEmpty()) {
predicates.add(order.get("status").in(statuses));
}
if (minTotal != null) {
predicates.add(cb.greaterThanOrEqualTo(order.get("totalAmount"), minTotal));
}
if (maxTotal != null) {
predicates.add(cb.lessThanOrEqualTo(order.get("totalAmount"), maxTotal));
}
if (startDate != null) {
predicates.add(cb.greaterThanOrEqualTo(order.get("createdAt"), startDate));
}
if (endDate != null) {
predicates.add(cb.lessThanOrEqualTo(order.get("createdAt"), endDate));
}
if (delayedOnly) {
predicates.add(cb.greaterThan(shipment.get("delayedDays"), 0));
}
cq.select(order)
.where(predicates.toArray(new Predicate[0]))
.distinct(true);
4.2. Strengths
- Maximum flexibility and dynamic composition.
- Strong control over joins, grouping, and projection.
- Useful in custom repositories and query engines.
4.3. Weaknesses
- Verbose boilerplate.
- Harder onboarding for new team members.
- Without good helper methods, readability drops quickly.
5. Spring Data Specification Approach
Specification keeps Criteria power but improves structure by splitting conditions into reusable building blocks.
5.1. Reusable Specifications
public class OrderSpecifications {
public static Specification<Order> tenantIs(String tenantId) {
return (root, query, cb) -> cb.equal(root.get("tenantId"), tenantId);
}
public static Specification<Order> notDeleted() {
return (root, query, cb) -> cb.isFalse(root.get("deleted"));
}
public static Specification<Order> customerNameContains(String keyword) {
return (root, query, cb) -> {
if (keyword == null || keyword.isBlank()) return cb.conjunction();
Join<Order, Customer> customer = root.join("customer", JoinType.INNER);
return cb.like(cb.lower(customer.get("name")), "%" + keyword.toLowerCase() + "%");
};
}
public static Specification<Order> hasStatuses(List<OrderStatus> statuses) {
return (root, query, cb) -> (statuses == null || statuses.isEmpty())
? cb.conjunction()
: root.get("status").in(statuses);
}
}
5.2. Composition in Service Layer
Specification<Order> spec = Specification
.where(OrderSpecifications.tenantIs(tenantId))
.and(OrderSpecifications.notDeleted())
.and(OrderSpecifications.customerNameContains(keyword))
.and(OrderSpecifications.hasStatuses(statuses))
.and(OrderSpecifications.totalBetween(minTotal, maxTotal))
.and(OrderSpecifications.createdBetween(startDate, endDate))
.and(OrderSpecifications.hasDelayedShipment(delayedOnly));
Page<Order> page = orderRepository.findAll(spec, pageable);
5.3. Strengths
- Great reusability and testability.
- Excellent for complex search screens with optional filters.
- Keeps repository interfaces clean and small.
5.4. Weaknesses
- Can still become complex with deep joins/subqueries.
- Too many tiny specs may fragment logic if naming is poor.
6. JPA @Query Approach
@Query is ideal when query shape is fixed and readability of explicit JPQL is preferred.
6.1. Example with Optional Parameters
@Query("""
SELECT o
FROM Order o
JOIN o.customer c
LEFT JOIN o.shipments s
WHERE o.tenantId = :tenantId
AND o.deleted = false
AND (:keyword IS NULL OR LOWER(c.name) LIKE LOWER(CONCAT('%', :keyword, '%')))
AND (:minTotal IS NULL OR o.totalAmount >= :minTotal)
AND (:maxTotal IS NULL OR o.totalAmount <= :maxTotal)
AND (:startDate IS NULL OR o.createdAt >= :startDate)
AND (:endDate IS NULL OR o.createdAt <= :endDate)
""")
Page<Order> search(
@Param("tenantId") String tenantId,
@Param("keyword") String keyword,
@Param("minTotal") BigDecimal minTotal,
@Param("maxTotal") BigDecimal maxTotal,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate,
Pageable pageable
);
6.2. Strengths
- Very readable for stable query logic.
- Easy to review during code reviews.
- Good control over exact JPQL statement.
6.3. Weaknesses
- Large optional filters become hard to maintain.
- Difficult reuse across multiple query variants.
- String-based queries are less refactor-friendly.
7. Comparison Matrix
| Criteria | Hibernate Criteria API | Spring Data Specification | JPA @Query |
|---|---|---|---|
| Dynamic filters | Excellent | Excellent | Moderate |
| Readability | Low to Medium | Medium to High | High (for fixed queries) |
| Reusability | Manual | Very High | Low |
| Refactor safety | Good | Good | Lower (string query) |
| Best for complex search screens | Yes | Yes (preferred) | Not ideal |
8. Architecture Recommendations
8.1. Recommended Hybrid Strategy
- Use Specification for user-driven dynamic filtering and paging APIs.
- Use @Query for stable read models and well-defined reports.
- Use raw Criteria API in custom repositories for edge cases (advanced projections/subqueries).
8.2. Team-Level Guidelines
- Standardize naming for specifications (e.g.,
statusIn,createdBetween). - Keep one package for specs and unit-test each one independently.
- Avoid giant
@Querymethods for highly optional filters. - Monitor generated SQL and indexes for all complex paths.
9. Conclusion
For complex enterprise cases, Spring Data Specification usually provides the best balance between power and maintainability. Criteria API remains essential for custom advanced scenarios, while JPA @Query is excellent for stable and explicit query definitions.
The best architecture is not one approach only; it is a clear and consistent combination of all three.
0 Comments