Skip to main content
spring internals

Query Derivation and @Query Processing

7 min read Chapter 54 of 78

Query Derivation and @Query Processing

The proxy is created (CH18-S1). Now it needs to know what each custom method does. For derived queries, Spring Data parses the method name into a structured query at startup. For @Query methods, it parses the annotation value. Both are validated before the application starts.

This section opens the parser.

PartTree: The Method Name Parser

PartTree is the class that converts method names into query structures. It receives the method name and the domain class, then produces a tree of predicates that query creators (JPA, MongoDB, etc.) translate into their native query languages.

For the SaaS backend’s OrderRepository:

List<Order> findByTenantIdAndStatusOrderByCreatedAtDesc(
    UUID tenantId, OrderStatus status
);

PartTree splits this into:

Subject: find (returns List<Order>)
Predicate:
  OrPart[0]:
    Part[0]: tenantId, Type=SIMPLE_PROPERTY
    Part[1]: status, Type=SIMPLE_PROPERTY
    Connector: AND
OrderBy:
  Order[0]: createdAt, Direction=DESC

The parser strips the prefix (findBy, readBy, getBy, queryBy, searchBy, streamBy), then splits on And and Or. Each segment maps to a property on the entity class.

Property Resolution

Each Part must resolve to a valid property path on the domain entity. PartTree walks the entity’s metadata to verify:

@Entity
public class Order {
    @Id private UUID id;
    private UUID tenantId;          // PartTree resolves "TenantId" to this
    @Enumerated(EnumType.STRING)
    private OrderStatus status;     // PartTree resolves "Status" to this
    private Instant createdAt;      // PartTree resolves "CreatedAt" to this
}

The resolution is case-sensitive in a specific way: PartTree converts the method name segment to a property path by trying camelCase splits. TenantId becomes tenantId. CreatedAt becomes createdAt.

Supported Keywords

The grammar supports operators appended to property names:

// Equality (default)
findByTenantId(UUID tenantId)
// -> WHERE o.tenantId = ?1

// Comparison
findByTotalGreaterThan(BigDecimal amount)
// -> WHERE o.total > ?1

findByCreatedAtBetween(Instant start, Instant end)
// -> WHERE o.createdAt BETWEEN ?1 AND ?2

// String matching
findByCustomerNameContaining(String fragment)
// -> WHERE o.customerName LIKE %?1%

findByCustomerNameStartingWith(String prefix)
// -> WHERE o.customerName LIKE ?1%

// Null checks
findByDeletedAtIsNull()
// -> WHERE o.deletedAt IS NULL

// Collection checks
findByStatusIn(Collection<OrderStatus> statuses)
// -> WHERE o.status IN (?1)

// Negation
findByStatusNot(OrderStatus status)
// -> WHERE o.status != ?1

// Boolean
findByActiveTrue()
// -> WHERE o.active = true

// Limiting
findTop10ByTenantIdOrderByCreatedAtDesc(UUID tenantId)
// -> SELECT o FROM Order o WHERE o.tenantId = ?1 ORDER BY o.createdAt DESC
// (limited to 10 results)

// Counting
countByTenantIdAndStatus(UUID tenantId, OrderStatus status)
// -> SELECT COUNT(o) FROM Order o WHERE o.tenantId = ?1 AND o.status = ?2

// Existence
existsByTenantIdAndEmail(UUID tenantId, String email)
// -> SELECT CASE WHEN COUNT(o) > 0 THEN true ELSE false END ...

// Deletion
deleteByTenantIdAndStatus(UUID tenantId, OrderStatus status)
// -> DELETE FROM Order o WHERE o.tenantId = ?1 AND o.status = ?2

Each keyword maps to a Part.Type enum value. The JPA query creator translates each Part.Type into the corresponding JPQL predicate.

@Query: Explicit Query Definitions

When method names become unreadable or the query exceeds what derivation supports, use @Query:

public interface OrderRepository extends JpaRepository<Order, UUID> {

    @Query("SELECT o FROM Order o JOIN o.lineItems li " +
           "WHERE o.tenantId = :tenantId " +
           "AND li.product.category = :category " +
           "GROUP BY o " +
           "HAVING SUM(li.quantity) > :minQuantity")
    List<Order> findBulkOrders(
        @Param("tenantId") UUID tenantId,
        @Param("category") String category,
        @Param("minQuantity") int minQuantity
    );
}

No method name parsing occurs. Spring Data detects the @Query annotation and constructs a SimpleJpaQuery (for JPQL) or NativeJpaQuery (for native SQL) directly from the annotation value.

Named Parameters with @Param

Without @Param, Spring Data uses positional parameters (?1, ?2). With @Param, it uses named parameters (:tenantId, :category). Named parameters are self-documenting and order-independent.

// Positional: fragile if you reorder parameters
@Query("SELECT o FROM Order o WHERE o.tenantId = ?1 AND o.status = ?2")
List<Order> findOrders(UUID tenantId, OrderStatus status);

