Skip to content

Spring Data Repositories Interview Questions & Answers

15 questions Updated 2026-06-26 Share:

Spring Data JPA repositories — the repository hierarchy, derived query methods, @Query JPQL and native queries, paging and sorting, projections, modifying queries, and how Spring generates the implementation at runtime.

Read the in-depth guideSpring Data JPA Repositories, Explained(opens in new tab)
15 of 15

A Spring Data repository is an interface you declare — Spring generates the implementation at runtime, so you write no DAO code for standard CRUD and query operations.

interface CustomerRepository extends JpaRepository<Customer, Long> {
    // inherited for free: save, findById, findAll, delete, count, existsById, ...
    List<Customer> findByLastName(String lastName);   // implemented from the method name
}

You parameterize it with the entity type and its id type. Spring scans for interfaces extending a repository marker, creates a proxy bean for each, and wires it wherever you inject it. You only ever write declarations; the framework provides the behavior.

Rule of thumb: Declare a repository interface extending JpaRepository<Entity, Id> and let Spring generate the implementation — you never write the CRUD boilerplate.

The interfaces build on each other, each adding capability:

Repository (marker, no methods)
  └─ CrudRepository<T, ID>          save, findById, findAll, delete, count
       └─ PagingAndSortingRepository  findAll(Pageable), findAll(Sort)
            └─ JpaRepository<T, ID>    flush, saveAndFlush, deleteAllInBatch, getReferenceById
  • Repository — empty marker; the root of the hierarchy.
  • CrudRepository — basic CRUD.
  • PagingAndSortingRepository — adds paging and sorting.
  • JpaRepository — adds JPA-specific batch ops and flushing.

Most projects extend JpaRepository for the full set. Extend a narrower interface only if you deliberately want to expose a smaller surface.

Rule of thumb: Extend JpaRepository by default; drop to CrudRepository only when you want to intentionally limit the available operations.

Spring Data parses the method name into a JPQL query at startup — keywords map to WHERE conditions, so the name is the query.

List<User> findByEmailAndActiveTrue(String email);
List<User> findByAgeGreaterThanOrderByAgeDesc(int age);
Optional<User> findFirstByOrderByCreatedAtDesc();
long countByActiveFalse();
boolean existsByEmail(String email);

Supported keywords include And, Or, Between, LessThan, GreaterThan, Like, Containing, In, IsNull, True/False, OrderBy, and prefixes find/read/count/ exists/delete. If a name can't be parsed, the app fails at startup — a useful early check. Names get unwieldy fast, though.

Rule of thumb: Use derived queries for simple, readable lookups; once a method name gets long or awkward, switch to @Query.

Use @Query to write JPQL (or SQL) explicitly when a derived name would be unwieldy or you need joins, projections, or expressions the name parser can't express.

@Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
Optional<User> findActiveByEmail(@Param("email") String email);

@Query("SELECT u FROM User u WHERE u.lastName LIKE %:term%")
List<User> search(@Param("term") String term);

Bind parameters by name (:email with @Param) — clearer than positional ?1. JPQL queries against entity/field names, not table/column names, so they're validated against your mapping and stay portable across databases. Spring even validates the JPQL at startup.

Rule of thumb: Reach for @Query with named parameters when method-name derivation gets awkward; it keeps complex queries readable and database-portable.

Set nativeQuery = true on @Query to run raw SQL against the actual tables — for database-specific features JPQL can't express (window functions, vendor functions, hints).

@Query(value = "SELECT * FROM users u WHERE u.created_at > NOW() - INTERVAL '7 days'",
       nativeQuery = true)
List<User> recentlyJoined();

Native queries use table and column names, not entity/field names, so they bypass JPQL validation and tie you to a specific database dialect. They also don't support JPQL paging niceties the same way (you supply a countQuery for Page). Use them sparingly, only when JPQL genuinely can't do the job.

Rule of thumb: Prefer JPQL for portability; use nativeQuery = true only for vendor-specific SQL JPQL can't express, accepting the loss of portability.

A @Query is a SELECT by default. @Modifying marks it as an UPDATE/DELETE/DDL statement, telling Spring to call executeUpdate() instead of running a select.

@Modifying
@Query("UPDATE User u SET u.active = false WHERE u.lastLogin < :cutoff")
int deactivateStale(@Param("cutoff") LocalDateTime cutoff);   // returns affected row count

Two gotchas: it must run inside a transaction (@Transactional), and a bulk update bypasses the persistence context — entities already loaded in memory keep their stale values. Add clearAutomatically = true (or re-fetch) so you don't read pre-update state afterward.

Rule of thumb: Annotate bulk UPDATE/DELETE queries with @Modifying inside a transaction, and clear the context since they skip dirty checking.

Pass a Pageable (or Sort) argument and return a Page. Spring adds the LIMIT/OFFSET and runs a separate count query so you know the total.

Page<User> findByActiveTrue(Pageable pageable);

// caller:
Pageable p = PageRequest.of(0, 20, Sort.by("lastName").ascending());
Page<User> page = repo.findByActiveTrue(p);
page.getTotalElements();   // total matching rows
page.getContent();         // this page's 20 rows

Return Page when you need the total count, Slice when you only need "is there a next page?" (cheaper — no count query), or a plain List/Pageable to skip counting entirely. The same Pageable works on derived and @Query methods alike.

Rule of thumb: Return Page when you need totals, Slice for infinite-scroll where the count isn't needed — and let Pageable carry both paging and sorting.

Spring Data supports many wrapper and collection types and adapts the query accordingly:

Optional<User> findByEmail(String email);   // 0 or 1 — preferred for single results
User findById(Long id);                      // may return null
List<User> findByActiveTrue();               // a collection
Stream<User> streamByActiveTrue();           // lazy stream (close it!)
Page<User> findByLastName(String n, Pageable p);
long countByActiveTrue();                    // aggregate
boolean existsByEmail(String email);

