- Chapter
- Chapter 1 — Spring Data Fundamentals
- Prerequisites
- Volumes I–II; JPA basics (entities, mappings)
- You will be able to
- Explain the repository abstraction, use CRUD operations correctly, implement paging and sorting end to end, and design repositories around aggregates
- Covers
- Repository · CRUD · Paging · Sorting
On this page
- 1. Concept / Definition
- 2. Why It Exists
- 3. Core Concepts
- 4. How It Works Internally
- 4A. Deep Dive — save() and the "Is New?" Decision
- 5. APIs / Features / Syntax
- 6. Practical Examples
- 7. Common Problems & Mistakes
- 8. Performance & Production Considerations
- 9. Architecture & Design Considerations
- 10. Interview Questions & Answers
1Concept / Definition
A repository is a collection-like abstraction for loading and storing domain objects: callers ask for aggregates by identity or criteria and hand back aggregates to be persisted, without knowing how storage works. Spring Data implements this pattern: you declare an interface extending a Spring Data base interface, and Spring generates the implementation.

@Entity
@Table(name = "orders")
public class Order {
@Id private UUID id;
@Enumerated(EnumType.STRING) private OrderStatus status;
private BigDecimal total;
private Instant createdAt;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
// constructors, domain methods
}
public interface OrderRepository extends ListCrudRepository<Order, UUID>,
ListPagingAndSortingRepository<Order, UUID> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}2Why It Exists
Before Spring Data, every entity needed a DAO class with the same CRUD code and hand-written queries, plus transaction and exception-translation boilerplate. The Repository pattern (Fowler, PoEAA; Evans, DDD) suggested a domain-centric collection abstraction. Spring Data (2011) combined both ideas: a common programming model across stores (JPA, MongoDB, Redis, Elasticsearch, JDBC, R2DBC …), generated implementations, and query derivation from method names — eliminating most DAO code while keeping store-specific power available.
3Core Concepts
3.1 Repository
| Aspect | Meaning |
|---|---|
| Marker interface | Repository<T, ID> — domain type and id type; no methods |
| Domain type | Usually an aggregate root (Order, not OrderLine) |
| Collection semantics | save adds/updates, findById retrieves, delete removes |
| Store independence | Same interfaces work with JPA, MongoDB, JDBC… (store-specific ones add features) |
| Discovery | Spring Boot auto-configures scanning in the application package and below |

3.2 CRUD

| Method | Behaviour (JPA) | Note |
|---|---|---|
save(entity) | persist if new, otherwise merge | Returns the managed instance — use the return value |
saveAll(iterable) | Loop of save | Batching needs JDBC batch settings (Chapter 4) |
findById(id) | EntityManager.find → Optional<T> | Uses persistence context cache |
findAllById(ids) | IN query | Missing ids are silently absent |
existsById(id) | Count/exists query | No entity loading |
count() | SELECT COUNT(*) | Expensive on huge tables |
deleteById(id) | Loads entity, then removes | Cascades and callbacks apply |
deleteAll() | Loads all, deletes one by one | Use deleteAllInBatch (JPA) for bulk deletes |
New or existing? SimpleJpaRepository.save asks JpaEntityInformation.isNew(entity): by default, an entity is new when its @Id is null (or a primitive 0) or, if it has a @Version attribute, when the version is null. Entities with application-assigned ids (UUIDs set in the constructor) are therefore considered existing and merged — causing an extra SELECT. Fix it with a @Version field or by implementing Persistable<ID>.
3.3 Paging