// Named: explicit binding, parameter order does not matter
@Query("SELECT o FROM Order o WHERE o.tenantId = :tenantId AND o.status = :status")
List<Order> findOrders(
    @Param("tenantId") UUID tenantId,
    @Param("status") OrderStatus status
);

Use @Param for any @Query method. The positional syntax is error-prone when queries change.

Native Queries

@Query(value = "SELECT * FROM orders WHERE tenant_id = :tenantId " +
               "AND status = :status FOR UPDATE SKIP LOCKED",
       nativeQuery = true)
List<Order> findAndLockOrders(
    @Param("tenantId") UUID tenantId,
    @Param("status") String status
);

Native queries use database column names, not entity field names. They bypass JPQL and go directly to the database. FOR UPDATE SKIP LOCKED is a PostgreSQL-specific feature that JPQL cannot express.

Native queries are not validated at startup against the JPA metamodel. Column name typos surface at runtime.

Interface-Based Projections

Not every query needs the full entity. The SaaS backend’s order list endpoint only needs id, status, and total:

public interface OrderSummary {
    UUID getId();
    OrderStatus getStatus();
    BigDecimal getTotal();
    Instant getCreatedAt();
}
public interface OrderRepository extends JpaRepository<Order, UUID> {
    List<OrderSummary> findByTenantId(UUID tenantId);
}

Spring Data creates a proxy for the projection interface. The generated query selects only the columns mapped to the projection’s getter methods:

SELECT o.id, o.status, o.total, o.createdAt
FROM Order o
WHERE o.tenantId = ?1

This reduces data transfer and memory usage. The projection proxy is lightweight: it wraps a Tuple and delegates getter calls to the corresponding tuple element.

For @Query methods, class-based projections (DTOs) with constructor expressions are often clearer:

public record OrderSummaryDTO(UUID id, OrderStatus status, BigDecimal total) {}

@Query("SELECT new com.saas.order.dto.OrderSummaryDTO(o.id, o.status, o.total) " +
       "FROM Order o WHERE o.tenantId = :tenantId")
List<OrderSummaryDTO> findOrderSummaries(@Param("tenantId") UUID tenantId);

Startup Query Validation

Every derived query and every JPQL @Query is validated when the application context initializes. This happens inside QueryExecutorMethodInterceptor construction, which iterates over all query methods and creates the corresponding RepositoryQuery objects.

For derived queries, validation means:

  1. Parse the method name with PartTree
  2. Resolve each property against the entity metamodel
  3. Verify parameter count matches the number of predicates

For JPQL @Query, validation means:

  1. Parse the JPQL string
  2. Validate entity names and property paths against the JPA metamodel
  3. Verify parameter bindings

This validation loop is the largest single contributor to Spring Data startup cost. An application with 200 repository methods parses and validates 200 queries before the first request.

The Failure Mode: Ambiguous Property Paths

@Entity
public class Order {
    @Id private UUID id;
    private UUID tenantId;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;        // field named 'status'

    @ManyToOne
    private OrderStatusHistory statusHistory;  // also starts with 'status'
}

// BROKEN: 'Status' could resolve to 'status' or 'statusHistory.xxx'
public interface OrderRepository extends JpaRepository<Order, UUID> {
    List<Order> findByStatusHistoryTimestampAfter(Instant after);
}

PartTree tries to resolve StatusHistoryTimestampAfter. It finds two possible paths:

  1. status + HistoryTimestampAfter (fails, OrderStatus has no historyTimestampAfter)
  2. statusHistory + TimestampAfter (resolves to statusHistory.timestamp with After keyword)

When the parser finds an ambiguous split, it takes the longest match. But if both paths are valid properties, the resolution becomes unpredictable. The parser may choose the wrong path, producing a query that compiles but returns wrong results.

// Potential error at startup:
org.springframework.data.mapping.PropertyReferenceException:
No property 'historyTimestampAfter' found for type 'OrderStatus'

Or worse, no error, and the wrong query executes silently.

The Correct Pattern

// CORRECT: use underscore to force property path splitting
public interface OrderRepository extends JpaRepository<Order, UUID> {
    // Underscore explicitly separates 'statusHistory' from 'timestamp'
    List<Order> findByStatusHistory_TimestampAfter(Instant after);
}

The underscore tells PartTree exactly where to split the property path. statusHistory is the navigation property, timestamp is the leaf property, After is the keyword.

For any query that navigates associations, prefer @Query over derived method names:

// CORRECT: @Query removes all ambiguity
@Query("SELECT o FROM Order o " +
       "WHERE o.statusHistory.timestamp > :after")
List<Order> findWithRecentStatusChange(@Param("after") Instant after);

Rules for choosing between derived queries and @Query:

  1. Single entity, simple predicates, two or fewer conditions: derived query
  2. Navigation across associations: @Query or underscore separator
  3. Joins, subqueries, aggregations: @Query
  4. Database-specific syntax: native @Query
  5. Method name exceeds 50 characters: @Query (readability)

The method name is an interface contract. Keep it readable. If the derived name is findByTenantIdAndStatusAndCreatedAtAfterAndTotalGreaterThanOrderByCreatedAtDesc, replace it with @Query and a meaningful method name like findRecentLargeOrders.