What is Spring Data JPA? Three Layers, Not One
This name hides three separate technologies, and confusing them is the single most common source of "wait, is this a JPA thing or a Hibernate thing" confusion:
| Layer | What it actually is |
|---|---|
| JPA (Java Persistence API) | A specification โ interfaces and annotations
(@Entity, @Id, EntityManager).
Defines no behaviour by itself. |
| Hibernate | The implementation of that spec that actually talks to the
database. Spring Boot wires this in by default when you add
spring-boot-starter-data-jpa. |
| Spring Data JPA (this page) | A layer on top of JPA that eliminates the boilerplate of
writing EntityManager calls by hand โ you declare a
repository interface, and Spring generates a working
implementation at startup. |
Without this abstraction, even a simple find-by-email requires manually
building a CriteriaQuery or JPQL string against an
EntityManager, injected and managed by hand. Spring Data JPA
replaces all of that with an interface:
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
// No implementation anywhere in your codebase. Spring generates one at startup.
}
Every convenience on this page โ derived queries, automatic pagination, cascades โ still executes real SQL against a real database, with real performance characteristics. The sections on N+1 queries and lazy loading further down exist because Spring Data JPA's ease of use is precisely what lets these problems hide until production load reveals them.
Setup
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency> <!-- runtime driver: H2 for dev, real driver for prod -->
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
# application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.jpa.hibernate.ddl-auto=validate # never create/update in production โ see note below
spring.jpa.show-sql=true
create-drop and update are convenient for local
dev with H2, but let Hibernate infer your production schema from entity
annotations โ no review, no rollback, no migration history. Production
schemas are managed by Flyway or Liquibase,
with ddl-auto=validate (Hibernate checks the schema matches
your entities but never changes it) as the only safe production setting.
Entity Mapping
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(unique = true, nullable = false)
private String email;
@Enumerated(EnumType.STRING) // ALWAYS STRING โ ORDINAL breaks silently if the enum is ever reordered
private Status status = Status.ACTIVE;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Order> orders = new ArrayList<>(); // LAZY by default
@ManyToOne
@JoinColumn(name = "department_id")
private Department department; // EAGER by default โ see note below
@PrePersist
protected void onCreate() { createdAt = LocalDateTime.now(); }
// constructors, getters, setters
}
| Relationship | Default fetch type |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
The "to-one" relationships default to eager, "to-many" default to lazy.
Developers who assume everything is lazy by default get an unexpected
eager-loaded object graph on every @ManyToOne/@OneToOne
fetch. In practice, explicitly setting
fetch = FetchType.LAZY on every relationship,
including to-one ones, and fetching eagerly only where a specific query
needs it (see the N+1 section below), is the safer default for anything
beyond a toy project.
Repository Interfaces โ Who Actually Implements Them?
/*
* Repository<T, ID> โ marker interface, no methods
* โ
* CrudRepository<T, ID> โ save, findById, findAll, deleteById...
* โ
* PagingAndSortingRepository<T, ID> โ findAll(Pageable), findAll(Sort)
* โ
* JpaRepository<T, ID> โ flush(), saveAndFlush(), batch deletes
*/
public interface UserRepository extends JpaRepository<User, Long> {
// You write zero implementation. Ever.
}
At startup, Spring Data scans for interfaces extending
Repository, and for each one generates a dynamic
proxy whose calls are routed to
SimpleJpaRepository โ the actual class backing every
inherited method (save, findById, etc.) using
an injected EntityManager underneath. Your custom methods
(findByEmail) are handled separately, by parsing the method
name or the @Query annotation at startup. This is why a typo
in a derived query method name fails at application startup,
not at first call โ Spring Data validates every method signature against
the entity's properties while building the proxy.
Query Derivation โ Queries From Method Names
Spring Data parses the method name itself into a query. The keywords compose:
| Keyword | Method example | Generated intent |
|---|---|---|
findBy | findByEmail(String) | WHERE email = ? |
Containing | findByNameContaining(String) | WHERE name LIKE %?% |
And / Or | findByStatusAndName(...) | WHERE status = ? AND name = ? |
Between | findByCreatedAtBetween(a, b) | WHERE created_at BETWEEN ? AND ? |
In | findByStatusIn(Collection) | WHERE status IN (...) |
OrderBy...Asc/Desc | findByStatusOrderByNameAsc | WHERE status = ? ORDER BY name |
First / Top<N> | findFirstByStatus | LIMIT 1 |
countBy | countByStatus(Status) | SELECT COUNT(*) ... |
existsBy | existsByEmail(String) | SELECT EXISTS(...) |
deleteBy | deleteByStatus(Status) | DELETE ... |
Derived queries are excellent up to two or three conditions. Beyond that,
method names become unreadable
(findByStatusAndDepartmentNameAndCreatedAtBetweenOrderByNameDesc)
and provide no compile-time guarantee the generated query is what you
think it is. Past that point, an explicit @Query with named
parameters is more readable and just as concise to write.
Custom Queries with @Query
public interface UserRepository extends JpaRepository<User, Long> {
// JPQL โ portable across databases, always prefer this over native SQL
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmailAddress(@Param("email") String email);
// Native SQL โ only when JPQL genuinely can't express it (DB-specific functions, hints)
@Query(value = "SELECT * FROM users WHERE email = :email", nativeQuery = true)
Optional<User> findByEmailNative(@Param("email") String email);
// Bulk update/delete โ @Modifying is mandatory, or Spring throws at runtime
@Modifying(clearAutomatically = true) // clears the persistence context โ see note below
@Query("UPDATE User u SET u.status = :status WHERE u.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") Status status);
}
?1/?2 parameters bind by position.
Reorder the method's arguments during a refactor and every call site
silently sends values to the wrong placeholder โ no compiler warning,
wrong data written or read. @Param("name") binds by name and
survives reordering. There is no situation where positional parameters
are worth this risk, in JPQL or native SQL.
A bulk UPDATE/DELETE via @Query
bypasses the persistence context entirely โ it hits the database
directly. Any User entity already loaded and cached in the
current session keeps its stale in-memory value even
though the database row changed. clearAutomatically = true
clears the persistence context after the bulk operation, forcing the
next read to hit the database again instead of returning the stale
cached entity.
Projections โ Interface-Based and Record-Based DTOs
// Interface projection โ Spring Data generates a proxy matching these getters
public interface UserSummary {
String getName();
String getEmail();
}
// Record (DTO) projection โ Java 17+ idiom, matches constructor params to entity
// property names. No @Query needed even for a derived query method.
public record UserView(String name, String email) {}
public interface UserRepository extends JpaRepository<User, Long> {
List<UserSummary> findByStatus(Status status); // interface projection
List<UserView> findByStatusOrderByName(Status status); // record projection
}
Both projection styles generate a SELECT naming only the
required columns, rather than the whole entity. This directly avoids the
"don't return entities to controllers" problem: a record built for a list
view never even fetches the columns the list view doesn't render, and its
immutability means it can never accidentally trigger lazy-loading on
access โ see the Lazy Loading section below.
The N+1 Query Problem
This is the single most common Spring Data JPA performance bug in production, and it's invisible in development with a handful of test rows.
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findAll(); // inherited โ 1 query
}
for (User user : userRepository.findAll()) { // query #1: SELECT * FROM users (1 query)
user.getOrders().size(); // orders is LAZY โ triggers a query PER user
} // query #2..#N+1: SELECT * FROM orders WHERE user_id = ? (N queries)
// 1 query to fetch 100 users + 100 separate queries to fetch each user's orders = 101 total
The name literally describes the bug: 1 query to fetch the parent rows, plus N queries โ one per row โ to lazily fetch each one's associations. It scales linearly with dataset size, which is why it's invisible with 5 test users and catastrophic with 50,000 production users.
Fix 1: JOIN FETCH โ one query instead of N+1
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.status = :status")
List<User> findWithOrdersByStatus(@Param("status") Status status);
// Single query: users LEFT JOIN orders โ the association is populated immediately, no follow-up queries
Fix 2: @EntityGraph โ same result, without hand-writing JPQL
@EntityGraph(attributePaths = {"orders", "department"})
List<User> findByStatus(Status status);
// Works with a plain derived query method โ no @Query needed. Declaratively lists
// which lazy associations to eagerly fetch, for THIS query only.
Combine a JOIN FETCH over a collection with
Pageable, and Hibernate cannot apply
LIMIT/OFFSET at the SQL level โ the join
multiplies rows (one row per child), so the database can't paginate
parent entities correctly. Hibernate's fallback is to fetch
everything and paginate in memory, logging a
firstResult/maxResults warning โ defeating the entire purpose of
pagination. The fix: fetch the page of parent IDs first (a plain paged
query, no fetch join), then a second query with
JOIN FETCH ... WHERE id IN (:ids) for just that page.
Transactions and the Service Layer
Every inherited repository method (save, findById,
deleteById) is already individually transactional โ
SimpleJpaRepository is annotated
@Transactional internally. Putting
@Transactional on the Service layer isn't about
adding transactionality that's missing โ it's about widening the boundary so
multiple repository calls commit or roll back together, as
one atomic unit:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryRepository inventoryRepository;
public OrderService(OrderRepository orderRepository, InventoryRepository inventoryRepository) {
this.orderRepository = orderRepository;
this.inventoryRepository = inventoryRepository;
}
@Transactional // BOTH writes commit together, or BOTH roll back
public void placeOrder(Order order) {
orderRepository.save(order);
inventoryRepository.decrementStock(order.getProductId(), order.getQuantity());
// if decrementStock throws, the order save above is rolled back too โ without
// @Transactional here, the order would already be permanently committed
}
@Transactional(readOnly = true) // read-only hint: Hibernate skips dirty-checking, may reduce lock overhead
public List<Order> findRecentOrders() {
return orderRepository.findTop50ByOrderByCreatedAtDesc();
}
}
It's a real hint to Hibernate: entities loaded inside a read-only transaction skip the dirty-checking machinery used to detect changes for an eventual flush, since none is expected. On read-heavy endpoints, this measurably reduces overhead โ and it's free to add anywhere a method genuinely performs no writes.
Lazy Loading, LazyInitializationException, and Open Session in View
A LAZY association isn't loaded when the entity is fetched โ it's
a proxy that fetches on first access. Access it after the
persistence session that loaded the entity has closed, and Hibernate can't
run the follow-up query:
@Transactional
public User findUser(Long id) {
return userRepository.findById(id).orElseThrow(); // session open, method returns fine
}
// ... later, in the controller, outside any transaction:
user.getOrders().size(); // throws LazyInitializationException โ session is already closed
Spring Boot ships spring.jpa.open-in-view=true by
default, logging a startup warning that's easy to miss.
This keeps the Hibernate session open for the entire HTTP request โ
including view rendering โ which is precisely why the
LazyInitializationException above often doesn't happen: lazy
associations quietly resolve later, during Thymeleaf rendering or JSON
serialisation, each one issuing its own query outside any
explicit transaction boundary.
This is widely considered an anti-pattern in production systems: it hides
N+1 queries instead of surfacing them at the point they're written, holds
a database connection for the entire request lifecycle (starving the
connection pool under load), and makes it unclear which layer is actually
responsible for data access. The recommended production setting is
spring.jpa.open-in-view=false, which forces every lazy access
to happen deliberately, inside an explicit
@Transactional service method โ via JOIN FETCH,
@EntityGraph, or a DTO projection, as covered above.
Pagination and Sorting
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByStatus(Status status, Pageable pageable); // includes a COUNT(*) query for total pages
Slice<User> findByNameContaining(String name, Pageable pageable); // no COUNT query โ cheaper
}
Pageable pageable = PageRequest.of(0, 20, Sort.by("name").ascending());
Page<User> result = userRepository.findByStatus(Status.ACTIVE, pageable);
Page<T> runs a second COUNT(*) query to
populate getTotalElements()/getTotalPages() โ
necessary for numbered pagination UI ("page 3 of 47"), but real cost on a
large table. Slice<T> skips it entirely, only
exposing hasNext() by fetching one extra row โ the right
choice for infinite-scroll or "load more" UIs that never need a total
count.
Specifications โ Composable Dynamic Queries
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {}
public class UserSpecifications {
public static Specification<User> hasStatus(Status status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<User> nameLike(String name) {
return (root, query, cb) -> cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
}
}
// Compose conditionally โ only the filters actually provided end up in the query
Specification<User> spec = Specification.where(null);
if (criteria.status() != null) spec = spec.and(UserSpecifications.hasStatus(criteria.status()));
if (criteria.name() != null) spec = spec.and(UserSpecifications.nameLike(criteria.name()));
List<User> results = userRepository.findAll(spec);
The moment a query's filters are optional and combinable at
runtime โ a search form where any subset of fields might be
filled in โ a derived query method or a fixed @Query can't
express it; you'd need one method per combination. Specifications build
the WHERE clause dynamically, composing only the conditions
that actually apply for a given call.
Auditing
@Configuration
@EnableJpaAuditing
public class JpaConfig {}
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
@CreatedDate @Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@CreatedBy @Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
@Entity
public class User extends Auditable { /* id + own fields */ }
// Supplies the value for @CreatedBy / @LastModifiedBy
@Component
public class AuditorAwareImpl implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(Authentication::isAuthenticated)
.map(Authentication::getName);
}
}
Testing the Repository Layer: @DataJpaTest
@DataJpaTest // loads ONLY JPA config + repositories โ no web layer, no full context
class UserRepositoryTest {
@Autowired private UserRepository userRepository;
@Autowired private TestEntityManager entityManager; // direct EntityManager access for test setup
@Test
void findByEmail_returnsMatchingUser() {
entityManager.persist(new User("Ana", "ana@example.com"));
entityManager.flush();
Optional<User> found = userRepository.findByEmail("ana@example.com");
assertThat(found).isPresent();
}
}
@DataJpaTest auto-configures an embedded database (H2 on the
classpath) and replaces your real DataSource with it โ fast,
but it means dialect-specific SQL or native queries written for
PostgreSQL are never actually exercised. Add
@AutoConfigureTestDatabase(replace = Replace.NONE) together
with Testcontainers to run the exact same tests against a
real, disposable PostgreSQL container instead โ the only way to genuinely
trust a native query or a database-specific constraint before it hits
production.
Interview Questions
Q: What's the relationship between JPA, Hibernate, and Spring Data JPA?
JPA is a specification (interfaces/annotations only). Hibernate is the
implementation that actually executes queries against the database. Spring
Data JPA sits on top of both, generating repository implementations so you
never write EntityManager code by hand.
Q: What does JpaRepository give you that plain CrudRepository doesn't?
Batch operations like flush() and saveAndFlush(),
and pagination/sorting inherited from PagingAndSortingRepository
โ JpaRepository is the top of that interface hierarchy.
Q: What is the difference between Page and Slice?
Page runs an extra COUNT(*) query to know the total
number of results/pages. Slice skips that query and only knows
whether a next page exists โ cheaper, and enough for infinite-scroll UIs.
Q: Explain the N+1 query problem and both ways to fix it.
Fetching a list of parents (1 query) and then lazily accessing a
LAZY association on each one triggers a separate query per row
(N queries) โ 1+N total instead of 1. Fixed either with an explicit
JOIN FETCH in JPQL (single query, but breaks correct pagination
over collections), or with @EntityGraph on a derived query
method (same effect, declaratively, without hand-writing JPQL).
Q: What is Open Session in View, and why do most production teams disable it?
It's Spring Boot's default (spring.jpa.open-in-view=true) of
keeping the Hibernate session open for the entire HTTP request, including
view rendering โ which means lazy associations silently resolve during
serialisation instead of throwing LazyInitializationException
where the code actually accesses them. This hides N+1 queries instead of
surfacing them, and holds a database connection for the full request
lifecycle, which starves the connection pool under concurrent load. Disabling
it (false) forces lazy access to happen deliberately inside an
explicit @Transactional boundary.
Q: Why does @Transactional belong on the Service layer rather than the Repository?
Every inherited repository method is already individually transactional โ
SimpleJpaRepository is annotated internally. Placing
@Transactional on a Service method widens the boundary so that
multiple repository calls inside it commit or roll back together as one
atomic unit โ a concern the repository itself, which knows nothing about the
business operation calling it, cannot express.
Q: Why is @Modifying(clearAutomatically = true) necessary on a bulk @Query update?
A bulk JPQL UPDATE/DELETE executes directly against
the database, bypassing the persistence context entirely. Any entity already
loaded and cached in the current session retains its old in-memory value even
though the underlying row changed โ clearAutomatically = true
clears that context afterwards so the next read is forced back to the
database instead of returning a stale cached entity.