DAOs you declare instead of write
The defining trick of Spring Data is that you declare an interface and Spring supplies the
implementation at runtime. There's no DAO class, no boilerplate EntityManager plumbing, no hand-written
CRUD. This article covers how that works, the four ways to express a query, and the runtime machinery
underneath — the parts interviewers actually probe.
Declaring a repository
interface CustomerRepository extends JpaRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
}
Parameterize with the entity and its id type. You inherit save, findById, findAll, delete,
count, existsById and more for free, and Spring generates a proxy bean you can inject anywhere. You
write only declarations.
The hierarchy
The interfaces stack, each adding capability:
Repository (marker)
└─ CrudRepository save, findById, findAll, delete, count
└─ PagingAndSortingRepository findAll(Pageable / Sort)
└─ JpaRepository flush, saveAndFlush, deleteAllInBatch, getReferenceById
Extend JpaRepository by default for the full toolkit; drop to CrudRepository only when you want to
deliberately limit the exposed surface.
Four ways to express a query
1. Derived query methods — Spring parses the method name into JPQL at startup:
List<User> findByEmailAndActiveTrue(String email);
Optional<User> findFirstByOrderByCreatedAtDesc();
boolean existsByEmail(String email);
Keywords like And, Between, GreaterThan, Like, In, OrderBy, and the find/count/exists/
delete prefixes cover a lot. If a name can't be parsed, the app fails to start — a useful early check.
But names get unwieldy fast.
2. @Query (JPQL) — when the name would be awkward:
@Query("SELECT u FROM User u WHERE u.lastName LIKE %:term%")
List<User> search(@Param("term") String term);
JPQL queries entity and field names, so it's validated against your mapping and portable across
databases. Bind by name with @Param.
3. Native SQL — for vendor-specific features JPQL can't express:
@Query(value = "SELECT * FROM users WHERE created_at > NOW() - INTERVAL '7 days'", nativeQuery = true)
List<User> recentlyJoined();
Native queries use table and column names, bypass JPQL validation, and tie you to one dialect — use them sparingly.
4. Modifying queries — bulk updates and deletes need @Modifying:
@Modifying
@Query("UPDATE User u SET u.active = false WHERE u.lastLogin < :cutoff")
int deactivateStale(@Param("cutoff") LocalDateTime cutoff);
@Modifying tells Spring to call executeUpdate(). Two gotchas: it must run in a transaction, and it
bypasses the persistence context, so entities already in memory keep stale values — add
clearAutomatically = true or re-fetch.
Paging, sorting, and what to return
Pass a Pageable and return a Page; Spring adds LIMIT/OFFSET and runs a count query:
Page<User> findByActiveTrue(Pageable pageable);
Pageable p = PageRequest.of(0, 20, Sort.by("lastName").ascending());
Page<User> page = repo.findByActiveTrue(p);
page.getTotalElements(); // total matching rows
Return Page when you need the total, Slice for infinite scroll where you only need "is there a
next page?" (no count query, so it's cheaper), or a plain List to skip counting. For single results,
return Optional — it makes "not found" explicit and dodges NPEs. Use count/exists instead of
fetching rows just to check existence.
Projections for read-only views
When you only need a few columns, project instead of loading the whole entity:
interface UserView { // closed interface projection
String getEmail();
String getLastName();
}
List<UserView> findByActiveTrue(); // SELECT email, last_name ...
A closed projection (only entity properties) lets Spring narrow the SELECT to exactly those columns —
ideal for list and summary screens, with no lazy-loading surprises. A DTO/record projection works the
same way. Open projections with SpEL load the full entity first, so prefer closed ones for the
optimization.
How the implementation is generated
There's no code on disk. At startup Spring scans for repository interfaces and builds a JDK dynamic proxy for each:
repo.findByEmail(x)
→ dynamic proxy
→ QueryExecutorMethodInterceptor
→ custom impl? derived query? @Query? named query?
→ runs it via SimpleJpaRepository
Base CRUD comes from SimpleJpaRepository; derived methods are parsed by a PartTree; @Query uses the
declared string. This runtime-proxy design is exactly why a typo in a derived method name shows up as a
startup failure, not a compile error.
Escape hatches: custom implementations and getReferenceById
When derived methods and static @Query strings aren't enough — dynamic queries via the Criteria API or
QueryDSL — add a fragment and an Impl:
interface UserRepositoryCustom { List<User> searchComplex(SearchCriteria c); }
class UserRepositoryCustomImpl implements UserRepositoryCustom { // "Impl" suffix is the convention
@PersistenceContext EntityManager em;
public List<User> searchComplex(SearchCriteria c) { /* dynamic JPQL / Criteria */ }
}
interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {}
And a small but useful optimization: getReferenceById returns a lazy proxy without a SELECT when you
only need to set a foreign key:
order.setCustomer(customerRepo.getReferenceById(customerId)); // no query — just sets the FK
The trade-off is that a missing id surfaces later as EntityNotFoundException, not immediately. Use
findById when you actually need the data or must verify existence now.
Finding the repositories
@SpringBootApplication implies @EnableJpaRepositories for the main class's package and below, so
repositories there are picked up automatically. The classic "no repository bean found" error almost always
means the interface lives outside that package tree — add an explicit
@EnableJpaRepositories(basePackages = "...") for repositories elsewhere or for multiple datasources.
Recap
Declare an interface extending JpaRepository; choose the lightest query mechanism that's still readable —
derived methods, then @Query, then native SQL, with @Modifying for bulk writes; return Optional,
Page, or Slice to match the use case; project into closed interfaces or DTOs for read-only views; and
remember it's all a runtime JDK proxy over SimpleJpaRepository, which explains the startup-time
validation. Add a ...Custom/...Impl fragment when you need dynamic queries. You write intent; Spring
writes the DAO.