After the previous lesson, CicloUrbana knows how to save, look up by identifier, count and paginate. It knows how to do the basics. But the Ribalta network needs a great deal 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. All of those are questions, and Spring Data offers five different ways to ask them.
This is the most practical lesson in the module and the one that changes day-to-day work the most. Here is the complete arsenal: queries derived from the method name —the mechanism that surprises newcomers to Spring Data the most—, @Query with JPQL, native PostgreSQL queries, bulk modifications with @Modifying, projections that avoid loading whole entities, @EntityGraph to settle the N+1 of 04-04 and Specifications for dynamic filters. And, cutting across all of it, the judgement to choose: every mechanism has a point where it stops being the right tool.
Contents
- Queries derived from the method name
- An exhaustive keyword table
- Nested properties and ambiguities
- When to abandon the method name
@Querywith JPQL- DTO projections in the query
JOIN FETCHand solving N+1- Native queries
@Modifying:UPDATEandDELETE- Projections by interface and by
record @EntityGraphSpecificationand the Criteria API@NamedQuery,StreamableandStream- Verifying the SQL actually executed
- Common Mistakes and Tips
- Exercises
- Queries derived from the method name
The mechanism is simple to state and surprising the first time: you declare a method whose name describes the query and Spring Data generates it.
public interface BikeRepository extends JpaRepository<Bike, Long> {
Optional<Bike> findByPlate(String plate);
List<Bike> findByStatus(BikeStatus status);
List<Bike> findByStatusAndBatteryLevelGreaterThanEqual(
BikeStatus status, int minimumLevel);
long countByStationId(Long stationId);
boolean existsByPlate(String plate);
}None of them has an implementation and all five work. At startup, PartTreeJpaQuery parses each name and builds the query.
How the name is parsed. It splits into two parts: the subject (findByStatus... → find) says what is returned —find, read, get, query, search are equivalent; plus count, exists, delete, with Distinct and limiters such as Top10 or First—, and the predicate, which starts at By and describes the filter with property names, operators and connectors.
The parsing is strict. findByBatteryLevel works because Bike has a batteryLevel field; findByBattery fails at startup:
org.springframework.data.mapping.PropertyReferenceException:
No property 'battery' found for type 'Bike'. Did you mean 'batteryLevel'?That is a great advantage: the error shows up at startup, not on the first request. Renaming an entity field breaks startup and forces you to fix the methods that used it: the early validation that SQL strings never give you.
- An exhaustive keyword table
| Keyword | Example in CicloUrbana | Generated JPQL (fragment) |
|---|---|---|
findBy |
findByName(String n) |
where e.name = ?1 |
readBy/getBy/queryBy |
Synonyms of findBy |
The same |
countBy |
countByStatusAndStationId(...) |
select count(e) where ... |
existsBy |
existsByPlate(String p) |
select count(e) > 0 where ... |
deleteBy/removeBy |
deleteByStatusAndEndedAtIsNotNull(...) |
delete from ... where ... |
And |
findByStatusAndActiveTrue(...) |
where a = ?1 and b = true |
Or |
findByStatusOrBatteryLevelLessThan(...) |
where a = ?1 or b < ?2 |
Between |
findByStartedAtBetween(Instant f, Instant t) |
where e.startedAt between ?1 and ?2 |
LessThan / LessThanEqual |
findByBatteryLevelLessThan(int n) |
where e.batteryLevel < ?1 |
GreaterThan / GreaterThanEqual |
findByCapacityGreaterThanEqual(int c) |
where e.capacity >= ?1 |
After / Before |
findByStartedAtAfter(Instant i) |
where e.startedAt > ?1 |
Like / NotLike |
findByNameLike(String p) |
where e.name like ?1 |
Containing |
findByNameContaining(String t) |
where e.name like %?1% |
StartingWith / EndingWith |
findByPlateStartingWith("RB-") |
where e.plate like ?1% |
In / NotIn |
findByStatusIn(List<BikeStatus> l) |
where e.status in ?1 |
IsNull / IsNotNull |
findByEndedAtIsNull() |
where e.endedAt is null |
True / False |
findByActiveTrue() |
where e.active = true |
IgnoreCase |
findByNameIgnoreCase(String n) |
where upper(e.name) = upper(?1) |
OrderBy...Asc/Desc |
findByActiveTrueOrderByNameAsc() |
order by e.name asc |
Top/First |
findTop5ByOrderByStartedAtDesc() |
limit 5 |
Distinct |
findDistinctByStationId(Long id) |
select distinct e |
Not |
findByStatusNot(BikeStatus s) |
where e.status <> ?1 |
CicloUrbana's repositories with genuinely useful queries:
public interface StationRepository extends JpaRepository<Station, Long> {
Optional<Station> findByNameIgnoreCase(String name);
boolean existsByName(String name);
List<Station> findByActiveTrueOrderByNameAsc();
List<Station> findByCapacityGreaterThanEqual(int minimumCapacity);
List<Station> findByNameContainingIgnoreCase(String text);
Page<Station> findByActiveTrue(Pageable pageable);
}
public interface RentalRepository extends JpaRepository<Rental, Long> {
List<Rental> findByUserIdAndEndedAtIsNull(Long userId); // in progress
Page<Rental> findByUserIdOrderByStartedAtDesc(Long id, Pageable p);
List<Rental> findByStartedAtBetween(Instant from, Instant to);
long countByBikeIdAndEndedAtIsNotNull(Long bikeId);
List<Rental> findTop10ByOrderByTotalAmountDesc(); // most expensive
}Notice findByUserIdAndEndedAtIsNull: this is the query that answers "does this user have a rental in progress?", CicloUrbana's central business rule, and it fits in a method name without writing SQL or JPQL. A practical detail: the order of the parameters must match the order in which they appear in the name; swapping them does not compile if the types differ, but with two parameters of the same type the error moves to run time and silently produces wrong results. A good reason not to chain too many conditions.
- Nested properties and ambiguities
You can navigate to properties of related entities:
List<Bike> findByStationName(String name); // bikes at station "X"
List<Rental> findByUserEmail(String email); // rentals for that emailSpring Data generates a JOIN automatically:
The ambiguity problem. The parser resolves findByStationName greedily: first it looks for a stationName property on Bike and, failing to find it, splits at the last capital letter and looks for station.name. If both existed the first would win, and the query would be a different one from the one you intended with no error at all. To disambiguate, an underscore is used: findByStation_Name(String name) is unequivocal. It is ugly and it breaks Java naming conventions, but it is explicit.
Beware of deep navigation: findByBikeStationLocationLatitude(...) generates three chained JOINs in an unreadable name. When you get there, move to @Query.
- When to abandon the method name
Derived queries are excellent up to a point, and that point is recognisable without ambiguity:
| Signal | Example |
|---|---|
| The name goes beyond about 60 characters | findByStatusAndBatteryLevelLessThanAndStationActiveTrueOrderByBatteryLevelAsc |
| There are more than three or four conditions | Any one with three Ands and an Or |
It mixes And and Or |
The precedence is not obvious when reading it |
| It needs aggregations | count, sum, avg over columns |
| It needs subqueries | "stations without a single available bike" |
It needs JOIN FETCH |
Solving the N+1 |
| The filter is optional | Filters that may or may not be supplied |
The alternatives, in order of preference: @Query with JPQL for complex but portable queries; a native query when PostgreSQL-specific SQL is needed; Specification for dynamic, combinable filters; and a repository of your own (Impl) for logic that does not fit into a single query.
@Query with JPQL
@Query with JPQLJPQL (Jakarta Persistence Query Language) is SQL over the object model: it queries entities and their properties, not tables and columns.
@Query("""
select s from Station s
where s.active = true
and s.capacity >= :minimumCapacity
order by s.name
""")
List<Station> findActiveWithCapacity(@Param("minimumCapacity") int minimumCapacity);Notice Station capitalised and s.capacity: they are the Java class name and its field name, not stations and not the column. That is what makes JPQL portable across engines and verifiable against the model. And Java text blocks (""") are the right way to write multi-line queries, with no concatenation and no lost spaces.
Positional versus named parameters:
// Positional: fragile. Reordering the arguments breaks the query silently
@Query("select s from Station s where s.capacity >= ?1 and s.active = ?2")
List<Station> find(int capacity, boolean active);
// Named: the recommended form
@Query("select s from Station s where s.capacity >= :capacity and s.active = :active")
List<Station> find(@Param("capacity") int capacity, @Param("active") boolean active);Always use named parameters with @Param. Positional ones depend on argument order and give no hint at all when read. And a warning inherited from the JDBC world: never concatenate values into the query text, because that is SQL injection. JPA parameters travel in prepared statements, with the values separated from the text: that is their protection.
Paginated queries with @Query and their countQuery:
@Query(value = "select r from Rental r where r.user.id = :userId "
+ "and r.startedAt between :from and :to",
countQuery = "select count(r) from Rental r where r.user.id = :userId "
+ "and r.startedAt between :from and :to")
Page<Rental> findByUserAndPeriod(@Param("userId") Long userId,
@Param("from") Instant from,
@Param("to") Instant to,
Pageable pageable);Spring Data can derive the countQuery automatically, but with complex queries —especially with JOIN FETCH or DISTINCT— the derivation either fails or generates a count far more expensive than necessary. Declaring it is the safe option.
- DTO projections in the query
One of the highest-impact techniques in the whole lesson: fetch only the columns you need and build the DTO directly, without going through managed entities.
package com.ciclourbana.stations.dto;
public record StationOccupancy(Long stationId, String name, int capacity,
long availableBikes) { }@Query("""
select new com.ciclourbana.stations.dto.StationOccupancy(
s.id, s.name, s.capacity, count(b.id))
from Station s
left join s.bikes b
on b.status = com.ciclourbana.bikes.BikeStatus.AVAILABLE
where s.active = true
group by s.id, s.name, s.capacity
order by s.name
""")
List<StationOccupancy> findOccupancy();Requirements of select new: a fully qualified class name and a constructor whose types match exactly those of the expressions. A record satisfies this naturally. Compare with the alternative of loading entities:
| Load entities and count in Java | select new with count |
|
|---|---|---|
| Queries | 1 + N (or 1 with JOIN FETCH) |
1 |
| Data transferred | Every column of stations and bikes | 4 columns per station |
| Entities in the context | Thousands | None |
Risk of LazyInitializationException |
Yes | No |
| Reusable as a response | Requires mapping | It already is the DTO |
It is the solution we chose in exercise 2 of 04-04, now written out in full. The rule: if the endpoint is not going to modify anything, ask yourself whether it needs entities at all.
JOIN FETCH and solving N+1
JOIN FETCH and solving N+1When you do need entities with their relationships loaded, JOIN FETCH brings them in a single query.
@Query("""
select distinct s from Station s
left join fetch s.bikes
where s.id = :id
""")
Optional<Station> findWithBikes(@Param("id") Long id);join fetch versus a plain join. A normal join serves to filter: you can place conditions on the joined entity, but it is not loaded, so accessing it afterwards fires lazy queries. join fetch filters and loads. The distinction is subtle and critical.
Why distinct. With a JOIN to a collection, the database returns one row per bike, that is, the station repeated N times; without distinct, the list would contain duplicates. In Hibernate 6 it is applied in memory without being added to the SQL, so it costs nothing. You can chain several levels:
@Query("""
select distinct r from Rental r
join fetch r.user
join fetch r.bike b
join fetch b.station
where r.endedAt is null
""")
List<Rental> findInProgressWithDetail();Four entities in a single query, instead of 1 + 3N.
The two limits of JOIN FETCH. The first: you cannot paginate with collections; we already saw this in 04-04, Hibernate warns with HHH90003004 and paginates in memory, so with collections and pagination you have to use @BatchSize or two queries (one for paginated ids and another with where id in). The second: only one collection fetch per query, because doing join fetch on two different collections generates a cartesian product —30 bikes × 20 incidents = 600 rows for one station—; Hibernate 6 allows it, but it is almost always a mistake.
- Native queries
When JPQL cannot reach, nativeQuery = true runs SQL straight from the engine.
@Query(value = """
SELECT s.id, s.name, s.address, s.capacity,
s.latitude, s.longitude,
(6371 * acos(
cos(radians(:latitude)) * cos(radians(s.latitude)) *
cos(radians(s.longitude) - radians(:longitude)) +
sin(radians(:latitude)) * sin(radians(s.latitude))
)) AS distance_km
FROM stations s
WHERE s.active = true
ORDER BY distance_km ASC
LIMIT :limit
""", nativeQuery = true)
List<NearbyStationProjection> findNearest(@Param("latitude") double latitude,
@Param("longitude") double longitude,
@Param("limit") int limit);That expression is the haversine formula, which computes the distance over the Earth's surface between two coordinates. JPQL has no trigonometric functions, so there is no portable alternative. The projection that collects the result (section 10):
public interface NearbyStationProjection {
Long getId(); String getName(); String getAddress();
Integer getCapacity();
Double getDistanceKm(); // maps the distance_km column
}When a native query is justified: engine-specific functions (geospatial, JSONB, full text); window functions (ROW_NUMBER, LAG, RANK), which JPQL does not support; CTEs (WITH ... AS) and recursive queries; specific optimisations such as PostgreSQL's ON CONFLICT; and bulk operations where performance rules.
Its risks, to be taken on knowingly:
| Risk | Consequence |
|---|---|
| Portability | PostgreSQL SQL does not run on H2, although MODE=PostgreSQL helps |
| Physical names | Renaming a column in the entity does not update the query |
| No validation | Errors appear at run time, not at startup |
| Outside the context | It returns raw data; loaded entities are not synchronised |
| Testing | It forces you to test against a real PostgreSQL (Testcontainers, 06-05) |
The second risk is the most treacherous: a native query references battery_level, somebody renames the field in the entity and updates the schema, and the query compiles, starts up and fails in production. CicloUrbana's rule: JPQL by default, native only when JPQL cannot; and when you do use native, cover it with an integration test.
@Modifying: UPDATE and DELETE
@Modifying: UPDATE and DELETEBy default, @Query assumes a read query. To modify, @Modifying is required:
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("""
update Bike b
set b.status = com.ciclourbana.bikes.BikeStatus.MAINTENANCE
where b.batteryLevel < :threshold
and b.status = com.ciclourbana.bikes.BikeStatus.AVAILABLE
""")
int markForMaintenanceByBattery(@Param("threshold") int threshold);It returns the number of rows affected. Three things are mandatory or nearly so:
@Transactional is indispensable. Without it, TransactionRequiredException: Executing an update/delete query. Normally the transaction comes from the service (04-07).
flushAutomatically = true pushes the context's pending changes out before running the query; without it, a bike modified in memory and not yet flushed would fail to match the WHERE it ought to match.
clearAutomatically = true is the most important and the least understood. A modifying query runs straight against the database, bypassing the persistence context, so the entities already loaded are left with stale values:
@Transactional
public void desynchronisationExample() {
Bike bike = bikeRepository.findById(1L).orElseThrow(); // AVAILABLE
bikeRepository.markForMaintenanceByBattery(20); // UPDATE in the DB
bike.getStatus(); // it still says AVAILABLE! It comes from the context, not the DB
}Worse still: if bike is then modified and committed, dirty checking would write the old status, undoing the bulk update. clearAutomatically = true empties the context after the query, forcing a re-read.
graph TD
A["findById(1) -> context: bike AVAILABLE"] --> B["@Modifying UPDATE in the DB"]
B --> C{"clearAutomatically"}
C -->|false| D["The context keeps AVAILABLE<br/>(stale data)"]
C -->|true| E["Context emptied<br/>the next read goes to the DB"]
When to use @Modifying and when not to. It is the right tool for bulk operations —marking a hundred bikes, closing the night's abandoned rentals— and it is not the tool for modifying one specific entity: there, loading it and changing it is enough, letting dirty checking do its work. A final warning: a modifying query bypasses cascades, @EntityListeners and optimistic locking, so updated_at is not refreshed and version is not incremented; if that matters, do it explicitly in the query itself.
- Projections by interface and by
record
recordA projection returns a subset of the data instead of the complete entity. There are three ways.
Closed projection by interface. You declare an interface with getX() methods matching properties:
public interface StationSummary {
Long getId(); String getName(); Integer getCapacity();
}
// and in the repository, without changing the method name:
List<StationSummary> findByActiveTrue();Spring Data generates a proxy and, most importantly, restricts the SELECT to the necessary columns: select s1_0.id, s1_0.name, s1_0.capacity from stations s1_0 where s1_0.active = true. With a thirty-column table, the difference in data transferred is enormous.
Open projection with @Value and SpEL. It allows computation:
public interface LabelledStation {
String getName(); Integer getCapacity();
@Value("#{target.name + ' (' + target.capacity + ' docks)'}")
String getLabel();
}It has a cost worth knowing about: with @Value, Spring Data loads the complete entity in order to evaluate the expression, losing the SELECT optimisation. Use it only when you need the computation.
Projection to a record. The cleanest option with modern Java: just declare public record StationSummary(Long id, String name, Integer capacity) { } and use it as the return type. Spring Data recognises the record and uses its canonical constructor; the component names must match the property names.
Dynamic projections. The same method can return different shapes depending on what you ask for:
<T> List<T> findByActiveTrue(Class<T> type);
// repository.findByActiveTrue(Station.class) -> complete entities
// repository.findByActiveTrue(StationSummary.class) -> summarised projection| Type | Optimised SELECT |
Computes | Syntax |
|---|---|---|---|
| Closed interface | Yes | No | getX() methods |
Open interface (@Value) |
No | Yes | SpEL |
record |
Yes | No | The most concise |
select new in @Query |
Yes | Yes (aggregations) | Qualified name |
@EntityGraph
@EntityGraph@EntityGraph declares, per method, which associations to load, without writing JPQL:
@EntityGraph(attributePaths = {"bikes"})
Optional<Station> findById(Long id);
@EntityGraph(attributePaths = {"user", "bike", "bike.station"})
List<Rental> findByEndedAtIsNull(); // loads four entities in one queryIt generates the same LEFT JOIN FETCHes you would write by hand and works over derived queries as well as over @Query.
JOIN FETCH |
@EntityGraph |
|
|---|---|---|
| Syntax | Inside the JPQL | A declarative annotation |
| Over derived queries | No | Yes |
| Nested paths | Yes | Yes ("bike.station") |
Control over the JOIN type |
Yes (left/inner) |
No (always LEFT) |
| Reusable | No | Yes, with @NamedEntityGraph |
And with @NamedEntityGraph(name = "Station.withBikes", attributeNodes = @NamedAttributeNode("bikes")) on the entity, the graph is defined once and referenced by name from any method with @EntityGraph("Station.withBikes").
Remember that the pagination limit with collections (section 7) applies just the same: @EntityGraph over a collection with Pageable also paginates in memory.
Specification and the Criteria API
Specification and the Criteria APIThe case no previous technique solves well: the station search with combinable optional filters. The council wants to filter by name, minimum capacity, active status and bike availability, in any combination; with derived queries that would take 16 methods and with @Query an unreadable where full of (:param is null or ...).
First, the repository also extends JpaSpecificationExecutor<Station>. Then, a class holding the predicates:
package com.ciclourbana.stations;
public final class StationSpecs {
private StationSpecs() { }
public static Specification<Station> nameContains(String text) {
return (root, query, cb) -> (text == null || text.isBlank()) ? 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);
}
// active(Boolean) is analogous: null if the filter is absent, cb.equal if present
public static Specification<Station> withAvailableBikes() {
return (root, query, cb) -> {
Join<Station, Bike> bikes = root.join("bikes", JoinType.INNER);
query.distinct(true);
return cb.equal(bikes.get("status"), BikeStatus.AVAILABLE);
};
}
}And the service combines them, with Specification.where(...) and and:
@Transactional(readOnly = true)
public PageResponse<StationResponse> search(StationFilter filter, Pageable pageable) {
Specification<Station> spec = Specification
.where(StationSpecs.nameContains(filter.name()))
.and(StationSpecs.minimumCapacity(filter.minimumCapacity()))
.and(StationSpecs.active(filter.active()));
if (Boolean.TRUE.equals(filter.onlyWithBikes())) {
spec = spec.and(StationSpecs.withAvailableBikes());
}
return PageResponse.of(
stationRepository.findAll(spec, pageable).map(mapper::toResponse));
}The key is returning null. Spring Data discards null predicates, so an absent filter never appears in the WHERE: no SQL concatenation and no nested ifs, and any combination of filters works without writing extra code. Specification is also compatible with Pageable and Sort.
Its trade-off is the verbosity of the Criteria API —cb.greaterThanOrEqualTo(root.get("capacity"), minimum) reads worse than capacity >= :minimum— and the fact that root.get("capacity") is a string no compiler verifies. The mitigation is JPA's static metamodel, which generates Station_ classes with typed constants: root.get(Station_.capacity).
@NamedQuery, Streamable and Stream
@NamedQuery, Streamable and StreamNamed queries. They are declared on the entity with @NamedQuery(name = "Station.findWithoutBikes", query = "select s from Station s where s.bikes is empty") and invoked by declaring a findWithoutBikes() method in the repository, which Spring Data resolves by the name Station.<method>. Their historical advantage was validation at startup, but @Query is validated today too, and their drawback is that they move the query away from the repository that uses it and clutter the entity. In CicloUrbana we do not use them.
Streamable<T> is an Iterable enriched with map, filter and and, useful for composing results without resorting to Stream. And Stream<T> processes large results without loading them all into memory, with one strict rule:
@Transactional(readOnly = true)
public void exportRentals(Writer output) {
try (Stream<Rental> stream = rentalRepository.streamAllByEndedAtIsNotNull()) {
stream.forEach(r -> writeLine(output, r));
}
}Three mandatory conditions: try-with-resources, because the Stream keeps a JDBC cursor open; an active transaction for the whole consumption; and emptying the context periodically with entityManager.clear() if you traverse hundreds of thousands of rows, or all of them stay managed and memory runs out. Without the close, the connection never returns to the pool: the leak from 04-02.
- Verifying the SQL actually executed
With so many mechanisms, the only way to know what is going on is to look. The configuration is the one from 04-02:
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
spring:
jpa:
properties:
hibernate:
format_sql: true
generate_statistics: trueAnd the three questions to ask of every new endpoint: how many queries does it run? —it must be a constant number, not proportional to the results (04-04)—; does it fetch columns it does not use? —if the DTO has 4 fields and the SELECT brings 20, a projection is missing—; and are they the ones you expected?, because a query you do not recognise is usually a lazy load fired by accident.
For hard cases, PostgreSQL's EXPLAIN ANALYZE shows the real execution plan: if a Seq Scan over a large table appears, an index is missing. That analysis belongs to 09-01.
Common Mistakes and Tips
Mile-long method names. Once you go past three or four conditions, move to @Query. Readability matters more than brevity of code.
Using positional parameters (?1, ?2). Reordering the arguments breaks the query without the compiler saying a word. Use @Param.
Forgetting clearAutomatically in @Modifying. The context is left out of sync and dirty checking can undo the bulk update.
Doing JOIN FETCH on two collections. A cartesian product. One collection per query.
Paginating with a collection JOIN FETCH. Hibernate warns with HHH90003004 and loads everything into memory. Use @BatchSize or two queries.
Overusing native queries. They break portability and are not validated at startup. Only when JPQL cannot.
Consuming a Stream without try-with-resources. It leaves a cursor and a connection open: a pool leak.
Tip: project whenever you are not going to modify. If the endpoint only reads, a projection record or a select new avoids loading entities, reduces memory and removes the risk of LazyInitializationException.
Tip: declare the countQuery on complex paginated queries. Automatic derivation fails with JOIN FETCH and DISTINCT.
Tip: use Specification for search screens. Any combination of optional filters without a single concatenating if.
Tip: look at each new endpoint's SQL log before calling it done. It costs a minute and finds 90% of the problems in this lesson.
Exercises
Exercise 1: choose the mechanism
For each CicloUrbana need, choose the mechanism (derived query, @Query JPQL, native, projection, Specification) and write the method signature.
- Bikes at a station with status
AVAILABLE. - The 10 stations nearest to a coordinate, with the distance in kilometres.
- A search with four combinable optional filters and pagination.
- A count of rentals per origin station for the last month, for a report.
- Every bike with battery below the threshold, moved to
MAINTENANCEin a single operation.
Exercise 2: billing report
Write the query that produces CicloUrbana's monthly report: for each user with at least one finished rental in the month, their name, email, number of rentals, total minutes and total amount billed, ordered by amount descending and paginated. Define the DTO and the repository method.
Exercise 3: find four faults
This repository has four problems. Identify them and fix them.
public interface RentalRepository extends JpaRepository<Rental, Long> {
@Query("select r from Rental r join fetch r.user join fetch r.bike " +
"where r.startedAt between ?1 and ?2")
Page<Rental> findByPeriod(Instant from, Instant to, Pageable pageable);
@Query(value = "SELECT * FROM rentals WHERE total_amount > :minimum", nativeQuery = true)
List<Rental> findExpensive(@Param("minimum") BigDecimal minimum);
@Modifying
@Query("update Rental r set r.status = 'EXPIRED' where r.endedAt is null " +
"and r.startedAt < :cutoff")
int expireAbandoned(@Param("cutoff") Instant cutoff);
}Solutions
Solution 1.
-
A derived query. Two simple conditions over direct properties:
List<Bike> findByStationIdAndStatus(Long stationId, BikeStatus status); -
A native query with an interface projection. JPQL has no trigonometric functions, so the haversine formula is only expressible in SQL:
List<NearbyStationProjection> findNearest(...)withnativeQuery = true. -
Specification. Four optional filters are 16 combinations, and no other technique covers it without duplicating code: thePage<Station> findAll(Specification<Station>, Pageable)inherited fromJpaSpecificationExecutoris enough. -
@QueryJPQL withselect new. It is an aggregation withgroup by, impossible in a derived query, and it needs no entities:
@Query("""
select new com.ciclourbana.rentals.dto.RentalsPerStation(
r.originStation.id, r.originStation.name, count(r))
from Rental r
where r.startedAt >= :from
group by r.originStation.id, r.originStation.name
order by count(r) desc
""")
List<RentalsPerStation> countByOriginStation(@Param("from") Instant from);@Modifying. A bulk operation over many rows: loading them all to modify them would be absurd:
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update Bike b
set b.status = com.ciclourbana.bikes.BikeStatus.MAINTENANCE
where b.batteryLevel < :threshold
and b.status = com.ciclourbana.bikes.BikeStatus.AVAILABLE
""")
int markForMaintenance(@Param("threshold") int threshold);Solution 2.
package com.ciclourbana.rentals.dto;
public record UserBilling(Long userId, String name, String email,
long rentalCount, long totalMinutes,
BigDecimal totalAmount) { }@Query(value = """
select new com.ciclourbana.rentals.dto.UserBilling(
u.id, u.name, u.email,
count(r.id),
sum(function('extract', epoch from (r.endedAt - r.startedAt))) / 60,
sum(r.totalAmount))
from Rental r
join r.user u
where r.endedAt is not null
and r.startedAt >= :from
and r.startedAt < :to
group by u.id, u.name, u.email
having sum(r.totalAmount) > 0
order by sum(r.totalAmount) desc
""",
countQuery = """
select count(distinct r.user.id) from Rental r
where r.endedAt is not null
and r.startedAt >= :from and r.startedAt < :to
""")
Page<UserBilling> billingForPeriod(@Param("from") Instant from,
@Param("to") Instant to,
Pageable pageable);Decisions and why:
select newwith arecord: the result is directly the response DTO, without loading a singleUserorRentalas an entity. With thousands of rentals, the difference in memory is orders of magnitude.- A declared
countQuery: withgroup by, automatic derivation would count grouped rows and give an incorrect number of pages.count(distinct r.user.id)is the real one. join r.user uinstead of navigatingr.user.namerepeatedly: a single explicitJOIN, more readable and with a single alias in thegroup by.>= :from and < :toinstead ofbetween, which is inclusive at both ends and would duplicate the last instant between two consecutive months: the half-open interval is the correct one for date ranges.function('extract', ...)invokes an engine function from JPQL, with the dependency on PostgreSQL that entails; the portable alternative would be to store the duration in minutes as a column when the rental finishes, which would also avoid the calculation on every report.
Solution 3. The four problems:
1. Page with JOIN FETCH and no countQuery. Although here they are @ManyToOne associations and not collections —which avoids the HHH90003004 warning—, Spring Data will try to derive the count from a query with fetch and will either fail or generate a count with unnecessary JOINs. It has to be declared.
2. Positional parameters. ?1 and ?2 are fragile: swapping from and to in the signature raises no compile error and produces a query that never returns anything. Use @Param.
3. The native query returns entities with SELECT *. It works while the columns match, breaks silently as soon as one is added or renamed, and is not validated at startup. Besides, it does not need to be native: totalAmount > :minimum is pure JPQL.
4. @Modifying without clearAutomatically or flushAutomatically, and with an enum literal. The context is left with stale rentals, and 'EXPIRED' in quotes is a string literal that Hibernate may not convert to the enum type. @Transactional is missing as well. Corrected version:
public interface RentalRepository extends JpaRepository<Rental, Long> {
@Query(value = "select r from Rental r join fetch r.user join fetch r.bike "
+ "where r.startedAt >= :from and r.startedAt < :to",
countQuery = "select count(r) from Rental r "
+ "where r.startedAt >= :from and r.startedAt < :to")
Page<Rental> findByPeriod(@Param("from") Instant from,
@Param("to") Instant to, Pageable pageable);
@Query("select r from Rental r where r.totalAmount > :minimum")
List<Rental> findExpensive(@Param("minimum") BigDecimal minimum);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("""
update Rental r
set r.status = com.ciclourbana.rentals.RentalStatus.EXPIRED
where r.endedAt is null and r.startedAt < :cutoff
""")
int expireAbandoned(@Param("cutoff") Instant cutoff);
}Conclusion
CicloUrbana now knows how to ask questions. You have mastered queries derived from the method name, understanding how the name is parsed into subject and predicate, the complete keyword table and —most valuable of all— that the error shows up at startup with a PropertyReferenceException that even suggests the right name. You know how to navigate nested properties, disambiguate with _ when necessary and recognise the signals that the method name has run out of road: more than three conditions, a mix of And and Or, aggregations, subqueries or optional filters. You write JPQL with @Query over entities and properties instead of tables and columns, with named parameters and text blocks, declaring the countQuery when the paginated query is complex. And you project to a DTO with select new, the highest-impact technique in the lesson: a single query, four columns, no managed entities and the response DTO built directly.
You solve the N+1 of 04-04 with JOIN FETCH, knowing its two limits —no paginating collections, one collection per query— and its declarative alternative @EntityGraph, with or without @NamedEntityGraph. You resort to native queries only when JPQL cannot reach, as with the haversine formula for the nearest stations, taking on its portability and late-validation risks knowingly. You handle @Modifying for bulk operations knowing why clearAutomatically and flushAutomatically are not optional: a modifying query bypasses the persistence context, auditing, cascades and optimistic locking. You choose among projections by closed interface, open with SpEL, by record and dynamic with generics, knowing which one optimises the SELECT and which does not. And you build Ribalta's station search with Specification, where returning null for an absent filter lets any combination work without a single if.
One piece remains that has appeared in every example without being fully explained: @Transactional. We have put it on the services, we have needed it in @Modifying, we have said that the persistence context lives as long as the transaction, that dirty checking saves without calling save() and that readOnly = true optimises something. All of those are claims we have not yet justified. Lesson 04-07, Transactions and Persistence Management, justifies them: what a transaction is and what the ACID properties mean when starting a rental requires marking the bike and creating the record together or not at all; where to put @Transactional and why; how it works underneath and the two traps that sometimes make it not work at all; the seven values of propagation and the four of isolation; the rollback rule and the classic mistake of catching the exception and losing it; pessimistic locking versus the optimistic @Version in the race for a station's last bike; and the transactional events that let the rental be charged after the commit without holding a connection from the pool.
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
