Contents
- JPA Specifications
- Specification Factory & Composition
- Querydsl — Type-Safe Predicates
- Custom Repository Fragments
- Interface Projections
- DTO Projections with @Query
- Specification vs Querydsl vs JPQL
A Specification<T> encapsulates a single WHERE predicate. Repositories extend JpaSpecificationExecutor<T> to gain findAll(Specification) and related methods.
<!-- No extra dependency — part of spring-data-jpa -->
// Repository — extend both JpaRepository and JpaSpecificationExecutor
public interface OrderRepository
extends JpaRepository<Order, Long>,
JpaSpecificationExecutor<Order> {
}
// Inline Specification — for simple one-off cases
Specification<Order> byCustomer = (root, query, cb) ->
cb.equal(root.get("customerId"), customerId);
List<Order> orders = orderRepo.findAll(byCustomer);
// Paginated
Page<Order> page = orderRepo.findAll(byCustomer,
PageRequest.of(0, 20, Sort.by("createdAt").descending()));
Group related specifications in a factory class. Compose them with and(), or(), and not() to build complex dynamic filters without if-else chains in service code.
public class OrderSpecs {
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) -> status == null
? cb.conjunction() // no filter
: cb.equal(root.get("status"), status);
}
public static Specification<Order> forCustomer(String customerId) {
return (root, query, cb) -> StringUtils.hasText(customerId)
? cb.equal(root.get("customerId"), customerId)
: cb.conjunction();
}
public static Specification<Order> createdAfter(Instant from) {
return (root, query, cb) -> from == null
? cb.conjunction()
: cb.greaterThanOrEqualTo(root.get("createdAt"), from);
}
public static Specification<Order> totalBetween(BigDecimal min, BigDecimal max) {
return (root, query, cb) -> {
if (min == null && max == null) return cb.conjunction();
if (min == null) return cb.lessThanOrEqualTo(root.get("total"), max);
if (max == null) return cb.greaterThanOrEqualTo(root.get("total"), min);
return cb.between(root.get("total"), min, max);
};
}
// Eagerly join items to avoid N+1 for detail queries
public static Specification<Order> withItemsFetched() {
return (root, query, cb) -> {
if (query.getResultType() != Long.class) { // skip for count queries
root.fetch("items", JoinType.LEFT);
query.distinct(true);
}
return cb.conjunction();
};
}
}
@Service
public class OrderSearchService {
private final OrderRepository repo;
public Page<Order> search(OrderSearchRequest req, Pageable pageable) {
Specification<Order> spec = Specification
.where(OrderSpecs.hasStatus(req.getStatus()))
.and(OrderSpecs.forCustomer(req.getCustomerId()))
.and(OrderSpecs.createdAfter(req.getFrom()))
.and(OrderSpecs.totalBetween(req.getMinTotal(), req.getMaxTotal()));
return repo.findAll(spec, pageable);
}
}
Querydsl generates Q-classes from your entities at compile time. Unlike Specifications, Querydsl predicates use actual field names — renaming a field causes a compile error rather than a runtime failure.
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<classifier>jakarta</classifier>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<classifier>jakarta</classifier>
<scope>provided</scope>
</dependency>
<!-- APT plugin to generate Q-classes during compile -->
<plugin>
<groupId>com.mysema.maven</groupId>
<artifactId>apt-maven-plugin</artifactId>
<version>1.1.3</version>
<executions>
<execution>
<goals><goal>process</goal></goals>
<configuration>
<outputDirectory>target/generated-sources/java</outputDirectory>
<processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
</configuration>
</execution>
</executions>
</plugin>
// Repository — extend QuerydslPredicateExecutor
public interface OrderRepository
extends JpaRepository<Order, Long>,
QuerydslPredicateExecutor<Order> {
}
@Service
public class OrderQueryService {
private final OrderRepository repo;
public List<Order> findHighValuePendingOrders(BigDecimal threshold) {
QOrder order = QOrder.order; // generated Q-class
BooleanExpression predicate = order.status.eq(OrderStatus.PENDING)
.and(order.total.gt(threshold))
.and(order.createdAt.after(Instant.now().minus(30, ChronoUnit.DAYS)));
return (List<Order>) repo.findAll(predicate);
}
public Page<Order> searchOrders(String customerId, OrderStatus status, Pageable pageable) {
QOrder order = QOrder.order;
BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(customerId)) builder.and(order.customerId.eq(customerId));
if (status != null) builder.and(order.status.eq(status));
return repo.findAll(builder, pageable);
}
}
When Specifications and Querydsl aren't flexible enough — batch inserts, raw SQL, complex aggregations — implement a custom repository fragment. Spring Data mixes it in automatically.
// 1. Define the fragment interface
public interface OrderRepositoryCustom {
List<OrderSummary> findOrderSummaryByCustomerIds(List<String> customerIds);
int bulkUpdateStatus(List<Long> orderIds, OrderStatus newStatus);
}
// 2. Implement with EntityManager or JdbcTemplate
@Repository
public class OrderRepositoryImpl implements OrderRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
public List<OrderSummary> findOrderSummaryByCustomerIds(List<String> customerIds) {
return em.createQuery(
"""
SELECT new com.example.dto.OrderSummary(
o.customerId, COUNT(o), SUM(o.total), MAX(o.createdAt)
)
FROM Order o
WHERE o.customerId IN :customerIds
GROUP BY o.customerId
""", OrderSummary.class)
.setParameter("customerIds", customerIds)
.getResultList();
}
@Override
@Transactional
public int bulkUpdateStatus(List<Long> orderIds, OrderStatus newStatus) {
return em.createQuery(
"UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
.setParameter("status", newStatus)
.setParameter("ids", orderIds)
.executeUpdate();
}
}
// 3. Main repository extends both
public interface OrderRepository
extends JpaRepository<Order, Long>,
JpaSpecificationExecutor<Order>,
OrderRepositoryCustom { // Spring Data merges the implementation
}
Interface projections return a subset of columns — Spring Data generates a proxy at runtime. Useful for list views where you don't need the entire entity graph.
// Projection interface — only the fields you need
public interface OrderListView {
Long getId();
String getCustomerId();
OrderStatus getStatus();
BigDecimal getTotal();
Instant getCreatedAt();
// SpEL expression for computed properties
@Value("#{target.total.multiply(new java.math.BigDecimal('1.2'))}")
BigDecimal getTotalWithTax();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
// Spring Data generates SELECT id, customer_id, status, total, created_at FROM orders
List<OrderListView> findByStatus(OrderStatus status);
@EntityGraph(attributePaths = "customer")
Page<OrderListView> findByCustomerId(String customerId, Pageable pageable);
}
DTO projections use new in JPQL to construct arbitrary DTOs directly from the query — no proxy, full type safety, compatible with records.
// DTO — works with Java records too
public record OrderSummary(
String customerId,
long orderCount,
BigDecimal totalRevenue,
Instant lastOrderAt
) {}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT new com.example.dto.OrderSummary(
o.customerId,
COUNT(o),
SUM(o.total),
MAX(o.createdAt)
)
FROM Order o
WHERE o.createdAt >= :since
GROUP BY o.customerId
ORDER BY SUM(o.total) DESC
""")
List<OrderSummary> findTopCustomersByRevenue(@Param("since") Instant since);
// Native query with DTO projection via interface
@Query(value = """
SELECT customer_id AS customerId,
COUNT(*) AS orderCount,
SUM(total) AS totalRevenue
FROM orders
WHERE created_at >= :since
GROUP BY customer_id
""", nativeQuery = true)
List<CustomerRevenueView> findRevenueByCustomerNative(@Param("since") Instant since);
}
| Approach | Type Safety | Dynamic Queries | Complexity | Best For |
| Derived methods | ✅ | ❌ fixed | Low | Simple findBy queries |
| @Query JPQL | Partial | Limited | Low | Fixed complex queries |
| Specification | Partial (strings) | ✅ excellent | Medium | Search forms, dynamic filters |
| Querydsl | ✅ compile-time | ✅ excellent | Medium | Complex domain queries, refactoring safety |
| Custom fragment + EntityManager | Partial | ✅ | High | Aggregations, bulk ops, native SQL |