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> encapsulates a single WHERE predicate. Repositories extend JpaSpecificationExecutor<T> to gain findAll(Specification) and related methods.

XML
<!-- No extra dependency — part of spring-data-jpa -->
Java
// Repository — extend both JpaRepository and JpaSpecificationExecutor
public interface OrderRepository
        extends JpaRepository<Order, Long>,
                JpaSpecificationExecutor<Order> {
}
Java
// 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(), and not() to build complex dynamic filters without if-else chains in service code.

Java
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();
        };
    }
}
Java
@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.

XML
<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>
Java
// Repository — extend QuerydslPredicateExecutor
public interface OrderRepository
        extends JpaRepository<Order, Long>,
                QuerydslPredicateExecutor<Order> {
}
Java
@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.

Java
// 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.

Java
// 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 in JPQL to construct arbitrary DTOs directly from the query: no proxy, full type safety, compatible with records.

Java
// DTO — works with Java records too
public record OrderSummary(
    String customerId,
    long orderCount,
    BigDecimal totalRevenue,
    Instant lastOrderAt
) {}
Java
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

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 + EntityManagerPartial✅HighAggregations, bulk ops, native SQL