Spring Data repositories
Having entities is only half of persistence; you need to query them. Spring Data JPA's repositories are
where its "magic" is most striking: you declare an interface, write no implementation, and get a working
data-access object with CRUD and custom queries. This lesson explains that mechanism — because, per the
course's promise, a generated repository is not magic once you know how it works — building Dakiya's
ParcelRepository, verified against a running app.
Declare an interface, get an implementation
A Spring Data repository is an interface extending JpaRepository:
public interface ParcelRepository extends JpaRepository<Parcel, Long> {
}
That is the whole thing — no implementing class. JpaRepository<Parcel, Long> says "a repository for
Parcel entities whose id is a Long", and from it you get, for free:
repository.save(parcel); // insert or update
repository.findById(1L); // Optional<Parcel>
repository.findAll(); // List<Parcel>
repository.findAll(pageable); // a Page (pagination)
repository.count(); // long
repository.delete(parcel); // delete
repository.existsById(1L); // boolean
Verified against the running app: save, findById, findAll, count and delete all work with no code
written beyond the interface. How? At startup, Spring Data creates a proxy that implements the interface,
backed by a generic implementation (SimpleJpaRepository) that turns each method into the appropriate JPA
call. So it is not magic — it is a proxy object Spring builds for your interface, delegating to a real
implementation. You declare the contract; Spring Data provides the implementation.
Derived query methods: queries from method names
The striking part: you can add custom finder methods just by naming them, and Spring Data generates the query from the name:
public interface ParcelRepository extends JpaRepository<Parcel, Long> {
List<Parcel> findByDestinationCity(String city);
List<Parcel> findByStatus(Parcel.Status status);
List<Parcel> findByStatusAndDestinationCity(Parcel.Status status, String city);
long countByStatus(Parcel.Status status);
boolean existsByTrackingCode(String trackingCode);
}
Spring Data parses the method name — findBy + Status + And + DestinationCity — and builds the
matching query (WHERE status = ? AND destination_city = ?). Verified: findByStatus(BOOKED) returned the
5 booked parcels. The vocabulary is rich: findBy, countBy, existsBy, deleteBy, with And, Or,
GreaterThan, LessThan, Between, Like, In, IsNull, OrderBy…Asc/Desc, and more. For a huge
fraction of everyday queries, you never write the query — you name the method and Spring Data derives it.
The limit is readability: findByStatusAndDestinationCityAndRecipientNameOrderByCreatedAtDesc is a method
name past the point of usefulness. When a derived name gets long or the query gets complex, switch to
@Query.
@Query: when the method name is not enough
For queries too complex to express as a name, write JPQL (or SQL) with @Query:
// JPQL — queries entities and their fields, not tables and columns
@Query("select p from Parcel p where p.status = :status and p.destinationCity = :city")
List<Parcel> search(@Param("status") Parcel.Status status, @Param("city") String city);
// a fetch join to avoid N+1 (next lesson)
@Query("select p from Parcel p join fetch p.hub")
List<Parcel> findAllWithHub();
// native SQL when you truly need it
@Query(value = "SELECT * FROM parcel WHERE destination_city = :city", nativeQuery = true)
List<Parcel> searchNative(@Param("city") String city);
JPQL looks like SQL but operates on entities and their fields (Parcel p, p.status), not tables and
columns — Hibernate translates it to SQL. Named parameters (:status) bind to @Param. Verified:
findAllWithHub (a fetch join) ran as one query. Use @Query with JPQL for complex queries, and
nativeQuery = true only when you need database-specific SQL the JPA-in-depth module returns to. The
progression: derived method for simple queries → @Query (JPQL) for complex ones → native SQL as a last
resort.
Parameterised, always — and returning the right type
Two points that matter:
- Queries are always parameterised. Both derived methods and
@Querywith:paramspass values as bound parameters, never string-concatenated — so, like Django's ORM, Spring Data is safe from SQL injection by default. You would have to go out of your way (string-building a native query) to be unsafe. - Return the type that fits.
Optional<Parcel>for "zero or one" (findById),List<Parcel>for many,long/booleanfor counts/existence,Page<Parcel>for a paginated slice. ReturningOptionalfor a single result forces the caller to handle "not found" (as the controller'sorElseThrowdoes) rather than risk a null.
Custom repository logic, when you need it
Occasionally you need behaviour the derived/@Query methods cannot express — dynamic queries built at
runtime (the Specifications lesson), or logic combining several queries. Spring Data supports custom
implementation fragments: you define an interface with your method, provide an implementation class, and
Spring Data mixes it into the generated repository. This is the escape hatch for the rare case; for the vast
majority of data access, JpaRepository plus derived methods plus the occasional @Query is the whole
toolkit. The point to carry: you declare what you want (methods on an interface); Spring Data provides
how (the implementation) — and knowing it is a generated proxy, not magic, means you can reason about it
when a query does not behave as expected.
Check your work
Declare an interface. Extend JpaRepository<Entity, IdType> and get CRUD (save, findById,
findAll, count, delete, pagination) with no implementation. Spring Data builds a proxy backed by
SimpleJpaRepository — a generated implementation, not magic. Verified.
Derived queries. Name a method (findByStatusAndDestinationCity) and Spring Data parses the name into a
query. Verified findByStatus → 5. Rich vocabulary (And/Or/Between/Like/OrderBy…); switch to
@Query when the name gets long.
@Query. JPQL (queries entities/fields, Hibernate translates to SQL) for complex queries; nativeQuery
for database-specific SQL as a last resort. Verified a fetch-join @Query ran.
Safe and well-typed. All queries are parameterised (injection-safe by default); return Optional
(zero/one), List (many), Page (paginated), long/boolean (count/exists).
Custom logic. Custom implementation fragments handle what derived/@Query cannot; rare — the standard
toolkit covers most data access.
Practice
- Declare
ParcelRepository extends JpaRepository<Parcel, Long>with no methods; usesave,findById,findAll,countand confirm they work with no implementation. - Add
findByStatusandfindByDestinationCity; reproduce the verified counts and inspect (show-sql) the generatedWHEREclauses. - Add
findByStatusAndDestinationCityandcountByStatus; confirm the name is parsed into the right query. - Write a
@Query(JPQL) for a query too complex to name; confirm it runs, then write the native-SQL version and note when you would prefer each. - Return
Optional<Parcel>from a single-result finder and handle the empty case withorElseThrow. - Take a very long derived method name and rewrite it as a
@Query— feel where the readability line is.
Official documentation
- Spring Data JPA — Reference — Repositories, derived queries,
@Query. - Spring Data — Query methods (derived) — The method-name vocabulary.
- Spring Data JPA — @Query — JPQL and native queries.
Next: entity relationships and fetch types.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship