Contents

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); }
ApproachType SafetyDynamic QueriesComplexityBest For
Derived methods❌ fixedLowSimple findBy queries
@Query JPQLPartialLimitedLowFixed complex queries
SpecificationPartial (strings)✅ excellentMediumSearch forms, dynamic filters
Querydsl✅ compile-time✅ excellentMediumComplex domain queries, refactoring safety
Custom fragment + EntityManagerPartialHighAggregations, bulk ops, native SQL