Repositories and queries
Derived query methods, @Query with JPQL and native SQL, projections, pagination, and specifications for the search screen.
Spring Data will write a query from a method name. That is the feature everyone meets first, it is genuinely good, and the important thing to learn about it is where it stops \u2014 because the failure mode is not an error, it is a method name nobody can read.
Derived queries, and the SQL they become
Declare the method; write no body:
List<Account> findByOwnerAndBalanceGreaterThanOrderByBalanceDesc(String owner, int min);Hibernate: select account0_.id, account0_.balance, account0_.owner, account0_.version
from account account0_
where account0_.owner=? and account0_.balance>?
order by account0_.balance descSpring parsed the name at startup, matched owner and balance against the entity's fields, and built that. The parsing happens when the context starts, so a typo in a property name fails the application at boot rather than at the first request \u2014 which is the right time to find out and is worth knowing when you see a startup failure mentioning a property you cannot find.
The vocabulary is small and covers most of what a repository needs:
| Fragment | Becomes |
|---|---|
findBy, readBy, getBy | select |
And, Or | and, or |
GreaterThan, Between, Like, In | the obvious operator |
IsNull, IsNotNull, True | a null or boolean test |
OrderBy...Asc/Desc | order by |
countBy, existsBy, deleteBy | count, exists, delete |
findFirst5By, findTopBy | a limit |
The limit is readability, and it arrives fast. findByOwnerAndBalanceGreaterThanAndStatusInAndCreatedAtBetweenOrderByBalanceDesc is a legal method name that nobody can read, review, or change safely. Two rules keep this healthy:
- Three conditions is about the point where a name stops being an asset.
- The moment a query needs a join, a subquery, an aggregate, or anything conditional, it is not a name any more. Write it.
@Query, and when native is the honest choice
@Query("select a from Account a where a.balance > :min and a.owner like %:name%")
List<Account> search(@Param("min") int min, @Param("name") String name);That is JPQL: it queries your entities and their fields, not tables and columns. Rename a column in the database and JPQL does not care; rename a Java field and it breaks \u2014 which is the right way round, and it is why JPQL survives a schema refactor better than SQL strings.
Native SQL is the escape hatch:
@Query(value = "select * from account where balance > ?1", nativeQuery = true)
List<Account> raw(int min);Use it deliberately, for window functions, recursive CTEs, database-specific operators, or a bulk statement where loading entities would be absurd. The costs are real and worth stating: you lose portability, you lose compile-time-ish validation, and \u2014 the one that bites \u2014 a native modifying query bypasses the persistence context entirely. Hibernate does not know those rows changed, so managed entities in the current context are now stale.
Criteria and Specification: the query that is built at run time
The third option is the Criteria API: a query as Java objects rather than a string, type-safe through the generated metamodel (Order_.status), and verbose enough that nobody writes it for fun. Its real job is the search screen with eight optional filters, where a JPQL string would be concatenated in ifs and a derived query would need 2⁸ methods. Spring Data wraps it as Specification<T> — one predicate, composable:
static Specification<Order> withStatus(Status s) {
return (root, query, cb) -> s == null ? null : cb.equal(root.get(Order_.status), s);
}
static Specification<Order> placedAfter(Instant t) {
return (root, query, cb) -> t == null ? null : cb.greaterThan(root.get(Order_.placedAt), t);
}
Page<Order> page = orders.findAll(withStatus(filter.status()).and(placedAfter(filter.from())), pageable);A null predicate is ignored by and, so an absent filter costs nothing; the repository extends JpaSpecificationExecutor<Order>. That composability is the whole point (it is the composite pattern, from the design course), and it is also the boundary: a query with a fixed shape is clearer as @Query, and a Specification with a join inside a lambda is where the N+1 problem comes back wearing a type-safe coat. Reach for it exactly when the filters vary per request, and not before.
Projections: stop loading what you do not need
A repository method does not have to return entities. Declare an interface with the getters you want:
interface OwnerOnly { String getOwner(); }
List<OwnerOnly> findByBalanceLessThan(int max);Hibernate: select account0_.owner as col_0_0_ from account account0_ where account0_.balance<?One column. Not the whole row with three fields discarded afterwards \u2014 the select itself is narrower, and nothing is put into the persistence context, so there is no snapshot, no dirty checking, and no chance of an accidental write.
This is the cheapest performance win in the whole course and the most consistently skipped. A list endpoint that shows a name and a status does not need the entity, its @Version, its lazy collections, or the proxies for them.
Pagination costs two queries, and one of them surprises people
Page<Account> findByBalanceGreaterThan(int min, Pageable page);Hibernate: select account0_.id, account0_.balance, account0_.owner, account0_.version
from account account0_ where account0_.balance>? limit ?
Hibernate: select count(account0_.id) as col_0_0_ from account account0_ where account0_.balance>?
### page has 2 of 7 totalTwo statements. The page itself, and a count(*) over the whole matching set \u2014 because Page promises getTotalElements() and getTotalPages(), and there is no way to know those without counting.
On a small table that is free. On a ten-million-row table with a filter that is not well indexed, that count is the slow half of your endpoint, and it runs on every page \u2014 including page 400, which nobody has ever visited.
Slice is the answer when you do not need a total: it fetches one extra row to answer "is there a next page" and issues no count query. An infinite-scroll list wants a Slice. A table with numbered pages wants a Page and an index that makes the count cheap.
Which to reach for
| Situation | Use |
|---|---|
| one or two conditions | a derived query |
| a join, an aggregate, three-plus conditions | @Query with JPQL |
| a read-only list screen | a projection |
| filters that vary per request | Specification |
| a window function, a CTE, a bulk statement | native SQL, deliberately |
| a huge result set | a Slice, or a stream, not a List |