Repositories and queries

Derived query methods, @Query with JPQL and native SQL, projections, pagination, and specifications for the search screen.

6 min read🗃️ Spring Data JPA and Hibernate

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:

java
List<Account> findByOwnerAndBalanceGreaterThanOrderByBalanceDesc(String owner, int min);
plaintext
Hibernate: select account0_.id, account0_.balance, account0_.owner, account0_.version
           from account account0_
           where account0_.owner=? and account0_.balance>?
           order by account0_.balance desc

Spring 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:

FragmentBecomes
findBy, readBy, getByselect
And, Orand, or
GreaterThan, Between, Like, Inthe obvious operator
IsNull, IsNotNull, Truea null or boolean test
OrderBy...Asc/Descorder by
countBy, existsBy, deleteBycount, exists, delete
findFirst5By, findTopBya 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

java
@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:

java
@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 API is the third option: queries built as Java objects, type-safe, and verbose enough that nobody writes them for fun. Its real use is the search screen with eight optional filters, where string concatenation would be a mess. In Spring Data that is usually spelled Specification, which is Criteria with a friendlier surface and is worth reaching for exactly when the filters are dynamic \u2014 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:

java
interface OwnerOnly { String getOwner(); }
 
List<OwnerOnly> findByBalanceLessThan(int max);
plaintext
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

java
Page<Account> findByBalanceGreaterThan(int min, Pageable page);
plaintext
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 total

Two 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

SituationUse
one or two conditionsa derived query
a join, an aggregate, three-plus conditions@Query with JPQL
a read-only list screena projection
filters that vary per requestSpecification
a window function, a CTE, a bulk statementnative SQL, deliberately
a huge result seta Slice, or a stream, not a List
Progress is saved on this device and to your account when signed in.