Prefer Optional for single results — it makes "not found" explicit and avoids NPEs. A single query that returns multiple rows throws IncorrectResultSizeDataAccessException. Stream must be used inside a transaction and closed (try-with-resources).

Rule of thumb: Return Optional for at-most-one results, collections for many, and use count/exists instead of fetching rows just to check.

Projections let a query return a subset of an entity's data instead of the full entity — Spring selects only the columns the projection exposes.

interface UserView {              // closed interface projection
    String getEmail();
    String getLastName();
}
List<UserView> findByActiveTrue();          // SELECT email, last_name ...

record UserDto(String email, String lastName) {}    // class/DTO projection
List<UserDto> findByActiveTrueAndLastName(String n);

A closed projection (only entity properties) lets Spring optimize the SELECT to just those columns. An open projection with @Value("#{...}") SpEL pulls the whole entity first. Projections are ideal for read-only list/summary views — less data, no lazy-loading traps.

Rule of thumb: Use closed interface or DTO projections for read-only views so the query fetches only the columns you actually render.

At startup, Spring Data scans for repository interfaces and creates a dynamic proxy for each. Method calls route through a chain that decides how to satisfy them.

yourRepo.findByEmail(x)
  → JDK dynamic proxy
  → QueryExecutorMethodInterceptor
  → is it a custom impl? derived query? @Query? named query?
  → builds & runs the JPA query via SimpleJpaRepository

The base CRUD behavior comes from SimpleJpaRepository; derived methods are parsed into queries by a PartTree; @Query methods use the declared string. There's no code generation on disk — it's runtime proxying. That's why a typo in a derived method name surfaces as a startup failure, not a compile error.

Rule of thumb: Repositories are runtime JDK proxies backed by SimpleJpaRepository — knowing this explains why query-method errors appear at startup, not compile time.

Define a fragment interface plus an Impl class, then have your repository extend both. Spring wires the custom implementation alongside the generated one.

interface UserRepositoryCustom {                 // 1. the custom contract
    List<User> searchComplex(SearchCriteria c);
}
class UserRepositoryCustomImpl implements UserRepositoryCustom {   // 2. "...Impl" by convention
    @PersistenceContext EntityManager em;
    public List<User> searchComplex(SearchCriteria c) {
        // hand-built Criteria API / dynamic JPQL here
    }
}
interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {}  // 3.

The Impl suffix is the naming convention that links the fragment to its implementation. This is the escape hatch for dynamic queries (Criteria API, QueryDSL) that don't fit derived methods or static @Query strings.

Rule of thumb: Add a ...Custom fragment + ...Impl class for dynamic or complex queries, and extend both interfaces from your repository.

All persist, but they differ in when SQL hits the database and how save decides insert-vs-update.

repo.save(entity);          // schedules INSERT/UPDATE; SQL flushes at transaction commit
repo.saveAndFlush(entity);  // forces the SQL to the DB immediately (e.g. to read it back)
repo.saveAll(list);         // saves a collection, still batched at flush

save decides between insert and update by whether the entity is new (null/unset id → INSERT, otherwise it merges). saveAndFlush is for when you must see the row mid-transaction (a generated id, a DB trigger, validating a constraint early). Most code should just use save and let flush happen at commit.

Rule of thumb: Use save and let Hibernate flush at commit; reach for saveAndFlush only when you must force the SQL out mid-transaction.

Query by Example (QBE) builds a query from a probe — a populated entity instance whose non-null fields become the WHERE conditions — no JPQL or method name required.

User probe = new User();
probe.setActive(true);
probe.setLastName("Smith");
Example<User> example = Example.of(probe);   // matches active users named Smith
List<User> matches = repo.findAll(example);  // via JpaRepository (extends QueryByExampleExecutor)

Use an ExampleMatcher to tune matching (ignore case, "contains" instead of "equals", ignore specific paths). It's handy for dynamic search forms where the set of filters varies. Its limits: no OR, no ranges, no nested property traversal — for those, use Criteria/QueryDSL.

Rule of thumb: Use Query by Example for simple, dynamic equality-based filters; graduate to the Criteria API or QueryDSL when you need ranges, OR, or nested conditions.

Spring Boot auto-configures repository scanning. @SpringBootApplication triggers @EnableJpaRepositories for the package of the main class and its sub-packages, so any repository interface there is detected automatically.

@SpringBootApplication   // implies @EnableJpaRepositories for this package downward
class App { public static void main(String[] a) { SpringApplication.run(App.class, a); } }

You only add an explicit @EnableJpaRepositories(basePackages = "...") when repositories live outside the main application package or you need multiple datasources. The common failure — "no repository bean found" — almost always means the interface sits outside the scanned package tree.

Rule of thumb: Keep repositories under the main application package for zero config; add explicit @EnableJpaRepositories(basePackages=...) only when they live elsewhere.

getReferenceById (formerly getOne) returns a lazy proxy without hitting the database — it assumes the row exists and defers loading until you access a non-id property.

// Setting a FK association without loading the parent:
Order order = new Order();
order.setCustomer(customerRepo.getReferenceById(customerId));  // no SELECT — just sets the FK
orderRepo.save(order);                                          // INSERT uses customerId

It's an optimization when you only need the id — e.g. assigning a relationship — and want to skip a wasted SELECT. The catch: if the id doesn't exist, you get an EntityNotFoundException later (on property access or flush), not immediately. findById loads eagerly and returns an Optional.

Rule of thumb: Use getReferenceById to wire up a foreign key without a SELECT; use findById when you actually need the entity's data or must verify it exists now.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel