We have spent two lessons writing entities and relationships, and in the examples we have been using a stationRepository that does not exist yet. Meanwhile, InMemoryStationRepository is still there from 02-01, with its ConcurrentHashMap, its AtomicLong and its six hand-written methods. This lesson retires it.
And it does so in the most striking way possible: by deleting code. Spring Data JPA turns an empty interface into a fully functional repository, with a good twenty methods already implemented, pagination, sorting and exception translation. It is probably the moment in the course where the ratio of effort to result is most favourable. But it is worth understanding what goes on underneath, because several of those inherited methods have semantics that are not what their name suggests —save does not always insert, getReferenceById does not query the database— and mixing them up produces errors that are hard to trace.
Contents
- Spring Data's interface hierarchy
- How Spring Data creates the implementation
- Retiring
InMemoryStationRepository - The inherited methods and their exact semantics
findByIdversusgetReferenceByIdsave: insert or mergeOptional<T>and handling absence- Pagination and sorting
- Integrating pagination into the REST API
- Repositories with methods of your own
@Repositoryand exception translationExampleandSpecification: a first look- Common Mistakes and Tips
- Exercises
- Spring Data's interface hierarchy
graph TD
R["Repository<T, ID><br/>marker, no methods"]
C["CrudRepository<T, ID><br/>save, findById, delete..."]
LC["ListCrudRepository<T, ID><br/>returns List instead of Iterable"]
P["PagingAndSortingRepository<T, ID><br/>findAll(Pageable), findAll(Sort)"]
J["JpaRepository<T, ID><br/>flush, saveAndFlush, getReferenceById"]
R --> C
C --> LC
R --> P
LC --> J
P --> J
| Interface | What it brings | When to choose it |
|---|---|---|
Repository<T, ID> |
Nothing: it only marks the interface so Spring Data detects it | When you want to expose only the methods you declare |
CrudRepository<T, ID> |
Basic CRUD; returns Iterable<T> |
Simple applications, with no pagination |
ListCrudRepository<T, ID> |
The same, but returns List<T> |
Almost always better than CrudRepository |
PagingAndSortingRepository<T, ID> |
findAll(Pageable) and findAll(Sort) |
When all you need is pagination |
JpaRepository<T, ID> |
Everything above plus flush, saveAndFlush, getReferenceById, deleteAllInBatch |
The default option with JPA |
What to choose in CicloUrbana. JpaRepository for almost everything: it is what people expect and there is no cost for methods you do not use. The alternative worth considering is extending Repository<T, ID> and declaring only the permitted methods:
public interface RentalRepository extends Repository<Rental, Long> {
Rental save(Rental rental);
Optional<Rental> findById(Long id);
List<Rental> findByUserIdOrderByStartedAtDesc(Long userId);
// deleteById is deliberately NOT exposed: rentals are never deleted
}It is a defensible design decision: a rental history must never be deleted, and not exposing the method makes the accident impossible. The price is writing by hand the signatures you do want. (A note on ListCrudRepository: it is an addition in Spring Data 3 that returns List<T> where CrudRepository returned Iterable<T>; JpaRepository already extends it.)
- How Spring Data creates the implementation
This is the question everybody asks: who implements an interface that nobody implements?
At startup, JpaRepositoriesAutoConfiguration (02-06) activates repository scanning from the @SpringBootApplication package. For every interface extending Repository:
JpaRepositoryFactoryBeananalyses it and determines the entity and the type of its id.- It creates an instance of
SimpleJpaRepository<T, ID>, the standard implementation containing the real code ofsave,findById,findAlland so on, written against theEntityManager. - It wraps that instance in a Java dynamic proxy implementing your interface.
- It registers the proxy as a bean in the context.
sequenceDiagram
participant A as Spring startup
participant F as JpaRepositoryFactoryBean
participant P as Dynamic proxy
participant S as SimpleJpaRepository
participant EM as EntityManager
A->>F: StationRepository found
F->>S: create SimpleJpaRepository(Station.class, em)
F->>P: create proxy implementing StationRepository
A->>A: register the proxy as a bean
Note over P,S: At run time
P->>S: findById(1L) -> delegates
S->>EM: em.find(Station.class, 1L)
When you call a method, the proxy decides who to delegate to: if it is inherited (findById, save), to SimpleJpaRepository; if it is a derived query (findByName), it parses the name and generates the query (04-06); if it carries @Query, it runs that query; and if it belongs to a Custom interface you implement, to your implementation (section 10).
This proxy mechanism is the same one from 02-03, taken to the extreme that there is no wrapped object you wrote: the proxy is the only thing that exists of your interface. And a useful consequence: repositories are ordinary singleton beans, injectable through the constructor like any other (02-02).
- Retiring
InMemoryStationRepository
InMemoryStationRepositoryThis is the moment we have been preparing since 02-01. The in-memory implementation was, in summary:
@Repository
public class InMemoryStationRepository implements StationRepository {
private final Map<Long, Station> store = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong(0);
@Override
public Station save(Station station) {
Long id = station.id() != null ? station.id() : sequence.incrementAndGet();
store.put(id, new Station(id, station.name(), /* ... */));
return store.get(id);
}
@Override public Optional<Station> findById(Long id) { /* ... */ }
@Override public List<Station> findAll() { /* ... */ }
@Override public boolean existsById(Long id) { /* ... */ }
@Override public void deleteById(Long id) { /* ... */ }
@Override public boolean existsByName(String name) { /* ... */ }
}Around eighty lines counting the real file. Its complete replacement:
package com.ciclourbana.stations;
public interface StationRepository extends JpaRepository<Station, Long> {
boolean existsByName(String name);
List<Station> findByActiveTrue();
}What disappears:
| Removed | Replaced by |
|---|---|
The whole of InMemoryStationRepository |
SimpleJpaRepository, generated |
ConcurrentHashMap and AtomicLong |
The PostgreSQL sequence (04-03) |
save, findById, findAll... |
Methods inherited from JpaRepository |
A hand-written existsByName |
existsByName, a derived query (04-06) |
DemoStationLoader |
Data migration with Flyway (04-08) |
What has to be adjusted in StationService. The names of our own interface already line up with Spring Data's vocabulary —save, findById, findAll, existsById, deleteById, existsByName—, so the calls stay as they are; what changes are the exact contracts: findById hands back an Optional<Station>, save returns the managed instance you have to keep, and deleteById no longer tells you whether the row existed. That is the only real cost of the change.
You could keep a vocabulary of your own by declaring the methods in the interface and annotating each with @Query, but it does not pay off: Spring Data's names are a shared vocabulary that any Java developer recognises instantly. The domain identifiers stay yours —Station, StationService, StationRepository—; what you adopt is the framework's API, in the same way you write List and not a wrapper of your own.
And the important part: StationController does not change by a single line. The Ribalta REST API stays exactly as it was, with its thirteen endpoints, its DTOs and its status codes. That is the reward for having isolated storage behind an interface from the beginning.
- The inherited methods and their exact semantics
| Method | What it does exactly | Queries |
|---|---|---|
save(T) |
persist if it is new, merge if it has an id |
0-2 |
saveAll(Iterable<T>) |
save on each element |
N |
saveAndFlush(T) |
save + immediate flush |
1-2 |
findById(ID) |
Optional with the entity, or empty. Queries right away |
0-1 |
getReferenceById(ID) |
A lazy proxy. No query | 0 |
findAll() |
Every row in the table | 1 |
findAllById(Iterable<ID>) |
WHERE id IN (...) |
1 |
existsById(ID) |
SELECT count(*) ... WHERE id = ? |
1 |
count() |
SELECT count(*) |
1 |
deleteById(ID) |
Loads the entity and deletes it | 1-2 |
delete(T) |
Deletes an already loaded entity | 1 |
deleteAll() |
Loads all of them and deletes them one by one | 1 + N |
deleteAllInBatch() |
A single DELETE FROM table |
1 |
flush() |
Synchronises the context with the database | The pending ones |
Three warnings that prevent surprises:
deleteAll() versus deleteAllInBatch(). The first loads every entity and issues one DELETE per entity so it can apply cascades and callbacks: with 100,000 rentals, 100,001 statements. deleteAllInBatch() runs a single DELETE FROM rentals, but it skips cascades and the persistence context, which can end up holding entities that no longer exist. Fast and dangerous.
deleteById runs a SELECT before the DELETE, because it needs the entity in order to apply cascades. And if the id does not exist, in Spring Data 3 it throws no exception: it simply does nothing. To return the correct 404 (03-06) you have to check first with existsById and throw ResourceNotFoundException.
count() and existsById() do not load entities. They are pure aggregation queries. Preferring existsById(id) over findById(id).isPresent() is not cosmetic: the latter fetches every column of the row only to throw them away.
findById versus getReferenceById
findById versus getReferenceByIdThis is the most misunderstood difference in the API, and the one that can save the most performance.
findById(id) |
getReferenceById(id) |
|
|---|---|---|
| Queries the database | Yes, immediately | No |
| Returns | Optional<T> |
T (a proxy) |
| If the id does not exist | Optional.empty() |
Fails later, with EntityNotFoundException |
| Typical use | Reading or modifying the entity | Only assigning it as a foreign key |
The case where getReferenceById shines is exactly CicloUrbana's when starting a rental:
@Transactional
public RentalResponse start(StartRentalRequest request) {
Bike bike = bikeRepository.findById(request.bikeId())
.orElseThrow(() -> new ResourceNotFoundException("Bike", request.bikeId()));
if (!bike.canBeRented(networkProperties.batteryThreshold())) {
throw new BikeUnavailableException(bike.getPlate());
}
Rental rental = new Rental();
// We only need the id for the foreign key: there is NO need to load the user
rental.setUser(userRepository.getReferenceById(request.userId()));
rental.setBike(bike);
rental.setOriginStation(stationRepository.getReferenceById(request.originStationId()));
rental.setStartedAt(Instant.now());
bike.setStatus(BikeStatus.IN_USE);
return mapper.toResponse(rentalRepository.save(rental));
}Bike is loaded with findById because we have to read its status and its battery and modify its status. User and Station are only needed to fill user_id and origin_station_id in the INSERT, and for that the id the proxy already holds is enough: we save two SELECTs in the most frequent operation in the whole application.
The risk, which you must be aware of: if the user does not exist, getReferenceById does not fail there but later —when accessing a field of the proxy, or at commit, where the foreign key fails—, and the error arrives dislocated from its cause. The rule: use getReferenceById only to assign associations whose id you have already validated, for instance because it comes from an authenticated user (module 5).
save: insert or merge
save: insert or mergesave() looks like a plain "save" and it does two very different things depending on the entity's state (04-01):
graph TD
A["save(entity)"] --> B{"is the id null?"}
B -->|Yes| C["persist(): INSERT<br/>the entity becomes managed"]
B -->|No| D["merge(): SELECT + UPDATE<br/>returns a managed COPY"]
The most important practical consequence lies in merge: it returns an instance different from the one you passed in. The one you passed in stays detached.
Station detached = new Station(...); // with id = 1, coming from outside
Station managed = stationRepository.save(detached);
detached == managed; // false
detached.setName("Another name"); // NOT saved: it is still detached
managed.setName("Another name"); // IS saved: it is managedAlways use the value returned by save(). Ignoring it is one of the most common causes of "I modify it and it doesn't get saved".
And the corollary, which we already saw in 04-01 and will develop in 04-07: on a managed entity there is no need to call save(). Inside a transaction, loading with findById, doing station.setActive(false) and calling nothing else is enough and correct: dirty checking generates the UPDATE at commit.
A performance nuance in bulk loads: save() on an entity with an id runs a SELECT before the UPDATE, so it can merge. When inserting thousands of rows with ids already assigned, that SELECT is paid for every one of them. If you know the entity is new, entityManager.persist() avoids it; Spring Data works it out on its own only when the id is null or when the entity implements Persistable.
Optional<T> and handling absence
Optional<T> and handling absenceSpring Data returns Optional<T> from lookups by identifier. It is a deliberate design decision: it makes it impossible to forget the "does not exist" case, which with null is forgotten constantly.
Correct usage in CicloUrbana links directly to the exception hierarchy from 03-06:
@Transactional(readOnly = true)
public StationResponse findById(Long id) {
return stationRepository.findById(id)
.map(mapper::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("Station", id));
}It reads straight through: look it up, map it if it is there, and if not throw the exception that the @RestControllerAdvice turns into a 404 with a ProblemDetail.
Frequent incorrect forms:
findById(id).get(); // WRONG: NoSuchElementException -> 500 instead of 404
findById(id).orElse(null); // WRONG: reintroduces the null Optional came to remove
// WRONG: verbose and equivalent to orElseThrow
Optional<Station> opt = findById(id);
if (opt.isEmpty()) throw new ResourceNotFoundException("Station", id);Useful Optional methods in this context: map to transform, filter to add a condition, orElseThrow to demand presence, orElseGet for a computed default and ifPresentOrElse for two branches. And never use Optional as a method parameter or as an entity field: it is meant for return values.
- Pagination and sorting
GET /api/v1/stations today returns Ribalta's four stations. When the network grows to two hundred, or when the year's rentals are listed, returning everything will stop being viable: memory on the server, bandwidth and a client that cannot process it.
The pieces:
| Type | What it is |
|---|---|
Pageable |
A page request: number, size and sorting |
PageRequest |
Its implementation: PageRequest.of(0, 20, Sort.by("name")) |
Sort |
Sorting: Sort.by(Sort.Direction.DESC, "capacity") |
Page<T> |
A result with the total number of elements and pages |
Slice<T> |
A result without the total: it only knows whether there is a next page |
| Return type | SQL queries | Knows the total | When to use it |
|---|---|---|---|
List<T> |
1 | No | When you need no metadata |
Slice<T> |
1 (asks for size + 1 rows) |
No | Infinite scrolling, large tables |
Page<T> |
2 (one for data and one count) |
Yes | Tables with page numbers |
The difference in cost matters: Page runs a second SELECT count(*) query which, over a table of millions of rentals with complex filters, can be more expensive than the data query itself. Slice avoids it by asking for one extra row and checking whether it came back.
Nothing has to be declared in the repository: JpaRepository already inherits findAll(Pageable).
Page<Station> page = stationRepository.findAll(
PageRequest.of(0, 20, Sort.by("name").ascending()));
page.getContent(); // List<Station> with this page's 20
page.getTotalElements(); // 200 page.getTotalPages(); // 10
page.getNumber(); // 0 page.hasNext(); // trueAnd the SQL it generates with PostgreSQL:
select s1_0.id, s1_0.name, ... from stations s1_0
order by s1_0.name asc
offset 0 rows fetch first 20 rows only;
select count(s1_0.id) from stations s1_0; -- only with Page, not with SliceA warning about deep pages. OFFSET 100000 forces the database to read and discard a hundred thousand rows before returning twenty. With large tables, cursor-based pagination —"give me the next ones after this id"— is far more efficient (09-01).
- Integrating pagination into the REST API
Spring MVC resolves Pageable automatically from the query parameters, thanks to PageableHandlerMethodArgumentResolver, which Spring Boot registers on its own.
@GetMapping
public PageResponse<StationResponse> list(
@PageableDefault(size = 20, sort = "name") Pageable pageable) {
return stationService.list(pageable);
}The API now accepts GET /api/v1/stations?page=0&size=20&sort=capacity,desc.
@PageableDefault matters: without it, the default size is 20 but the client can ask for size=100000 and bring the server down. Cap the maximum globally as well:
spring:
data:
web:
pageable:
default-page-size: 20
max-page-size: 100
one-indexed-parameters: false # the first page is page 0Why Page is not returned directly. It is tempting —it works and Jackson serialises it— and it is a mistake. At startup, Spring Boot 3 even warns:
Serializing PageImpl instances as-is is not supported, meaning that there is no
guarantee about the stability of the resulting JSON structure!Three concrete reasons: the JSON structure is not stable —PageImpl is an internal Spring Data class whose serialisation has changed across versions and may change again, breaking every Ribalta client on a dependency upgrade—; it leaks internal details, because the JSON includes a pageable object with paged, unpaged and offset, framework concepts alien to the public contract; and it contradicts the discipline of 03-05, since if we do not expose entities, we certainly should not expose the framework's internal classes.
The solution is a DTO of our own, stable and documentable in OpenAPI (03-07):
package com.ciclourbana.common.dto; // page DTO, stable and documentable
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean first,
boolean last) {
public static <T> PageResponse<T> of(Page<T> page) {
return new PageResponse<>(
page.getContent(), page.getNumber(), page.getSize(),
page.getTotalElements(), page.getTotalPages(),
page.isFirst(), page.isLast());
}
}And in the service, with the mapping inside the transaction as 04-04 demands:
@Transactional(readOnly = true)
public PageResponse<StationResponse> list(Pageable pageable) {
Page<StationResponse> page = stationRepository.findAll(pageable)
.map(mapper::toResponse);
return PageResponse.of(page);
}Page.map() transforms the content while preserving the metadata: nothing has to be rebuilt by hand.
The response to the client:
{
"content": [ { "id": 1, "name": "Main Square", "capacity": 24 },
{ "id": 2, "name": "North Station", "capacity": 30 } ],
"page": 0, "size": 20, "totalElements": 4, "totalPages": 1,
"first": true, "last": true
}
- Repositories with methods of your own
Sometimes a method needs logic that neither derived queries nor @Query can express: direct access to the EntityManager, dynamic construction of criteria or native SQL calls with intermediate processing. Spring Data allows it through a very specific naming convention.
Step 1: the interface with your own methods.
package com.ciclourbana.stations;
public interface StationRepositoryCustom {
List<Station> findWithFilters(String name, Integer minimumCapacity, Boolean active);
}Step 2: the implementation. The name is mandatory: <InterfaceName>Impl.
package com.ciclourbana.stations;
public class StationRepositoryCustomImpl implements StationRepositoryCustom {
private final EntityManager entityManager;
public StationRepositoryCustomImpl(EntityManager em) { this.entityManager = em; }
@Override
public List<Station> findWithFilters(String name, Integer minimumCapacity,
Boolean active) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Station> query = cb.createQuery(Station.class);
Root<Station> root = query.from(Station.class);
List<Predicate> predicates = new ArrayList<>();
if (name != null && !name.isBlank())
predicates.add(cb.like(cb.lower(root.get("name")),
"%" + name.toLowerCase() + "%"));
if (minimumCapacity != null)
predicates.add(cb.greaterThanOrEqualTo(root.get("capacity"), minimumCapacity));
if (active != null)
predicates.add(cb.equal(root.get("active"), active));
query.where(predicates.toArray(Predicate[]::new))
.orderBy(cb.asc(root.get("name")));
return entityManager.createQuery(query).getResultList();
}
}Step 3: the repository extends both interfaces, with extends JpaRepository<Station, Long>, StationRepositoryCustom.
Spring Data detects that findWithFilters is neither an inherited nor a derivable method, looks for a class called StationRepositoryCustomImpl and delegates to it. The Impl suffix is mandatory —it is configurable with repositoryImplementationPostfix, but there is no reason to change it— and it is mistake number one with this mechanism: with any other name, startup fails with No property findWithFilters found for type Station.
Notice the real value of this pattern: the caller still sees a single interface. StationService injects StationRepository and calls findWithFilters without knowing that its implementation lives in another class. The composition is invisible from outside.
@Repository and exception translation
@Repository and exception translationInterfaces do not need the @Repository annotation. Spring Data detects them because they extend Repository. The annotation is necessary on hand-written data access classes, and on StationRepositoryCustomImpl it is optional.
What does matter is what @Repository enables: exception translation. A PersistenceExceptionTranslationPostProcessor —a BeanPostProcessor like the ones from 02-03— wraps the bean and converts provider-specific exceptions into Spring's DataAccessException hierarchy:
| Original exception | Translated into |
|---|---|
ConstraintViolationException (Hibernate) |
DataIntegrityViolationException |
StaleObjectStateException (Hibernate) |
ObjectOptimisticLockingFailureException |
NoResultException (JPA) |
EmptyResultDataAccessException |
A connection PSQLException |
DataAccessResourceFailureException |
The benefit is real: your code catches Spring exceptions and does not depend on Hibernate. If the JPA implementation ever changed, the catch blocks would still be valid.
In CicloUrbana this is put to use in the global handler from 03-06:
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handleIntegrityViolation(DataIntegrityViolationException ex) {
log.warn("Integrity violation: {}", ex.getMostSpecificCause().getMessage());
ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The operation violates a data integrity constraint.");
p.setTitle("Integrity conflict");
p.setProperty("code", "INTEGRITY_VIOLATION");
return p;
}It is a safety net, not the main route: the right thing is still to check existsByName before inserting and return a 409 explaining which name is duplicated; this handler covers the race conditions that the prior check cannot avoid. Note as well that the message to the client does not include the exception detail, which contains table and constraint names: to the log yes, to the response no.
Example and Specification: a first look
Example and Specification: a first lookSpring Data offers two more mechanisms for dynamic queries. Here is the overview; the detail is in 04-06.
Example (query by example). You build a partially filled entity and Spring Data looks for the ones that resemble it.
Station probe = new Station();
probe.setActive(true);
probe.setCapacity(24);
ExampleMatcher matcher = ExampleMatcher.matching()
.withIgnoreNullValues()
.withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING).withIgnoreCase();
List<Station> result = stationRepository.findAll(Example.of(probe, matcher));It is convenient for simple equality filters, and very limited: it expresses neither ranges (capacity > 20), nor OR, nor conditions over associations. We will not use it in CicloUrbana.
Specification (the Criteria API packaged up). Each condition is an object composable with and and or:
public class StationSpecs {
public static Specification<Station> nameContains(String text) {
return (root, query, cb) -> text == null ? null
: cb.like(cb.lower(root.get("name")), "%" + text.toLowerCase() + "%");
}
public static Specification<Station> minimumCapacity(Integer minimum) {
return (root, query, cb) -> minimum == null ? null
: cb.greaterThanOrEqualTo(root.get("capacity"), minimum);
}
}// The repository must also extend JpaSpecificationExecutor<Station>
Page<Station> result = stationRepository.findAll(
StationSpecs.nameContains("north").and(StationSpecs.minimumCapacity(20)),
pageable);Returning null when the filter is absent is the key: Spring Data ignores those predicates, so the station search accepts any combination of optional filters without a single if concatenating SQL. It is cleaner than the StationRepositoryCustomImpl from section 10, and we will develop it in 04-06.
Common Mistakes and Tips
Ignoring the value returned by save(). With a detached entity, save performs a merge and returns a managed copy; the original stays detached and its changes are lost.
Calling save() on a managed entity. Harmless but unnecessary: dirty checking already generates the UPDATE. It betrays a lack of understanding of the persistence context.
Using findById(id).get(). It throws NoSuchElementException, which without a handler ends up as a 500 instead of the correct 404. Use orElseThrow with ResourceNotFoundException.
Returning Page directly from the controller. JSON that is unstable across versions and framework details in the public contract. Use a PageResponse of your own.
Naming your own implementation wrongly. It must be exactly <InterfaceName>Impl and live in the same package. With any other name, startup fails.
Using deleteAll() on large tables. It loads every entity and issues one DELETE per entity.
Tip: use getReferenceById to assign foreign keys. In a "start rental", it saves two SELECTs in CicloUrbana's most frequent operation.
Tip: prefer existsById over findById(id).isPresent(). The former is a count(*); the latter fetches the whole row.
Tip: cap max-page-size. Without that cap, a client can ask for a million records in a single request.
Tip: do not expose deleteById where it should not exist. Extending Repository and declaring only the permitted methods turns a business rule into a technical impossibility.
Exercises
Exercise 1: choose the base interface
For each CicloUrbana repository, choose the base interface and justify it in one or two sentences.
StationRepository: full CRUD with paginated listings.RentalRepository: they are created and queried, never deleted; paginated listings per user.FareRepository: five fixed rows, loaded at startup, read-only.IncidentRepository: creation, querying, closing and deletion of the ones that turn out to be false.
Exercise 2: end-to-end pagination
Implement the endpoint GET /api/v1/rentals?userId=7&page=0&size=20&sort=startedAt,desc returning a user's rentals paginated. Write the repository, the service and the controller, and explain why you choose Page or Slice.
Exercise 3: diagnose three faults
This service has three defects. Find them, explain the symptom each one produces and fix them.
@Service
public class StationService {
private final StationRepository repository;
private final StationMapper mapper;
public StationService(StationRepository r, StationMapper m) {
this.repository = r; this.mapper = m;
}
public StationResponse update(Long id, UpdateStationRequest request) {
Station station = repository.findById(id).get();
station.setName(request.name());
station.setCapacity(request.capacity());
repository.save(station);
return mapper.toResponse(station);
}
public void delete(Long id) {
repository.deleteById(id);
}
public Page<StationResponse> list(Pageable pageable) {
return repository.findAll(pageable).map(mapper::toResponse);
}
}Solutions
Solution 1.
JpaRepository<Station, Long>. It needs full CRUD and pagination; it is the standard case and there is no reason to restrict the API.Repository<Rental, Long>with hand-declared methods. The rule "a rental is never deleted" is a business rule, and the best way to guarantee it is not to expose the method:
public interface RentalRepository extends Repository<Rental, Long> {
Rental save(Rental rental);
Optional<Rental> findById(Long id);
Page<Rental> findByUserId(Long userId, Pageable pageable);
long countByUserIdAndEndedAtIsNull(Long userId);
}Without deleteById in the interface, nobody can delete a rental by accident: a business rule turned into a technical impossibility.
ListCrudRepository<Fare, String>, or evenRepositorywith onlyfindAllandfindByCode: five fixed rows need no pagination, and exposingdeleteAllover the fare catalogue is an unnecessary risk.JpaRepository<Incident, Long>: it needs all four operations, including the legitimate deletion of false ones.
Solution 2.
// Repository
public interface RentalRepository extends JpaRepository<Rental, Long> {
Page<Rental> findByUserId(Long userId, Pageable pageable);
}// Service
@Transactional(readOnly = true)
public PageResponse<RentalResponse> listByUser(Long userId, Pageable pageable) {
if (!userRepository.existsById(userId)) {
throw new ResourceNotFoundException("User", userId);
}
Page<RentalResponse> page = rentalRepository
.findByUserId(userId, pageable)
.map(mapper::toResponse);
return PageResponse.of(page);
}// Controller
@GetMapping("/api/v1/rentals")
public PageResponse<RentalResponse> list(
@RequestParam Long userId,
@PageableDefault(size = 20, sort = "startedAt",
direction = Sort.Direction.DESC) Pageable pageable) {
return rentalService.listByUser(userId, pageable);
}Page or Slice. For "my rentals" in a mobile app with infinite scrolling, Slice is better: it saves the SELECT count(*) and the user never sees the total. For an administration dashboard with page numbers, Page is necessary because you have to draw "page 3 of 47". Here we choose Page because the endpoint serves both consumers and the volume per user is moderate —a few hundred rentals—, so the count is cheap; if the history grew to millions of rows per user, we would migrate to Slice or to cursor-based pagination (09-01).
Details not to overlook: @Transactional(readOnly = true) lets Hibernate skip dirty checking (04-07); the mapping to DTO happens inside the transaction, avoiding LazyInitializationException with open-in-view: false; and we check that the user exists, so we return 404 instead of an empty page that would lie about the resource existing.
Solution 3. The three defects:
1. findById(id).get(). If the station does not exist, it throws NoSuchElementException and the client receives a 500 instead of the 404 with ProblemDetail that the 03-06 contract defines.
2. The @Transactional annotations are missing. This is the most serious one: update runs findById and save in two different transactions, so in between the entity is left detached, dirty checking is lost, save has to perform a merge with its extra SELECT and there is no atomicity if something fails in the middle.
3. deleteById without checking existence. It throws no exception if the id does not exist: the client receives a 204 No Content indicating that something which never existed was deleted. And a fourth, minor defect: list returns Page<StationResponse> to the controller, with the JSON stability problem from section 9. Corrected version:
@Service
@Transactional(readOnly = true) // the default for the whole class
public class StationService {
// constructor taking StationRepository repository and StationMapper mapper
@Transactional // overrides readOnly: this method writes
public StationResponse update(Long id, UpdateStationRequest request) {
Station station = repository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Station", id));
station.setName(request.name());
station.setCapacity(request.capacity());
// no save(): the entity is managed and dirty checking does the UPDATE
return mapper.toResponse(station);
}
@Transactional
public void delete(Long id) {
if (!repository.existsById(id)) {
throw new ResourceNotFoundException("Station", id);
}
repository.deleteById(id);
}
public PageResponse<StationResponse> list(Pageable pageable) {
return PageResponse.of(repository.findAll(pageable).map(mapper::toResponse));
}
}The pattern of @Transactional(readOnly = true) on the class with @Transactional on the methods that write is an excellent convention: everything is read-only by default and writing requires an explicit decision. We will develop it in 04-07.
Conclusion
InMemoryStationRepository is now history. You can place each interface of the Spring Data hierarchy and choose with judgement: JpaRepository as the default option and Repository with hand-declared methods when you want a business rule —"a rental is never deleted"— to be a technical impossibility. You understand how the implementation appears: JpaRepositoryFactoryBean creates a SimpleJpaRepository and wraps it in a dynamic proxy implementing your interface, the same proxy mechanism from 02-03 taken to the point where there is no class of yours underneath. And you have seen the result of the change: eighty lines of concurrent map replaced by an interface of four, without touching a single line of StationController.
You know the exact semantics of the inherited methods, including the three that surprise: save performs a merge if the entity has an id and returns a different instance that you must use; getReferenceById does not query the database and saves two SELECTs every time CicloUrbana starts a rental; and deleteById does not fail when the id does not exist, so the 404 has to be produced by checking beforehand. You handle Optional by linking it to ResourceNotFoundException in an orElseThrow that reads straight through. You have added pagination and sorting to the Ribalta API, telling Page from Slice by the cost of its SELECT count(*), receiving Pageable with @PageableDefault and capping max-page-size so that no client can ask for a million rows. And you return a PageResponse of your own instead of serialising PageImpl, for the same reason you did not expose entities in 03-05. You know how to compose a repository with methods of your own while respecting the Impl suffix, how to use @Repository's exception translation as a safety net against race conditions, and you have a first view of Example and Specification.
What you still cannot do is ask questions. Everything you have queried has been by identifier or the whole table. The Ribalta network needs much more: the stations with free space, the bikes with battery below the threshold, a user's rentals between two dates, the stations nearest to a coordinate, the occupancy report in a single query. Lesson 04-06, Query Methods in Spring Data JPA, covers the whole arsenal: queries derived from the method name with their exhaustive keyword table, @Query with JPQL and projections to DTOs, JOIN FETCH to settle the N+1 of 04-04 once and for all, native PostgreSQL queries for what JPQL cannot reach, @Modifying with its synchronisation traps, projections by interface and by record, @EntityGraph and Specifications for the search with combinable optional filters.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
