Interview question
Implement stable keyset pagination with Spring Data JPA
Implements descending cursor pagination with a unique tie-breaker, bounded projection query, and next-cursor detection.
TL;DR
Implements descending cursor pagination with a unique tie-breaker, bounded projection query, and next-cursor detection.
Keyset predicates, stable ordering, cursor validation, DTO projection, size-plus-one paging, and concurrent data changes.
Practice the problem like a real interview: restate, reason, implement, and test.
Return orders ordered by createdAt DESC, id DESC. The first request has no cursor. Later requests provide both cursor timestamp and cursor ID and must return rows strictly after that position. Fetch pageSize + 1 rows to determine whether another page exists. Do not use offset.
Next in Java & Spring Practical Labs: JPA version conflict
order: (10:00,id=9), (10:00,id=7), (09:00,id=8)
cursor after (10:00,id=9) -> (10:00,id=7), (09:00,id=8)
timestamp without ID -> invalid cursor
public record OrderSlice(java.util.List<OrderItem> items, OrderCursor next) {}
public record OrderCursor(java.time.Instant createdAt, long id) {}
interface OrderPageRepository extends org.springframework.data.jpa.repository.JpaRepository<Order, Long> {
@org.springframework.data.jpa.repository.Query("""
select new com.example.OrderItem(o.id, o.createdAt, o.total)
from Order o
where (:cursorTime is null
or o.createdAt < :cursorTime
or (o.createdAt = :cursorTime and o.id < :cursorId))
order by o.createdAt desc, o.id desc
""")
java.util.List<OrderItem> next(
@org.springframework.data.repository.query.Param("cursorTime") java.time.Instant cursorTime,
@org.springframework.data.repository.query.Param("cursorId") Long cursorId,
org.springframework.data.domain.Pageable limit);
}
OrderSlice page(OrderCursor cursor, int requestedSize) {
int size = Math.max(1, Math.min(requestedSize, 100));
var rows = repository.next(
cursor == null ? null : cursor.createdAt(),
cursor == null ? null : cursor.id(),
org.springframework.data.domain.PageRequest.of(0, size + 1));
boolean hasMore = rows.size() > size;
var visible = java.util.List.copyOf(rows.subList(0, Math.min(size, rows.size())));
var last = hasMore ? visible.get(visible.size() - 1) : null;
return new OrderSlice(visible,
last == null ? null : new OrderCursor(last.createdAt(), last.id()));
}
Application work is O(pageSize). With a matching (created_at DESC, id DESC) index and stable filters, the database can seek near the cursor instead of scanning O(offset) preceding rows.
createdAt skips or repeats ties.id > cursorId with descending order reverses the cursor boundary.pageSize + 1 items.Spot a weak answer, missing edge case, or clearer explanation? Send it in.
Say thanks with a standalone, one-time $5 contribution. No account required.