Spring Data JPA repositories and derived queries
An interface with no implementation that still runs queries — how Spring Data derives them from method names, and what Page and Slice cost.
After this lesson you can
- Read a derived query method name as the JPQL it stands for
- Choose between a derived query, @Query and a projection
- Explain the extra query a Page costs and when a Slice is enough
With Spring Data JPA you declare an interface and never implement it:
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByStatusAndTotalGreaterThan(Status status, BigDecimal min);
Optional<Order> findFirstByCustomerIdOrderByCreatedAtDesc(long customerId);
long countByStatus(Status status);
Page<Order> findByCustomerId(long customerId, Pageable pageable);
@Query("select o from Order o join fetch o.items where o.id = :id")
Optional<Order> findWithItems(@Param("id") long id);
}
At startup Spring Data creates a proxy for the interface. The inherited
methods (save, findById, findAll, deleteById, …) are served by
a generic implementation, SimpleJpaRepository. Every other method is
either annotated with @Query or has its query derived from its
name.
Reading a derived query
The name has a subject — find…By, count…By, exists…By,
delete…By, optionally limited with First or Top3 — then a
predicate: property names joined by And and Or, each optionally
followed by a keyword such as GreaterThan, LessThan, Between,
IsNull, Containing, In or IgnoreCase, and an optional
OrderBy<Property>Asc|Desc. Method parameters bind to the conditions
in order — but not one per condition: Between takes two values and
IsNull takes none.
A property in the predicate may also be a path through
associations: findByCustomerAddressCity on an Order filters on
o.customer.address.city, which JPQL turns into joins. Spring Data
finds the path by trying the whole name as a property first; if there
is none, it splits the name at a camel-case boundary, starting from the
right, and looks for the left part as a property. When it finds one, it
resolves the rest the same way inside that property's type; when it
does not, it moves the split one word further left. An underscore,
findByCustomer_Address_City, marks the boundaries explicitly. The
model below resolves single-condition names against a small
Order → Customer → Address model:
import java.util.*; public class Solution { // Entity metadata: type -> (property -> type of that property). static final Map<String, Map<String, String>> MODEL = Map.of( "Order", Map.of("status", "String", "createdAt", "Instant", "customer", "Customer", "shippingAddress", "Address"), "Customer", Map.of("id", "Long", "name", "String", "address", "Address"), "Address", Map.of("city", "String", "zipCode", "String")); static String lowerFirst(String s) { return Character.toLowerCase(s.charAt(0)) + s.substring(1); } // Returns the dotted path, or null if no reading of the name works. static String resolve(String type, String name) { Map<String, String> props = MODEL.getOrDefault(type, Map.of()); int underscore = name.indexOf('_'); if (underscore >= 0) { // explicit boundary String head = lowerFirst(name.substring(0, underscore)); if (!props.containsKey(head)) return null; String rest = resolve(props.get(head), name.substring(underscore + 1)); return rest == null ? null : head + "." + rest; } if (props.containsKey(lowerFirst(name))) return lowerFirst(name); for (int i = name.length() - 1; i > 0; i--) { // split points, right to left if (!Character.isUpperCase(name.charAt(i))) continue; String head = lowerFirst(name.substring(0, i)); if (!props.containsKey(head)) continue; String rest = resolve(props.get(head), name.substring(i)); if (rest != null) return head + "." + rest; } return null; } static String describe(String method) { String path = resolve("Order", method.substring("findBy".length())); return method + " -> " + (path == null ? "no such property: fails at startup" : "o." + path); } public static List<String> run() { return List.of( describe("findByStatus"), describe("findByCustomerId"), describe("findByShippingAddressZipCode"), describe("findByCustomerAddressCity"), describe("findByCustomer_Address_City"), describe("findByCustomerPhone")); }}Property names are checked against the entity when the repository is
created. A typo such as findByStauts fails the application at
startup, not at the first call — one of the real advantages of derived
queries. Their weakness is readability: once a name grows past two or
three conditions, write a @Query with JPQL (or nativeQuery = true
for SQL) instead. Update and delete queries written with @Query also
need @Modifying and must run inside a transaction.
Projections: select less
A repository method may return an interface or a record instead of the
entity — List<OrderSummary> findByStatus(Status s) where
OrderSummary declares only getId() and getTotal(). Spring Data
then selects only the columns it needs, and nothing returned is a
managed entity that could trigger lazy loading.
Paging: Page versus Slice
A Pageable (PageRequest.of(page, size, Sort.by("createdAt").descending()))
adds a limit and offset. Page numbers are zero-based. Returning
Page<T> also runs a count query, so the page knows the total
number of elements and pages. Returning Slice<T> skips the count: it
fetches one row more than the page size, only to learn whether a next
page exists. On a large table that count can cost more than the page
itself, so an infinite-scroll API that never shows "page 3 of 57" should
use Slice. (Spring Data does skip the count when the rows it got back
already reveal the total: a first page shorter than the page size, or a
later page that is short but not empty.)
import java.util.*; public class Solution { static final int SIZE = 3; // order by id desc limit SIZE offset page * SIZE (page is zero-based) static List<Integer> byOffset(List<Integer> table, int page) { return table.stream().sorted(Comparator.reverseOrder()) .skip((long) page * SIZE).limit(SIZE).toList(); } // where id < lastId order by id desc limit SIZE static List<Integer> byKeyset(List<Integer> table, int lastId) { return table.stream().filter(id -> id < lastId).sorted(Comparator.reverseOrder()) .limit(SIZE).toList(); } static List<Integer> seenAgain(List<Integer> first, List<Integer> second) { return second.stream().filter(first::contains).toList(); } public static List<String> run() { List<String> out = new ArrayList<>(); List<Integer> feed = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6, 7)); List<Integer> first = byOffset(feed, 0); feed.add(8); // a new post arrives List<Integer> second = byOffset(feed, 1); out.add("offset: " + first + " then " + second + ", seen again " + seenAgain(first, second)); feed = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6, 7)); first = byOffset(feed, 0); // the first page is the same query feed.add(8); second = byKeyset(feed, first.get(first.size() - 1)); out.add("keyset: " + first + " then " + second + ", seen again " + seenAgain(first, second)); return out; }}Spring Data 3.1 and later support keyset paging directly: a repository
method that takes a ScrollPosition and returns a Window<T>, started
with ScrollPosition.keyset().
Common mistakes
- Treating
PageRequest.of(1, 20)as the first page — it is the second; pages start at 0. - Returning
Pagefrom an endpoint that never displays a total, and paying for acount(*)on nearly every request. - A twelve-word derived method name that nobody can review; past a few
conditions,
@Queryis clearer and the query is visible.
What interviewers ask
- How does Spring Data implement a repository interface you never
implemented? — a proxy created at startup; CRUD methods from
SimpleJpaRepository, other methods derived from the name or taken from@Query, validated at startup. - Difference between
PageandSlice? —Pageruns an extra count query and knows the totals;Sliceonly knows whether there is a next page. - When would you write
@Queryinstead of a derived method? — complex predicates, joins and fetch joins, aggregates, or when the name stops being readable.
Exercise · Pro
What a Page or a Slice costs
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade