Spring Data JPA

Repository abstraction over JPA โ€” and the N+1 queries, lazy loading traps, and transaction boundaries it doesn't save you from

← Back to Index

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.
}
This abstraction hides real database behaviour โ€” that's the trade-off

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
ddl-auto=create / update in production is a real incident, not a style choice

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
}
FetchType defaults by relationship โ€” the #1 misremembered fact in JPA
RelationshipDefault fetch type
@ManyToOneEAGER
@OneToOneEAGER
@OneToManyLAZY
@ManyToManyLAZY

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.
}
There is no class here โ€” it's a runtime-generated proxy

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:

KeywordMethod exampleGenerated intent
findByfindByEmail(String)WHERE email = ?
ContainingfindByNameContaining(String)WHERE name LIKE %?%
And / OrfindByStatusAndName(...)WHERE status = ? AND name = ?
BetweenfindByCreatedAtBetween(a, b)WHERE created_at BETWEEN ? AND ?
InfindByStatusIn(Collection)WHERE status IN (...)
OrderBy...Asc/DescfindByStatusOrderByNameAscWHERE status = ? ORDER BY name
First / Top<N>findFirstByStatusLIMIT 1
countBycountByStatus(Status)SELECT COUNT(*) ...
existsByexistsByEmail(String)SELECT EXISTS(...)
deleteBydeleteByStatus(Status)DELETE ...
When a method name would need 5+ keywords, stop and write @Query

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);
}
Named parameters, always โ€” positional (?1) is a maintenance trap

?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.

@Modifying(clearAutomatically = true) โ€” why this matters

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
}
Records aren't just idiomatic โ€” they fetch less data

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.
JOIN FETCH + pagination is its own trap

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();
    }
}
readOnly = true is not just documentation

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
Open Session in View is Spring Boot's default โ€” and most teams disable it

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 vs Slice โ€” the COUNT query is the actual difference

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);
When to reach for Specifications instead of derived queries

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();
    }
}
By default this runs against an in-memory H2, not your real database

@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

๐ŸŽ“ Junior level

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.

๐Ÿ”ฅ Senior level

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.