Spring Data JPA: Custom Repositories, Querydsl & Specifications
Derived query methods cover simple cases. When you need dynamic filters, complex joins, or full type safety across column renames, reach for JPA Specifications (Criteria API), Querydsl predicates, or custom repository fragments. This article covers all three patterns alongside interface and DTO projections for lean read queries.
Contents
JPA Specifications
A Specification<T>JpaSpecificationExecutor<T>findAll(Specification)
<!-- 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()));
Specification Factory & Composition
Group related specifications in a factory class. Compose them with and()or()not()
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: Type-Safe Predicates
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);
}
}
Custom Repository Fragments
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
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 with @Query
DTO projections use new
// 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);
}
Specification vs Querydsl vs JPQL
| Approach | Type Safety | Dynamic Queries | Complexity | Best For |
|---|---|---|---|---|
| Derived methods | ✅ | ❌ fixed | Low | Simple findBy queries |
@Query | 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 |