RizTech Academy logo
RizTech Academy
Data Access with JPALesson 2 of 630 min

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 @Query with :params pass 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/boolean for counts/existence, Page<Parcel> for a paginated slice. Returning Optional for a single result forces the caller to handle "not found" (as the controller's orElseThrow does) 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

  1. Declare ParcelRepository extends JpaRepository<Parcel, Long> with no methods; use save, findById, findAll, count and confirm they work with no implementation.
  2. Add findByStatus and findByDestinationCity; reproduce the verified counts and inspect (show-sql) the generated WHERE clauses.
  3. Add findByStatusAndDestinationCity and countByStatus; confirm the name is parsed into the right query.
  4. 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.
  5. Return Optional<Parcel> from a single-result finder and handle the empty case with orElseThrow.
  6. Take a very long derived method name and rewrite it as a @Query — feel where the readability line is.

Official documentation

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