Spring Data Repository Design — When findBy Methods Are Enough and When They're Not

by Eric Hanson, Backend Developer at Clean Systems Consulting

Derived query methods — what they do and where they stop

Spring Data parses repository method names to generate queries. findByStatusAndCreatedAtAfter(OrderStatus status, Instant since) produces SELECT * FROM orders WHERE status = ? AND created_at > ?. No query writing, no boilerplate.

The parser handles a surprisingly large vocabulary: findBy, readBy, queryBy, countBy, deleteBy, existsBy — each with operators like And, Or, Not, In, NotIn, Between, LessThan, GreaterThan, Like, Containing, StartingWith, EndingWith, IsNull, IsNotNull, OrderBy.

// All valid derived queries
List<Order> findByStatus(OrderStatus status);
List<Order> findByStatusAndCreatedAtAfter(OrderStatus status, Instant since);
List<Order> findByStatusIn(List<OrderStatus> statuses);
List<Order> findByUserEmailContainingIgnoreCase(String emailFragment);
long countByStatusAndCreatedAtBetween(OrderStatus status, Instant from, Instant to);
boolean existsByUserIdAndStatus(Long userId, OrderStatus status);

The signal that derived queries are appropriate: the filter conditions are fixed at compile time, the method name remains readable, and the number of parameters is small (three or fewer).

The signal that derived queries have been pushed too far:

// Unreadable — method name is longer than a sentence
List<Order> findByStatusAndUserIdAndCreatedAtAfterAndTotalGreaterThanAndShippingAddressCountryCode(
    OrderStatus status, Long userId, Instant since, BigDecimal minTotal, String countryCode);

This method works but no one can read it. The rule of thumb: if the method name exceeds one line, it should be a @Query.

@Query — explicit JPQL and native SQL

@Query accepts JPQL or native SQL. It's the right choice when the derived method name would be unreadable, when the query needs a JOIN or aggregate, or when the query logic is complex enough to warrant visibility:

// JPQL — uses entity and field names
@Query("SELECT o FROM Order o " +
       "WHERE o.status = :status " +
       "AND o.user.email LIKE %:emailFragment% " +
       "AND o.total > :minTotal " +
       "AND o.createdAt > :since")
List<Order> findOrdersByMultipleCriteria(
    @Param("status") OrderStatus status,
    @Param("emailFragment") String emailFragment,
    @Param("minTotal") BigDecimal minTotal,
    @Param("since") Instant since);

For queries that need database-specific features (window functions, RETURNING, full-text search), use nativeQuery = true:

@Query(value = """
    SELECT o.*, u.email,
           RANK() OVER (PARTITION BY o.user_id ORDER BY o.total DESC) as spend_rank
    FROM orders o
    JOIN users u ON o.user_id = u.id
    WHERE o.status = :status
    """, nativeQuery = true)
List<Object[]> findOrdersWithSpendRank(@Param("status") String status);

The limitation of @Query for dynamic filtering: the query string is fixed at compile time. You can use optional parameters with conditional JPQL (CASE WHEN :status IS NULL THEN true ELSE o.status = :status END) but this approach is fragile and produces suboptimal query plans.

Specifications — dynamic queries with type safety

Spring Data JPA's Specification<T> (based on the JPA Criteria API) builds queries programmatically. Each Specification is a composable predicate:

public class OrderSpecifications {

    public static Specification<Order> hasStatus(OrderStatus status) {
        return (root, query, cb) ->
            status == null ? null : cb.equal(root.get("status"), status);
    }

    public static Specification<Order> createdAfter(Instant since) {
        return (root, query, cb) ->
            since == null ? null : cb.greaterThan(root.get("createdAt"), since);
    }

    public static Specification<Order> totalGreaterThan(BigDecimal minTotal) {
        return (root, query, cb) ->
            minTotal == null ? null : cb.greaterThan(root.get("total"), minTotal);
    }

    public static Specification<Order> userEmailContains(String fragment) {
        return (root, query, cb) -> {
            if (fragment == null) return null;
            Join<Order, User> userJoin = root.join("user", JoinType.INNER);
            return cb.like(cb.lower(userJoin.get("email")),
                "%" + fragment.toLowerCase() + "%");
        };
    }
}

Returning null from a specification means the predicate is not applied — the parameter is effectively optional. Compose specifications with and():

public interface OrderRepository extends JpaRepository<Order, Long>,
        JpaSpecificationExecutor<Order> {}

// Usage — only non-null parameters become predicates
public List<Order> findOrders(OrderFilter filter) {
    Specification<Order> spec = Specification.where(null)
        .and(hasStatus(filter.status()))
        .and(createdAfter(filter.since()))
        .and(totalGreaterThan(filter.minTotal()))
        .and(userEmailContains(filter.emailFragment()));

    return orderRepository.findAll(spec);
}

Specification.where(null) starts with an always-true predicate. Each .and(spec) is ignored if the spec returns null. The result: a dynamically constructed query with exactly the predicates that have non-null values.

The repository must extend JpaSpecificationExecutor<T> to get findAll(Specification<T>).

