JPA is the standard API. Spring Data JPA is a code-generation layer on top of it: you declare an interface, it generates the implementation, and that implementation calls the EntityManager underneath. Writing the same operation both ways makes the boundary clear.
JPA
The center of JPA is the persistence context and the EntityManager that manages it. The persistence context is a logical space that holds entities and provides the first-level cache, dirty checking, lazy loading, and write-behind. In plain JPA, you hold the EntityManager and open and close the transaction yourself.
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
User user = new User("Alice", "[email protected]");
em.persist(user); // managed state → INSERT queued
User found = em.find(User.class, user.getId()); // returned from first-level cache (no extra query)
found.setName("Bob"); // dirty checking → automatic UPDATE at flush
tx.commit(); // queued SQL executes
em.close();
This works, but createEntityManager, getTransaction, begin, commit, and close repeat for every CRUD operation on every entity. That repetition is what Spring Data JPA removes.
Spring Data JPA
The core of Spring Data JPA is the Repository. You don’t write the implementation class. You declare an interface, Spring builds a proxy-based implementation at runtime, and that implementation calls the EntityManager internally.
public interface UserRepository extends JpaRepository<User, Long> {
// save(), findById(), findAll(), delete() ... all provided automatically
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public User createUser(String name, String email) {
User user = new User(name, email);
return userRepository.save(user); // delegates to em.persist() or em.merge() internally
}
}
This does the same thing as the plain-JPA code above. You no longer hold the EntityManager, and you declare the transaction boundary with @Transactional instead of managing it by hand. CrudRepository gives you save(), findById(), findAll(), and deleteById(); JpaRepository adds flush(), saveAndFlush(), batch deletion, and paging/sorting. Either way, at the end of the call the EntityManager produces the same SQL.
So using a Repository adds no inherent performance overhead. The same implementation (usually Hibernate) generates the same SQL. The Repository is a layer that removes boilerplate, not a different engine.
Three ways to express a query
On top of the Repository there are three ways to write queries, each with a different job.
Query methods
Spring generates JPQL from the method-name convention. Combine prefixes like findBy, countBy, and existsBy with field names and keywords such as And/Or, Between/LessThan/Like/In/OrderBy. Method names are validated against entity fields, so typos and wrong field names surface early.
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByName(String name);
Optional<User> findByEmailAndStatus(String email, UserStatus status);
List<User> findByCreatedAtBetweenOrderByNameAsc(LocalDateTime start, LocalDateTime end);
long countByStatus(UserStatus status);
boolean existsByEmail(String email);
}
This suits simple lookups that read well as a name. Once you pass three or four conditions and the method becomes findByNameAndStatusAndCreatedAtBetweenAndRoleNot..., it stops being readable, and the next approach takes over.
@Query
For JOINs, subqueries, aggregations, or CASE expressions that are hard to name, write JPQL directly in @Query.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE u.status = :status AND u.createdAt > :date")
List<User> findActiveUsersAfter(@Param("status") UserStatus status,
@Param("date") LocalDateTime date);
@Query("SELECT u.department, COUNT(u) FROM User u GROUP BY u.department")
List<Object[]> countByDepartment();
@Query(value = "SELECT * FROM users WHERE MATCH(name, bio) AGAINST(?1)",
nativeQuery = true)
List<User> fullTextSearch(String keyword);
}
nativeQuery = true lets you write DB-specific SQL, but it costs portability, so prefer JPQL when you can. @Query fixes the query, which makes a dynamic query whose conditions come and go awkward to express.
QueryDSL
For a search screen where which conditions are filled is decided at runtime, @Query forces you to concatenate strings, which gets messy. QueryDSL composes conditions in a type-safe way and drops any condition that’s null. Dynamic queries are written without a tangle of if-else branches.
@Repository
@RequiredArgsConstructor
public class UserQueryRepository {
private final JPAQueryFactory queryFactory;
public List<User> searchUsers(UserSearchCondition condition) {
return queryFactory
.selectFrom(user)
.where(
nameContains(condition.getName()),
statusEq(condition.getStatus()),
createdAtBetween(condition.getStartDate(), condition.getEndDate())
)
.orderBy(user.createdAt.desc())
.fetch();
}
private BooleanExpression nameContains(String name) {
return StringUtils.hasText(name) ? user.name.contains(name) : null; // null drops out of WHERE
}
private BooleanExpression statusEq(UserStatus status) {
return status != null ? user.status.eq(status) : null;
}
private BooleanExpression createdAtBetween(LocalDateTime start, LocalDateTime end) {
if (start == null && end == null) return null;
if (start == null) return user.createdAt.loe(end);
if (end == null) return user.createdAt.goe(start);
return user.createdAt.between(start, end);
}
}
Any null argument passed to where() is ignored, so the code that used to branch on each condition collapses into one method per condition.
What to use, and when
You mix the three approaches in a single project. The same SQL goes out regardless, so the deciding factor is expressiveness and readability, not performance.
- Simple lookups that read well as a name → query methods
- Static queries with JOINs or aggregation but fixed conditions → @Query (JPQL)
- Searches whose conditions change dynamically → QueryDSL
Bulk processing is the exception. The Repository’s saveAll() fires an individual INSERT/UPDATE per entity, which is inefficient for large volumes. There, a bulk operation with @Modifying + @Query, or JDBC batching, is the right call. It’s a functional choice, one statement over the whole set instead of per-row processing through change detection, not a performance edge.