Spring Data JPA is where most Spring Boot services meet their database, and interviewers use it to test two things at once: whether you can use the repository abstraction fluently, and whether you understand the JPA machinery underneath it — the persistence context, lazy loading, transactions and the N+1 trap. The candidates who stand out are the ones who know when a query actually fires and when an entity is managed, because that is where the subtle production bugs live.
Explain the Spring Data JPA repository hierarchy.
At the base is Repository (a marker), then CrudRepository (basic CRUD), PagingAndSortingRepository (adds paging and sorting), and JpaRepository, which extends those and adds JPA-specific batch and flush operations plus List-returning methods. You declare an interface extending JpaRepository<Entity, IdType> and Spring generates the implementation at runtime.
The point of the hierarchy is capability layering — you extend the level whose API you need. Most applications extend JpaRepository for the fullest surface (findAll, saveAll, flush, saveAndFlush, paging). You never write the implementation; Spring Data creates a proxy backed by SimpleJpaRepository at startup.
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerIdAndStatus(Long customerId, OrderStatus status);
}
Interview note: Trap: "who implements your repository interface?" Spring Data JPA generates a proxy at startup delegating to
SimpleJpaRepository. Candidates who think they must write an@Repositoryclass for basic CRUD miss the entire premise of Spring Data.
How do derived query methods work?
Spring Data parses the method name into a query. Keywords like findBy, And, Or, GreaterThan, Between, Like, OrderBy map to a JPQL WHERE clause and ordering, so findByStatusAndTotalGreaterThan(status, amount) becomes a query without you writing any JPQL.
It's convenient for straightforward finders but degrades fast — findByCustomerIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc is unreadable and brittle. The senior instinct is to switch to @Query once a derived name gets long, because an explicit query is clearer and easier to optimize. Derived methods are validated at startup against the entity's fields, so a typo in a property name fails fast rather than at runtime.
Interview note: Follow-up: "what happens if you misspell a field in a derived method name?" The application fails to start — Spring Data validates the parsed property path against the entity metamodel during context initialization. That fail-fast behavior is a feature, not a nuisance.
When do you use @Query, and what's the difference between JPQL and native queries?
Use @Query when a derived method would be awkward or when you need something JPQL can't express. JPQL queries operate on entities and fields (SELECT o FROM Order o WHERE o.status = :status) and are database-agnostic; native queries (nativeQuery = true) are raw SQL against tables and columns, for database-specific features or complex SQL.
JPQL is the default choice because it's portable and works in terms of your object model, so refactoring a column name is contained. Native queries buy you vendor features (window functions, hints, specific SQL) at the cost of portability and of bypassing some JPA conveniences. Named parameters (:status) keep queries readable and safe from injection.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o WHERE o.status = :status AND o.total > :min")
List<Order> findActiveAbove(@Param("status") OrderStatus status,
@Param("min") BigDecimal min);
@Query(value = "SELECT * FROM orders WHERE created_at > now() - interval '7 days'",
nativeQuery = true)
List<Order> findRecentNative();
}
Interview note: Trap: "does a native query return managed entities?" If it selects all columns of a mapped entity and you declare the entity return type, yes — they're managed. But projections and partial selects return non-managed data, so changes to them aren't persisted. Knowing this distinction avoids a "why didn't my update save" surprise.
What is @Modifying, and why is it needed?
@Modifying marks a @Query as an UPDATE or DELETE (a write) rather than a SELECT, so Spring Data executes it as a bulk update instead of trying to read results. It must run inside a transaction, and because it operates directly on the database, it bypasses the persistence context.
That bypass is the catch: a bulk @Modifying update changes rows in the database but doesn't update entities already loaded in the current persistence context, which can then be stale. You often pair it with clearAutomatically = true (or flush/clear) so the context doesn't serve outdated entities after the bulk operation.
@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Order o SET o.status = :status WHERE o.createdAt < :cutoff")
int expireOldOrders(@Param("status") OrderStatus status, @Param("cutoff") Instant cutoff);
Interview note: Follow-up: "why does the method return an int?" A bulk update returns the count of affected rows. That count is often exactly what you want (how many orders expired), and it's a signal the query ran as a bulk operation, not entity-by-entity.
What is the N+1 problem and how do you fix it?
N+1 happens when you load N parent entities, then Hibernate fires one additional query per parent to load a lazy association — 1 + N queries where 1 or 2 would do. Fix it with a fetch join (JOIN FETCH) in @Query, an @EntityGraph, or batch fetching, so the children load together.
It's the single most common JPA performance bug, and interviewers love it because the fix reveals whether you understand lazy loading. A findAll() over 100 orders, each iterating order.getItems(), silently issues 101 queries. A fetch join collapses that to one. @EntityGraph does the same declaratively on a repository method without hand-writing the join.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findWithItems(@Param("status") OrderStatus status);
@EntityGraph(attributePaths = "items")
List<Order> findByStatus(OrderStatus status);
}
Interview note: Trap: "you added
JOIN FETCHon two collections and got aMultipleBagFetchExceptionor a cartesian explosion — why?" Fetching two collection associations in one query multiplies rows cartesian-style. Fetch one collection per query, useSetinstead ofListwhere appropriate, or split into separate queries / batch fetching. This nuance separates people who've hit N+1 in production from those who've only read about it.
LAZY vs EAGER — what's the difference and what do you default to?
LAZY loads an association only when you first access it; EAGER loads it immediately with the parent. @OneToMany/@ManyToMany default to LAZY, and @ManyToOne/@OneToOne default to EAGER. The strong recommendation is to make associations LAZY and fetch what you need explicitly per query.
EAGER is seductive because it "just works" without LazyInitializationException, but it means every load of the parent drags the association along whether you need it or not — and multiple EAGER associations compound into large joins and their own N+1 patterns. LAZY plus targeted fetch joins gives you control: load the graph the specific use case needs, nothing more.
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // override the EAGER default
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
Note the jakarta.persistence.* imports in Spring Boot 3 — @Entity, @Id, @ManyToOne all come from jakarta.persistence, not javax.persistence.
Interview note: Follow-up: "if LAZY is the recommendation, how do you avoid LazyInitializationException?" Fetch the needed associations inside the transaction (fetch join / entity graph), or map to a DTO in the query. The fix is loading the right data in the service layer — not switching to EAGER, which trades one problem for a performance one.
Explain @Transactional semantics, propagation and readOnly.
@Transactional wraps a method in a database transaction via a proxy: it commits on normal return and rolls back on unchecked exceptions by default. propagation controls how it behaves relative to an existing transaction (REQUIRED joins one or starts one, REQUIRES_NEW suspends and starts a separate one). readOnly = true signals no writes, letting Hibernate skip dirty checking and flushing.
The persistence context lives for the length of the transaction, which is why the boundary matters so much for JPA: inside it, entities are managed and lazy loading works; once it closes, they're detached. readOnly on query methods is a cheap, honest optimization that also communicates intent. Propagation is where trade-offs get interesting — REQUIRES_NEW borrows a second connection, which affects pool sizing.
@Service
public class OrderService {
@Transactional(readOnly = true)
public OrderDto view(Long id) { // no dirty checking, no flush
return mapper.toDto(orderRepo.findWithItems(id));
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditIndependently(AuditEvent e) { // commits even if caller rolls back
auditRepo.save(e);
}
}
Interview note: Trap: "your
@Transactionalmethod calls another@Transactionalmethod in the same class and the propagation is ignored — why?" Because@Transactionalworks through a proxy, and a self-invocation (this.method()) never crosses it, so the annotation on the inner call is skipped. Split into separate beans or inject the proxy.
save() vs saveAndFlush(), and how does the entity lifecycle work?
save() makes an entity managed and schedules the INSERT/UPDATE, but the SQL may not hit the database until the transaction flushes (commit, a query that needs current state, or an explicit flush). saveAndFlush() forces the flush immediately. Entities move through states: transient (new, unmanaged) → managed (in the persistence context) → detached (context closed) → removed.
Understanding flush timing explains a lot of "why didn't I see my insert" confusion. Within a transaction, changes to a managed entity are written on flush without an explicit save() at all — that's automatic dirty checking. saveAndFlush() is for when you need the generated ID or a constraint check right now, before the transaction ends.
Interview note: Follow-up: "do you need to call save() on a managed entity you modified?" No — if the entity is managed and you change a field inside the transaction, dirty checking flushes the UPDATE automatically. Calling
save()on an already-managed entity is redundant, though harmless.
How does optimistic locking with @Version work?
Add a @Version field (an int/long or timestamp) to the entity. On each update Hibernate includes the current version in the WHERE clause and increments it; if another transaction changed the row first, zero rows match and Hibernate throws OptimisticLockException. It prevents lost updates without holding database locks.
Optimistic locking suits the common case where conflicts are rare: no lock is held during the user's think-time, and the conflict is detected only at write. The alternative, pessimistic locking (@Lock(LockModeType.PESSIMISTIC_WRITE)), holds a real database lock and is for high-contention rows where you'd rather block than retry.
@Entity
public class Account {
@Id private Long id;
private BigDecimal balance;
@Version
private long version; // jakarta.persistence.Version
}
Interview note: Trap: "you get
OptimisticLockException— what's the correct response?" Not to suppress it. Catch it and retry the read-modify-write, or surface a "the record changed, please review" message. The exception is the system correctly stopping a lost update, so the fix is a retry or user prompt, not disabling versioning.
What interviewers really test
Spring Data JPA questions probe the gap between using the repository abstraction and understanding what it does — the persistence context, when queries fire, when entities are managed, and where the N+1 and LazyInitializationException traps hide. Anyone can extend JpaRepository; the signal is whether you can explain why a bulk update left a stale entity, or why a fetch join fixed 101 queries.
Build the foundation through the Spring Boot learning path, then connect JPA to its neighbors: it sits directly behind the web layer, so pair it with Spring MVC, and the transaction and proxy behavior that trips people up is really a container topic, which the bean scopes questions reinforce. Once you can trace a request through a transactional service to a fetch-joined query and back to JSON — without a stray N+1 or a detached-entity surprise — a Spring Data JPA mock interview becomes a demonstration rather than an interrogation.
Frequently Asked Questions
What is the difference between CrudRepository and JpaRepository?
What is the N+1 select problem in Spring Data JPA?
What does @Transactional readOnly=true actually do?
Why do I get LazyInitializationException?
How does pagination work in Spring Data JPA?
Want to Build Your Career in Java Full Stack with AI?
Join CodeBegun and train with working industry engineers — See the Java Full Stack course in Hyderabad