The limitation of Specifications. The Criteria API is verbose and uses string-based field references (root.get("status")) that are not type-safe — a typo compiles but fails at runtime. The metamodel (root.get(Order_.status)) is type-safe but requires generated metamodel classes (@StaticMetamodel).

For most applications, string-based references are acceptable — field name changes are caught by validation or integration tests. For large codebases where entity refactoring is frequent, the generated metamodel is worth the setup.

Querydsl — type-safe dynamic queries

Querydsl generates a Q class for each entity (QOrder, QUser) with typed path expressions. Dynamic queries use these paths:

<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>
</dependency>

The build generates QOrder.java:

// Generated — do not modify
public class QOrder extends EntityPathBase<Order> {
    public final EnumPath<OrderStatus> status = createEnum("status", OrderStatus.class);
    public final DateTimePath<Instant> createdAt = createDateTime("createdAt", Instant.class);
    public final NumberPath<BigDecimal> total = createNumber("total", BigDecimal.class);
    // ...
}

Usage:

public interface OrderRepository extends JpaRepository<Order, Long>,
        QuerydslPredicateExecutor<Order> {}

// Dynamic query with compile-time type checking
public List<Order> findOrders(OrderFilter filter) {
    QOrder order = QOrder.order;
    BooleanBuilder predicate = new BooleanBuilder();

    if (filter.status() != null) {
        predicate.and(order.status.eq(filter.status()));
    }
    if (filter.since() != null) {
        predicate.and(order.createdAt.after(filter.since()));
    }
    if (filter.minTotal() != null) {
        predicate.and(order.total.gt(filter.minTotal()));
    }

    return (List<Order>) orderRepository.findAll(predicate);
}

order.status.eq(filter.status()) is compile-time type-checked — status is OrderStatus, eq accepts OrderStatus. Renaming status to orderStatus in the entity regenerates QOrder and produces a compile error where order.status is used — the typo is caught at compile time, not at runtime.

Querydsl's advantage over Specifications: type safety and readability. The disadvantage: an additional build step, generated code to manage, and a library dependency. For teams that frequently refactor entities, the compile-time safety is worth it. For stable entity models, Specifications are simpler.

Query objects — the explicit alternative

For complex queries that don't fit any of the above — multiple joins, aggregations, dynamic column selection, complex subqueries — a dedicated query object class is cleaner than a repository method:

public class OrderReportQuery {

    private final EntityManager em;

    public OrderReportQuery(EntityManager em) {
        this.em = em;
    }

    public List<OrderReportRow> execute(OrderReportFilter filter) {
        StringBuilder jpql = new StringBuilder("""
            SELECT new com.example.OrderReportRow(
                o.id, o.status, o.total, u.email, COUNT(li)
            )
            FROM Order o
            JOIN o.user u
            LEFT JOIN o.lineItems li
            WHERE 1=1
            """);

        Map<String, Object> params = new HashMap<>();

        if (filter.status() != null) {
            jpql.append(" AND o.status = :status");
            params.put("status", filter.status());
        }
        if (filter.since() != null) {
            jpql.append(" AND o.createdAt > :since");
            params.put("since", filter.since());
        }

        jpql.append(" GROUP BY o.id, o.status, o.total, u.email");

        TypedQuery<OrderReportRow> query = em.createQuery(jpql.toString(), OrderReportRow.class);
        params.forEach(query::setParameter);

        return query.getResultList();
    }
}

Query objects are verbose but explicit — the query construction is visible, testable, and not buried in framework machinery. They're the right choice for complex reporting queries, queries that change shape significantly based on filter combinations, and queries that use database-specific features that JPQL can't express.

The decision hierarchy

Fixed conditions, few parameters, readable method name: derived query methods. findByStatusAndCreatedAtAfter.

Fixed conditions, complex query (JOINs, aggregates, subqueries): @Query with JPQL or native SQL.

Dynamic conditions (optional filters at runtime): Specifications (simpler, string-based) or Querydsl (type-safe, requires build step).

Complex dynamic queries with aggregation or database-specific features: query objects with direct EntityManager or JdbcTemplate access.

The pattern that signals the wrong choice: a derived query method so long it wraps to a second line, or a @Query with CASE WHEN :param IS NULL THEN 1=1 ELSE ... for optional filtering. Both signal that the query is dynamic and should use Specifications or a query object.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Email Templates for Junior Contractors Who Don’t Know What to Say

Sending emails as a junior contractor can feel like walking a tightrope. These simple templates make communication easier, without sounding stiff or fake.

Read more

How to Sleep When You’re Responsible for Production

You check alerts one last time at midnight, hoping everything is fine. Being responsible for production doesn’t have to mean sleepless nights.

Read more

Handling Criticism Without Feeling Defeated

Criticism stings, even when you know it’s supposed to help. Learning to handle it without losing confidence is a superpower for any professional.

Read more

How to Keep Clients Happy When Things Go Wrong

Even the best projects hit bumps. How you handle problems can make or break your client relationships.

Read more