Page<Order> page = orders.findByStatus(OrderStatus.OPEN, PageRequest.of(2, 20));
page.getContent(); // up to 20 orders
page.getTotalElements(); // requires a COUNT query
page.hasNext();
Slice<Order> slice = orders.findSliceByStatus(OrderStatus.OPEN, PageRequest.of(2, 20));
slice.hasNext(); // fetches 21 rows to decide — no COUNT query3.4 Sorting
// stable ordering
Sort sort = Sort.by(Sort.Order.desc("createdAt"), Sort.Order.asc("id"));
List<Order> recent = orders.findAll(sort);
// Type-safe sort with property references
Sort typed = Sort.sort(Order.class).by(Order::getCreatedAt).descending();Sorting is translated into ORDER BY on mapped properties (including nested paths such as customer.name, which add joins). Always include a unique tie-breaker (e.g. id) when paging, or rows with equal sort keys may appear on two pages.
4How It Works Internally
1. At start-up, Boot's JpaRepositoriesAutoConfiguration enables repository scanning; each interface becomes a JpaRepositoryFactoryBean bean definition.
2. The factory creates a proxy whose target is SimpleJpaRepository (CRUD, paging) and which also handles query methods (Topic 1.2).
3. CRUD calls delegate to the shared, transaction-bound EntityManager: persist, merge, find, remove, createQuery.
4. Paging: Spring Data builds a JPQL query with ORDER BY from the Sort, sets firstResult = page × size and maxResults = size, and — for Page — derives and executes a count query.
5. All SimpleJpaRepository methods are transactional: reads with readOnly = true, writes with a regular transaction, both joining an existing transaction if present.
4ADeep Dive — save() and the "Is New?" Decision
@Entity
public class Customer implements Persistable<UUID> {
@Id private UUID id = UUID.randomUUID(); // assigned before persisting
@Transient private boolean isNew = true;
@Override public UUID getId() { return id; }
@Override public boolean isNew() { return isNew; }
@PostLoad @PostPersist
void markNotNew() { this.isNew = false; }
}| Id strategy | save without Persistable/@Version | Consequence |
|---|---|---|
@GeneratedValue (sequence/identity) | id null → persist | INSERT (identity: immediate) |
| Assigned UUID in constructor | id set → merge | SELECT by id, then INSERT |
Assigned id + @Version Long version | version null → persist | INSERT only |
Assigned id + Persistable | isNew() decides | INSERT only |
5APIs / Features / Syntax
| API | Purpose |
|---|---|
Repository<T, ID>, CrudRepository, ListCrudRepository | Base interfaces |
PagingAndSortingRepository, ListPagingAndSortingRepository | Paging and sorting |
Pageable, PageRequest, Sort, Sort.TypedSort | Paging and ordering parameters |
Page<T>, Slice<T>, Window<T> | Results |
Persistable<ID>, @Version | New-entity detection |
spring.data.jpa.repositories.*, @EnableJpaRepositories | Configuration |
@DataJpaTest | Repository slice tests |
6Practical Examples
6.1 Paging from the web layer
@GetMapping("/api/orders")
PagedModel<OrderView> list(@RequestParam OrderStatus status,
@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC) Pageable pageable) {
return new PagedModel<>(orders.findByStatus(status, pageable).map(OrderView::from));
}6.2 SQL produced for a paged query
select o1_0.id, o1_0.created_at, o1_0.status, o1_0.total
from orders o1_0 where o1_0.status = ? order by o1_0.created_at desc
offset ? rows fetch first ? rows only;
select count(o1_0.id) from orders o1_0 where o1_0.status = ?;6.3 Repository test with Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:17");
@Autowired OrderRepository orders;
@Test
void pagesOpenOrders() {
orders.saveAll(sampleOrders(45));
Page<Order> page = orders.findByStatus(OrderStatus.OPEN, PageRequest.of(2, 20));
assertThat(page.getContent()).hasSize(5);
assertThat(page.getTotalElements()).isEqualTo(45);
}
}6.4 Bulk delete without loading entities
@Transactional
public void purgeCancelled(List<UUID> ids) {
// single DELETE ... WHERE id IN (...) — no cascades/callbacks
orders.deleteAllByIdInBatch(ids);
}6.5 Sorting safely
// Type-safe sort with property paths
Sort sort = Sort.sort(Order.class).by(Order::getCreatedAt).descending()
.and(Sort.by("id").descending()); // unique tie-breaker
// Sorting by a computed expression requires JpaSort.unsafe (never with client input)
Sort byLineCount = JpaSort.unsafe(Sort.Direction.DESC, "size(o.lines)");| Sorting rule | Reason |
|---|---|
| Whitelist client-supplied sort properties | Prevent unindexed sorts and property-path probing |
Add a unique tie-breaker (id) | Deterministic paging |
| Index the sort columns (with filter columns first) | Avoid filesort on large tables |
Avoid JpaSort.unsafe with user input | It passes raw expressions into JPQL |
6.6 Collection-like semantics of a repository
| Collection operation | Repository equivalent | Note |
|---|---|---|
add(order) | save(order) | Insert or merge |
get(id) | findById(id) | Optional — absence is explicit |
contains(id) | existsById(id) | SELECT count or EXISTS query |
remove(order) | delete(order) | Loads and removes managed entity |
size() | count() | Beware on large tables |
| iteration | findAll(Pageable) | Never iterate unpaged on large tables |
7Common Problems & Mistakes
- Repositories for every table instead of aggregates.
- Ignoring the return value of save (merge returns a different instance).
- findAll() on large tables — loads everything into memory.
- Paging without a unique tie-breaker sort.
- deleteAll() for bulk deletes — N selects and N deletes.
- Assigned ids without @Version/Persistable — extra SELECT per insert.
8Performance & Production Considerations
| Concern | Guidance |
|---|---|
| Counts | Page triggers counts — prefer Slice/Window for large tables |
| Deep pages | Offset cost grows; keyset scrolling (Chapter 4) |
| Bulk operations | deleteAllInBatch, @Modifying queries (bypass entity lifecycle) |
| Monitoring | Query counts and durations per repository method (Hibernate statistics, traces) |
9Architecture & Design Considerations
- Repositories per aggregate root, with domain-meaningful finders.
- Expose the minimum (custom base interfaces, Topic 1.3).
- Keep repositories free of business logic; rules live in the domain and services.
- Read models (projections, separate query services) for complex reads — CQRS-lite.
10Interview Questions & Answers
What is a Spring Data repository?
A Spring Data repository is an interface you declare, for example interface OrderRepository extends JpaRepository<Order, Long>, for which Spring generates an implementation at start-up. It provides standard operations such as save, findById, findAll and delete, plus paging and sorting. You can add query methods whose names Spring parses into queries, such as findByCustomerIdAndStatus, or define queries explicitly with @Query. Repositories hide most data-access boilerplate behind a consistent interface across stores such as JPA, MongoDB and Redis. They work best for aggregate persistence and simple queries; complex reporting usually needs dedicated query code.
Answer: Both represent one chunk of a larger result. A Page knows the total number of elements and pages, so the user interface can show "page 3 of 42"; to get that, Spring Data runs an extra count query, which can be expensive on large tables. A Slice only knows whether a next slice exists, which it finds by fetching one more row than requested, so no count query is needed. Use Page when users really need totals, and Slice for infinite scrolling or "load more" buttons. For very large data sets, keyset-based scrolling is more efficient than offset paging.Answer: SimpleJpaRepository.save asks the entity information whether the entity is new. By default, an entity is new if its identifier is null, or, when it has a @Version attribute, if the version is null; an entity can also decide for itself by implementing Persistable and its isNew method. For a new entity, save calls EntityManager.persist, which schedules an INSERT. Otherwise it calls merge, which loads the existing row if it is not already in the persistence context and copies the state across, resulting in an UPDATE at flush time. Knowing this explains several surprising extra SELECT statements.Why include a unique column in the sort when paging?
Offset-based paging assumes a stable order. If you sort only by a non-unique column, such as created date or status, rows with equal values have no defined order, and the database may return them in a different order on each query. A row can then appear on two pages or be skipped entirely as the user moves between pages, especially when data changes in between. Adding a unique column, usually the primary key, as the last sort key makes the order deterministic, for example Sort.by("createdAt").descending().and(Sort.by("id")). Keyset paging needs the same deterministic order.
Why can assigned UUIDs cause extra queries on save?
When an application assigns a UUID before saving, the identifier is never null, so Spring Data treats the entity as existing and calls merge instead of persist. Merge must find out whether the row exists, so Hibernate issues a SELECT before the INSERT, doubling the statements for every new entity. On bulk inserts this noticeably slows down writes. Fix it by adding a @Version attribute, which is null for new entities and is also useful for optimistic locking, or by implementing Persistable with an isNew flag that is set to false after persisting or loading.
Why is deleteAll() slow and what is the alternative?
deleteAll without arguments loads every entity of that type into memory and then deletes them one by one, issuing one DELETE per row, so deleting a large table can take minutes and use a lot of memory. It does this so that JPA life-cycle callbacks and cascades run for each entity. When those are not needed, deleteAllInBatch issues a single DELETE statement, and a @Modifying @Query can delete a filtered set in one statement. Bulk operations bypass the persistence context, so entities already loaded may be stale afterwards; clear the context or run the bulk operation in its own transaction.
How should repositories relate to aggregates?
In domain-driven design, an aggregate is a cluster of objects that must stay consistent together, with one entity, the aggregate root, as the only entry point. Repositories should follow the same boundary: one repository per aggregate root, such as OrderRepository, and none for internal entities such as OrderLine. All changes to order lines go through the Order, which enforces invariants like a maximum order value, and the whole aggregate is loaded and saved together, usually through cascades. Repositories for child entities let code bypass the root and break invariants, and they blur the consistency boundary that transactions rely on.
How do you balance repository convenience with read performance at scale?
Use each tool for what it does well. Repositories are ideal for loading and saving aggregates in transactional use cases and for simple lookups. For complex or high-volume reads, such as search screens, dashboards and exports, use projections that select only the needed columns, dedicated query services using JPQL, SQL or jOOQ, or separate read models fed by events. Make paging mandatory for list endpoints, and prefer keyset scrolling for large data sets. Measure query counts and latency in tests and production, and publish guidance so teams know when to step outside the repository abstraction.