Spring Data JPA Interview Questions – Repositories, Queries and Mappings
-
Last Updated: August 19, 2026
-
By: javahandson
-
Series
Master spring data jpa interview questions: repositories, derived queries, @Query, entity mappings, fetch types, cascade, projections, and @EntityGraph with code.
Spring Data JPA is the layer almost every Spring developer touches daily, yet it is also the layer where the most subtle bugs hide — lazy loading exceptions, accidental N+1 queries, and mappings that quietly own the wrong side of a relationship. Whether you are preparing for spring data jpa interview questions or trying to understand why your repository fires forty queries instead of one, the details in this article are exactly what senior interviewers probe for. They want to know that you understand what Spring Data generates on your behalf, not just which annotations to sprinkle on a class.
| 📌 How to use this article: Read each question as if the interviewer just asked it. Then read the answer. The Interview Insight notes at the end of each answer highlight the specific detail that separates average candidates from strong ones. |
Answer – Spring Data JPA is a Spring module that sits on top of the Java Persistence API (JPA) and removes most boilerplate in building a data-access layer. Instead of writing an implementation class full of EntityManager calls for every repository, you declare an interface, and Spring Data JPA generates the implementation at runtime. Its headline feature is the repository abstraction: you extend an interface like JpaRepository, and you immediately get save, findById, findAll, delete, pagination, and sorting without writing a single line of implementation code.
It is important to be precise about the layering here, because interviewers love this distinction. Three separate things are involved:
So the stack is: your code talks to Spring Data JPA, Spring Data JPA talks to the JPA API, and behind the JPA API sits Hibernate doing the real work of generating SQL, managing the persistence context, and talking to the database.
This means Spring Data JPA does not replace Hibernate — it wraps it. When you call userRepository.save(user), Spring Data JPA ultimately delegates to Hibernate’s EntityManager, which decides whether to issue an INSERT or an UPDATE, flushes the persistence context, and translates your entity into SQL for the configured dialect. Understanding this chain matters because when things go wrong — a LazyInitializationException, an unexpected extra query, a mapping that will not persist — the root cause is almost always in Hibernate’s behavior, not in Spring Data’s thin convenience layer. A senior developer can move fluidly between the two levels of abstraction.
You can also drop down a level whenever you need to. Spring Data JPA lets you inject the EntityManager directly, write custom repository fragments, and use native SQL, so you are never locked into the generated methods. The value proposition is that ninety percent of your data access becomes declarative, while the remaining ten percent — the tricky, performance-sensitive queries — remains fully under your control.
// Spring Data JPA: declare an interface, get an implementation for free
public interface UserRepository extends JpaRepository<User, Long> {
// No implementation needed — Spring Data generates it at runtime
}
// The generated implementation delegates to Hibernate's EntityManager:
// userRepository.save(user) -> SimpleJpaRepository.save()
// -> entityManager.persist() / merge()
// -> Hibernate flushes and issues SQL
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User register(User user) {
return userRepository.save(user); // INSERT via Hibernate
}
}| 🎯 Interview Insight: Never say “Spring Data JPA is an ORM” — it is not. Hibernate is the ORM. Spring Data JPA is a repository abstraction over the JPA spec, and the JPA spec is implemented by a provider (Hibernate by default). Being able to name all three layers — Spring Data JPA, the JPA API, and the Hibernate provider — is exactly the kind of precision that signals a senior candidate. |
Answer – Spring Data offers a hierarchy of repository interfaces, and each one builds on the one below it by adding more capability. Knowing which interface gives you which methods — and, more importantly, which one to extend by default — is a very common opening question because it reveals whether you understand the design of the framework or just copied an interface from a tutorial.
The hierarchy has three tiers, and each one adds capability on top of the tier below it:
In real projects, JpaRepository is the default choice for JPA-backed data access, because it includes everything the two lower interfaces provide plus the batch and flush operations you eventually need. The lower interfaces are useful when you deliberately want to expose a narrower surface — for instance, a repository that should only support paging and sorting but not arbitrary CRUD, or an abstraction that stays persistence-technology-neutral. Choosing the smallest interface that fits is a subtle design signal: it communicates intent and prevents callers from using operations you did not want to allow.
One nuance worth mentioning is that in recent Spring Data versions the interface hierarchy was refactored so that CrudRepository and ListCrudRepository (and their paging counterparts) coexist, giving you List-returning variants lower in the hierarchy. But the conceptual answer is unchanged: each interface adds capability on top of the previous one, and JpaRepository is the richest of the three.
| Interface | Extends | Key additions |
|---|---|---|
| CrudRepository | — | save, findById, findAll, count, delete (returns Iterable) |
| PagingAndSortingRepository | CrudRepository | findAll(Sort), findAll(Pageable) |
| JpaRepository | PagingAndSortingRepository | saveAndFlush, flush, batch deletes, getReferenceById, returns List |
// Default choice for JPA — richest interface
public interface ProductRepository extends JpaRepository<Product, Long> {
}
// Deliberately narrow — only paging & sorting, no arbitrary CRUD exposed
public interface ReportRepository extends PagingAndSortingRepository<Report, Long> {
}
// JpaRepository gives you batch + flush operations the others lack
productRepository.saveAndFlush(product); // save then flush immediately
productRepository.deleteAllInBatch(products); // single bulk DELETE, no cascade| 🎯 Interview Insight: A sharp follow-up is: “Why does JpaRepository return List while CrudRepository returns Iterable?” The answer is ergonomics — List gives you size(), indexed access, and stream operations directly. Mentioning getReferenceById (which replaced the deprecated getOne) also scores points: it returns a lazy proxy without hitting the database, useful for setting foreign keys without loading the full parent entity. |
Answer – Derived query methods are one of Spring Data JPA’s most distinctive features: you declare a method whose name describes the query, and Spring Data parses that name to generate the query for you. There is no @Query annotation and no implementation — the method signature is the specification. This works because Spring Data has a query builder that reads the method name, splits it into a subject and a predicate, and maps property references and keywords onto a JPQL query at startup.
The method name is parsed in two parts. The subject comes first — usually findBy, but also getBy, readBy, queryBy, countBy, existsBy, or deleteBy — and it determines the operation. The predicate follows and describes the criteria: property names combined with keywords. So findByLastName generates a query filtering on the lastName property, and findByLastNameAndFirstName filters on both. Property names must exactly match the entity’s field names (respecting camel case), because Spring resolves them against the entity metamodel; a typo in a property name causes a clear startup error rather than a silent runtime bug.
Spring supports a rich vocabulary of keywords inside the predicate:
This vocabulary is expressive enough that a large fraction of everyday queries never need a hand-written JPQL string.
The key discipline with derived queries is knowing when to stop. They are perfect for straightforward filters, but once a method name grows to a paragraph of camel case, it becomes unreadable and error-prone. That is precisely the boundary where you switch to @Query, which the next question covers. Interviewers appreciate a candidate who can articulate that boundary rather than treating derived queries as a hammer for every problem.
public interface UserRepository extends JpaRepository<User, Long> {
// WHERE last_name = ?
List<User> findByLastName(String lastName);
// WHERE last_name = ? AND active = true
List<User> findByLastNameAndActiveTrue(String lastName);
// WHERE age BETWEEN ? AND ? ORDER BY age DESC
List<User> findByAgeBetweenOrderByAgeDesc(int min, int max);
// WHERE lower(email) LIKE lower(concat('%', ?, '%'))
List<User> findByEmailContainingIgnoreCase(String fragment);
// SELECT count(*) WHERE active = true
long countByActiveTrue();
// LIMIT 3, ordered by score
List<User> findTop3ByOrderByScoreDesc();
}| 🎯 Interview Insight: The subtle trap here is property traversal. findByAddressCity works if User has an Address with a city field — Spring walks the property path. But if User also has an addressCity field, the resolution becomes ambiguous. Spring resolves greedily, and you disambiguate with an underscore: findByAddress_City. Knowing the underscore escape hatch shows real hands-on experience. |
Answer – The @Query annotation lets you attach an explicit query to a repository method instead of relying on the method name. This is the escape hatch you reach for whenever a query is too complex, too specific, or too performance-sensitive to express as a derived method. @Query accepts two flavours of query: JPQL, which is the default and operates on entities and their properties, and native SQL, which you enable with nativeQuery = true and which operates on actual table and column names.
JPQL is object-oriented: you write SELECT u FROM User u WHERE u.email = :email, referencing the User entity and its email property rather than the users table and its email column. The provider translates this into database-specific SQL at runtime, so the same JPQL runs unchanged across databases. Native queries, by contrast, are raw SQL passed straight to the database. You use them when you need vendor-specific features, complex joins that JPQL cannot express cleanly, or hand-tuned performance that depends on the exact SQL. The tradeoff is portability: a native query written for one database dialect may not run on another.
Parameters can be bound two ways:
For SELECT queries this is all you need. But for data-changing statements — UPDATE and DELETE — you must add the @Modifying annotation. Without it, Spring assumes the query is a SELECT and fails, because the machinery that executes a modifying statement is different from the one that runs a projection query.
There is one more detail interviewers probe: after a @Modifying query runs, the persistence context can hold stale entities, because a bulk UPDATE or DELETE bypasses the first-level cache and dirty checking. To keep the context consistent you often set clearAutomatically = true or flushAutomatically = true on the @Modifying annotation, or you manually clear the EntityManager. Being aware of this cache-staleness pitfall is what separates someone who has run bulk updates in production from someone who has only read about them.
public interface UserRepository extends JpaRepository<User, Long> {
// JPQL — references the User entity and its properties
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(@Param("email") String email);
// Native SQL — references the actual table and columns
@Query(value = "SELECT * FROM users WHERE created_at > NOW() - INTERVAL 7 DAY",
nativeQuery = true)
List<User> findRecentSignups();
// Modifying query — MUST annotate with @Modifying
@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE User u SET u.active = false WHERE u.lastLogin < :cutoff")
int deactivateStaleUsers(@Param("cutoff") LocalDateTime cutoff);
}| 🎯 Interview Insight: A classic gotcha: “Why did my @Modifying update run but the entity I just read still shows the old value?” Because the bulk statement bypassed the persistence context. The fix is clearAutomatically = true (or a manual entityManager.clear()). Also note that @Modifying queries return int/void — the row count — not entities, which surprises developers expecting the updated objects back. |
Answer – These five annotations form the core mapping vocabulary that turns a plain Java class into a persistent entity. Every JPA entity you ever write uses some combination of them, so interviewers use this question to confirm you understand the object-to-table mapping at a fundamental level before moving on to relationships and performance.
@Entity marks a class as a JPA entity, meaning its instances correspond to rows in a database table and the persistence provider will manage its lifecycle. The class must have a no-argument constructor and a primary key. By default, the table name is derived from the class name, so an Entity called Product maps to a table named product (or PRODUCT depending on the naming strategy). @Table overrides that default — you use it to specify an exact table name, a schema, or unique constraints and indexes, which is essential when your Java naming and your database naming diverge, as they often do in real systems with pre-existing schemas.
@Id designates the primary key field. Every entity needs exactly one identifier (or a composite one), and this is what the persistence context uses to track and cache entities. @GeneratedValue tells the provider how to populate that key automatically, and its strategy attribute is the important part:
@Column customizes the mapping of a field to its column. You use it to override the column name, mark a column as non-nullable or unique, set length for varchar columns, or mark it insertable = false / updatable = false for read-only mapped values. If you omit @Column, the field still maps to a column derived from its name with sensible defaults, so @Column is only needed when you want to deviate from those defaults. Together these annotations give you complete, declarative control over how your object model lands in the relational model.
@Entity
@Table(name = "products",
uniqueConstraints = @UniqueConstraint(columnNames = "sku"))
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "product_seq")
@SequenceGenerator(name = "product_seq",
sequenceName = "product_sequence",
allocationSize = 50)
private Long id;
@Column(name = "sku", nullable = false, unique = true, length = 40)
private String sku;
@Column(nullable = false)
private String name;
@Column(name = "price", precision = 10, scale = 2)
private BigDecimal price;
// no-arg constructor, getters, setters
}| 🎯 Interview Insight: When asked “which @GeneratedValue strategy do you prefer and why?”, the strong answer is SEQUENCE over IDENTITY on databases that support sequences. IDENTITY forces the provider to execute each INSERT immediately to obtain the generated key, which silently disables JDBC batch inserts. SEQUENCE with a sensible allocationSize lets Hibernate reserve a block of IDs and batch inserts efficiently — a real performance difference at scale. |
Answer – Relationship mapping is where JPA becomes genuinely powerful and genuinely dangerous, so this is a favorite intermediate question. The four cardinalities describe how rows in one table relate to rows in another, and each has a preferred way to be modeled. Getting the annotations right is only half the battle — getting the ownership and fetch behavior right is what actually determines whether your application performs well.
Each of the four cardinalities has a preferred way to be modeled:
For every relationship you also decide the fetch type and the cascade behavior, both of which the following questions cover. The single most important habit to state in an interview is this: keep collection relationships lazy, keep the foreign key on the many side, and manage bidirectional links carefully so both sides stay in sync. These habits prevent the majority of JPA performance problems before they start.
@Entity
public class Customer {
@Id @GeneratedValue private Long id;
// One customer -> many orders. Non-owning side.
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();
// One-to-one, owning side holds the FK
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private Profile profile;
}
@Entity
public class Order {
@Id @GeneratedValue private Long id;
// Many orders -> one customer. Owning side: FK column lives here.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
}
@Entity
public class Student {
@Id @GeneratedValue private Long id;
@ManyToMany
@JoinTable(name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
private Set<Course> courses = new HashSet<>();
}| 🎯 Interview Insight: The best answer volunteers a maintenance tip: for bidirectional relationships, add helper methods like addOrder() on the parent that set both sides of the link. Setting only one side is the most common relationship bug — the in-memory object graph looks correct but the foreign key never gets written because you updated the non-owning side. Owning side wins; the database only reflects the owning side. |
Answer – Fetch type controls when a related entity or collection is loaded from the database relative to loading the entity that references it. This single setting has an outsized impact on application performance, which is why it appears in almost every JPA interview. There are two options: EAGER, which loads the association immediately alongside the parent, and LAZY, which defers loading until the association is actually accessed.
With EAGER fetching, when you load a Customer, the provider immediately loads all EAGER associations too — the profile, the orders, whatever is marked EAGER — usually via joins or additional queries. This feels convenient because the data is simply there when you need it. The problem is that it is almost always more data than you need, and it is loaded whether or not you use it. Worse, EAGER associations cannot be turned off per query; once an association is EAGER at the mapping level, every query that loads the parent pays the cost, and this is a primary driver of the N+1 problem and of loading huge object graphs for a simple lookup.
With LAZY fetching, the provider loads the parent and installs a proxy in place of the association. The related data is fetched only when you first touch it — call getOrders() and only then does the query fire. This is efficient because you pay for exactly the data you use. The catch is the LazyInitializationException: if you access a lazy association after the persistence context has closed — typically after the transaction ends and you are back in the controller or view layer — there is no session to load it, and the proxy throws. The correct fix is to fetch what you need inside the transaction, using a fetch join or an @EntityGraph, rather than keeping the session open artificially.
The defaults are worth memorizing because they trip people up:
The professional recommendation is unambiguous: make everything LAZY explicitly, including the to-one associations, and fetch eagerly only where a specific query needs it, using a JOIN FETCH or an entity graph. Stating this preference clearly, with the reason, is exactly what an interviewer wants to hear.
| Aspect | FetchType.LAZY | FetchType.EAGER |
|---|---|---|
| When loaded | On first access of the association | Immediately with the parent |
| Default for | @OneToMany, @ManyToMany | @ManyToOne, @OneToOne |
| Main risk | LazyInitializationException | Over-fetching, N+1, huge graphs |
| Per-query override | Add JOIN FETCH / @EntityGraph | Cannot be disabled per query |
| Recommendation | Prefer everywhere | Avoid; fetch on demand instead |
@Entity
public class Order {
@Id @GeneratedValue private Long id;
// Override the EAGER default on to-one — make it LAZY
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// Already LAZY by default, but be explicit
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
// Fetch eagerly only where a query needs it:
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.id = :id")
Optional<Order> findByIdWithCustomer(@Param("id") Long id);| 🎯 Interview Insight: When asked “how do you fix LazyInitializationException?”, never answer “enable open-session-in-view” — that is the anti-pattern. The correct answers are: fetch join in the query, an @EntityGraph, a DTO projection that selects exactly what you need, or initializing the association inside the transactional service method. Reaching for a fetch join instead of keeping the session open marks you as someone who understands the tradeoff rather than papering over it. |
Answer – Cascade types control whether an operation you perform on a parent entity propagates to its associated child entities. Without cascading, saving a parent does not save its newly added children, and you would have to save each child manually. Cascading lets the object graph behave as a unit, which is convenient but must be applied deliberately, because cascading the wrong operation — especially delete — across the wrong relationship can wipe out data you meant to keep.
JPA defines several cascade types:
You apply cascade on the relationship annotation, and it flows from the side you annotate toward the associated entities.
The real skill is matching cascade types to genuine ownership. CascadeType.ALL, often combined with orphanRemoval = true, is appropriate for a true parent-child composition where the child cannot exist without the parent — an Order and its OrderItems, or a Post and its Comments in a model where comments belong exclusively to the post. Deleting the order should delete its items, and removing an item from the collection should delete it. By contrast, you would almost never cascade REMOVE from an Order to its Customer: deleting an order must not delete the customer who placed it. That association is a reference, not a composition. Reasoning aloud about ownership versus reference is exactly the maturity interviewers listen for.
One frequently confused point is that orphanRemoval is not a CascadeType, even though it feels related. CascadeType.REMOVE deletes children when the parent itself is deleted; orphanRemoval deletes a child when it is removed from the parent’s collection while the parent still lives. They overlap but are not the same, and using orphanRemoval on a shared association can delete a child that another parent still references. The safe default in most codebases is to cascade nothing by default and add specific cascade types only where a clear composition relationship justifies them.
| CascadeType | Propagates | Realistic scenario |
|---|---|---|
| PERSIST | Insert of new children on save | Save Order also inserts its new OrderItems |
| MERGE | Update of children on merge | Update a detached Cart updates its line items |
| REMOVE | Delete of children on delete | Delete Post deletes its Comments (composition) |
| REFRESH | Reload from DB | Refresh Invoice reloads its InvoiceLines |
| DETACH | Evict from context | Detach Order detaches its items together |
| ALL | All of the above | True parent-child aggregate roots |
@Entity
public class Order {
@Id @GeneratedValue private Long id;
// True composition: items live and die with the order
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
// Reference, NOT composition: never cascade REMOVE here
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer; // deleting the order must not delete the customer
}
// Because items cascade, this single save inserts the order AND its items:
order.getItems().add(new OrderItem(...));
orderRepository.save(order);| 🎯 Interview Insight: The killer follow-up: “What is the difference between CascadeType.REMOVE and orphanRemoval?” REMOVE fires when the parent is deleted; orphanRemoval fires when a child is removed from the collection while the parent still exists. If you say they are identical, you fail the question. Also flag the danger: never put CascadeType.REMOVE on a @ManyToOne toward a shared entity — you can cascade-delete something other parents still need. |
Answer – This question goes to the heart of how JPA models bidirectional relationships, and it is one of the most reliable ways for an interviewer to separate developers who truly understand JPA from those who copy annotations until the code compiles. In any bidirectional association, exactly one side owns the relationship, and the owning side is the one that controls the foreign key in the database. @JoinColumn and mappedBy are the two annotations that declare, respectively, the owning side and the inverse side.
The owning side is where the foreign key physically lives. You mark it with @JoinColumn, which names the foreign key column. When Hibernate generates SQL to persist or update the relationship, it looks only at the owning side. Changes to the owning side’s reference are what actually get written to the database. The inverse, non-owning side is declared with mappedBy, whose value is the name of the field on the owning side that maps the relationship back. mappedBy essentially says, “I do not own this relationship — the foreign key is managed over there, by that field.” The inverse side is a read-oriented mirror of the association.
The practical consequence, and the source of endless confusion, is that updating only the inverse side has no effect on the database. If you have a Customer with a mappedBy list of orders and you add an Order to that list without setting the order’s customer field — the owning @ManyToOne side — then Hibernate never sees a change to the owning side and never writes the foreign key. The in-memory graph looks correct, the code runs without error, and the relationship silently fails to persist. This is why experienced developers always update the owning side, and typically write helper methods that set both sides at once to keep the object graph and the database in agreement.
The standard convention makes this easy to remember:
Stating the convention crisply — foreign key on the many side, mappedBy on the one side — demonstrates command of the concept.
@Entity
public class Customer {
@Id @GeneratedValue private Long id;
// INVERSE side — does NOT own the FK. "customer" is the field on Order.
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();
// Helper keeps BOTH sides in sync
public void addOrder(Order order) {
orders.add(order);
order.setCustomer(this); // <-- sets the OWNING side, so FK is written
}
}
@Entity
public class Order {
@Id @GeneratedValue private Long id;
// OWNING side — @JoinColumn = the FK column lives here
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
}| 🎯 Interview Insight: The definitive one-liner to have ready: “The owning side has @JoinColumn and controls the foreign key; the inverse side has mappedBy and is ignored when writing.” Then add the failure mode: “If you update only the mappedBy side, nothing persists.” Delivering both the rule and its failure mode in two sentences signals that you have actually debugged this in production. |
Answer – Pagination is essential for any endpoint that returns lists, because loading an entire table into memory does not scale. Spring Data JPA provides first-class pagination through three abstractions — Pageable, Page, and Slice — that let you request a specific page of results, sorted however you like, without writing LIMIT and OFFSET by hand. You simply add a Pageable parameter to a repository method and return a Page or Slice.
Pageable is the request: it carries the page number, the page size, and an optional Sort. You typically build one with PageRequest.of(pageNumber, pageSize, Sort.by(“createdAt”).descending()). Spring translates this into the appropriate LIMIT and OFFSET for your database and applies the ORDER BY. Because sorting is part of Pageable, you get stable, database-side ordering rather than sorting in application memory. Sort can also be passed on its own to methods that only need ordering without paging.
The distinction between Page and Slice is the detail that earns points:
This makes Slice cheaper — one query instead of two — and it is the right choice for infinite-scroll interfaces or “load more” buttons where you never need the exact total, only whether there is more to load.
Choosing between them is therefore a performance decision, not a stylistic one. On a large table, the COUNT query behind Page can be surprisingly expensive, sometimes rivaling the cost of the page fetch itself. If your UI genuinely needs total counts and page numbers, Page is correct despite the cost. If it does not, Slice avoids an entire query on every request. A candidate who explains that Page costs an extra COUNT query while Slice costs one extra row, and then ties that to the UI pattern, demonstrates exactly the kind of cost-awareness senior roles require.
| Type | Extra cost | Knows total? | Best for |
|---|---|---|---|
| Page | A second COUNT query | Yes — total elements and pages | Numbered pagination controls |
| Slice | One extra row fetched | No — only “has next” | Infinite scroll / “load more” |
| List | None | No | Small, bounded result sets |
public interface OrderRepository extends JpaRepository<Order, Long> {
// Returns total count -> runs an extra COUNT query
Page<Order> findByStatus(String status, Pageable pageable);
// No count query -> just checks if a next page exists
Slice<Order> findByCustomerId(Long customerId, Pageable pageable);
}
// Building the request with sorting baked in
Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findByStatus("SHIPPED", pageable);
page.getTotalElements(); // available on Page (from the COUNT query)
page.getContent(); // the 20 rows for this page
page.hasNext(); // available on both Page and Slice| 🎯 Interview Insight: The follow-up that catches people: “Your paginated API got slow — why?” Very often the answer is the COUNT query behind Page on a large, filtered table, or deep OFFSET pagination where the database must skip millions of rows. Mentioning that Slice removes the COUNT, and that keyset (cursor) pagination removes the OFFSET cost entirely, turns a routine question into a standout answer. |
Answer – Projections let a repository method return only a subset of an entity’s data rather than the full entity. This matters for performance and for clean API boundaries: if a screen needs only a user’s id, name, and email, there is no reason to load and hydrate the entire User entity with all its columns and lazy associations. Spring Data JPA supports two styles of projection — interface-based and class-based — plus a dynamic variant, and choosing among them is a common intermediate question because it reveals whether you think about query shape and not just query result.
An interface-based projection is a simple interface with getter methods matching the properties you want. You declare a repository method that returns the interface type, and Spring Data creates a proxy at runtime that exposes only those getters, backed by the selected columns. These come in two forms:
A class-based projection — usually called a DTO projection — returns a concrete class, typically an immutable one, whose constructor parameters match the selected columns. With a JPQL constructor expression you write SELECT new com.example.UserDto(u.id, u.name, u.email) FROM User u, and Spring instantiates the DTO directly from the query result. This is explicit, debuggable, and produces a plain object with no proxy magic, which many teams prefer for values that cross the service boundary into controllers or external APIs. Records make this especially clean because a record’s canonical constructor lines up naturally with the projection.
The general recommendation is to prefer closed interface projections or DTO projections for read queries, because both narrow the selected columns and avoid loading unnecessary data and associations. Reserve full-entity loading for cases where you actually need to modify the entity, since only managed entities participate in dirty checking and updates. Being able to say “I use projections for read models and full entities for write operations” shows a mature, CQRS-flavored instinct that interviewers reward.
// 1. Closed interface projection — Spring selects only id, name, email
public interface UserSummary {
Long getId();
String getName();
String getEmail();
}
// 2. Open projection — computed value, but loses column optimization
public interface UserView {
@Value("#{target.firstName + ' ' + target.lastName}")
String getFullName();
}
// 3. Class-based (DTO) projection via constructor expression
public record UserDto(Long id, String name, String email) {}
public interface UserRepository extends JpaRepository<User, Long> {
List<UserSummary> findByActiveTrue(); // interface projection
@Query("SELECT new com.example.UserDto(u.id, u.name, u.email) FROM User u")
List<UserDto> findAllSummaries(); // DTO projection
// Dynamic projection — caller chooses the shape
<T> List<T> findByEmail(String email, Class<T> type);
}| 🎯 Interview Insight: The performance nuance that impresses: closed interface projections and constructor-expression DTOs both restrict the SELECT to the columns you asked for. Open interface projections (those with @Value SpEL) quietly fetch the whole entity and compute in memory, so they do not save any database work. If you claim projections improve performance, be ready to specify which kind — closed and DTO yes, open no. |
Answer – @Embeddable and @Embedded implement JPA’s notion of a value type — a group of columns that belong together conceptually but do not warrant their own table or identity. Instead of scattering related fields across an entity or giving every small concept its own table, you bundle them into a reusable component that is stored inline in the owning entity’s table. This keeps your object model expressive without complicating the schema, and it is a favorite advanced question because it tests whether you understand the difference between an entity and a value type.
You annotate the reusable component class with @Embeddable. An embeddable has no @Id and no independent lifecycle — it is not an entity, it cannot be queried on its own, and it has no identity of its own. Its fields simply map to columns. A classic example is an Address with street, city, state, and postal code: an address is not an entity in its own right, it is a value that belongs to whatever owns it. You then place @Embedded on a field of that type inside an entity, and JPA folds the embeddable’s columns directly into the entity’s table. So a Customer with an @Embedded Address results in a single customers table that includes the address columns, with no join and no separate table.
The power of this feature is reuse combined with cohesion. The same Address embeddable can be used by Customer, Supplier, and Warehouse, giving you one definition of what an address is and consistent columns everywhere. Because the embeddable is stored inline, reads and writes involve no join, so there is no performance penalty compared to inlining the fields manually — you get clean modeling for free. When two embeddables of the same type appear in one entity, such as a billing address and a shipping address, you use @AttributeOverrides to remap the column names so they do not collide, which is a detail worth mentioning to show depth.
It is worth contrasting this with a @OneToOne to a separate entity, because interviewers sometimes push on when to use which:
Framing the choice as “identity and lifecycle versus pure value” is the crisp distinction that lands well.
// The reusable value type — no @Id, no table of its own
@Embeddable
public class Address {
private String street;
private String city;
private String state;
@Column(name = "postal_code")
private String postalCode;
// getters, setters, equals/hashCode
}
@Entity
public class Customer {
@Id @GeneratedValue private Long id;
private String name;
// Columns fold directly into the customers table — no join
@Embedded
private Address billingAddress;
// Same type twice -> remap columns to avoid collisions
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "ship_street")),
@AttributeOverride(name = "city", column = @Column(name = "ship_city"))
})
private Address shippingAddress;
}| 🎯 Interview Insight: The distinguishing detail is @AttributeOverrides. Anyone can define one embeddable; the moment you need two of the same type in one entity — billing and shipping addresses — their columns collide, and only @AttributeOverrides resolves it. Volunteering that scenario unprompted signals you have modeled real domains, not just textbook examples. |
Answer – These three methods are all about the timing of when your changes actually reach the database, and the confusion around them comes from a fundamental JPA behavior: writing to the persistence context is not the same as writing to the database. Understanding the gap between the two — and what flushing does to close it — is the substance of this question.
The three methods differ only in when the SQL actually reaches the database:
The rule of thumb to state clearly is: use plain save() by default and let Hibernate flush at commit; reach for flush() or saveAndFlush() only when you have a specific reason to make the database catch up early.
| Method | What it does | When to use |
|---|---|---|
| save() | Registers entity; SQL deferred to flush time | Default — let Hibernate batch and flush at commit |
| flush() | Sends queued SQL now (no commit) | Need DB to see changes before txn ends |
| saveAndFlush() | Save then flush immediately | Need generated ID / DB values right after save |
@Service
public class AccountService {
private final AccountRepository accountRepository;
@Transactional
public void transfer(Account from, Account to) {
accountRepository.save(from); // scheduled, SQL may not run yet
accountRepository.save(to); // Hibernate can batch both writes
// Force the two UPDATEs to hit the DB now (still not committed)
accountRepository.flush();
// A native query below would now see the flushed rows
}
@Transactional
public Long createAndReturnId(User user) {
// Need the generated ID immediately -> flush right away
return accountRepository.saveAndFlush(mapToAccount(user)).getId();
}
}| 🎯 Interview Insight: The trap answer is “saveAndFlush commits the transaction.” It does not. Flushing sends SQL to the database but the transaction is still open and a rollback would erase everything. Keeping “flush is not commit” straight is the single most common point candidates get wrong on this question — say it explicitly and you are ahead. |
Answer – @EntityGraph is Spring Data JPA’s declarative answer to the tension created by lazy loading. You want associations to be lazy by default so you do not over-fetch, but for specific queries you need certain associations loaded up front to avoid both the N+1 problem and the LazyInitializationException. An entity graph lets you specify, per query, exactly which associations to fetch eagerly in a single round trip, without changing the mapping’s default fetch type. It is the clean, mapping-preserving alternative to sprinkling EAGER on your entities.
The problem it solves is concrete. Suppose Order has a lazy customer and lazy items. If you load a list of orders and then, in a loop, access each order’s customer, you trigger one query for the orders plus one query per order for the customer — the classic N+1 pattern that quietly turns a page render into hundreds of queries. You could write a JOIN FETCH query to fix it, and that works, but @EntityGraph offers the same fix declaratively and composes better with derived queries and pagination. You annotate the repository method with @EntityGraph and list the attribute paths to load, and Spring instructs Hibernate to fetch those associations along with the root entity.
There are two fetch semantics to be aware of:
In Spring Data’s @EntityGraph, the attributePaths you specify are the associations that get join-fetched. You can also define named entity graphs on the entity itself with @NamedEntityGraph and reference them by name, which is useful when the same fetch plan is reused across several queries. Nested paths, using the subgraph mechanism, let you fetch an association of an association — for instance, each order’s items and each item’s product in one graph.
The reason @EntityGraph is a favorite advanced question is that it forces you to articulate the whole fetch-strategy philosophy in one place: keep mappings lazy, then opt into eager loading per query where the access pattern demands it. Compared to a hand-written JOIN FETCH, an entity graph keeps the query itself as a clean derived method or simple JPQL and moves the fetch concern into an annotation, which many teams find more maintainable. Positioning @EntityGraph as “per-query eager fetching that keeps the mapping lazy” captures its entire value in a sentence.
@Entity
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode(value = "items", subgraph = "items-product")
},
subgraphs = @NamedSubgraph(
name = "items-product",
attributeNodes = @NamedAttributeNode("product"))
)
public class Order { /* lazy customer, lazy items */ }
public interface OrderRepository extends JpaRepository<Order, Long> {
// Ad-hoc graph: fetch customer + items in one query, no N+1
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(String status);
// Reuse a named graph defined on the entity
@EntityGraph(value = "Order.withCustomerAndItems")
Optional<Order> findById(Long id);
}| 🎯 Interview Insight: Be ready for “@EntityGraph vs JOIN FETCH — when would you use each?” Both solve N+1. JOIN FETCH lives in the JPQL and is explicit; @EntityGraph is declarative and reusable and works cleanly with derived query methods and pagination, where a JOIN FETCH plus Pageable can force Hibernate to paginate in memory. Naming that pagination interaction is an expert-level detail. |
Answer – A soft delete marks a row as deleted instead of physically removing it, so the data is preserved for auditing, recovery, or referential history while disappearing from normal application queries. This is an extremely common requirement in real systems — you rarely want a customer or an order to vanish irrecoverably — and implementing it cleanly in Spring Data JPA is a good advanced question because there are several approaches with meaningfully different tradeoffs.
The most portable approach uses a boolean or timestamp flag on the entity, such as deleted or deletedAt, combined with a couple of Hibernate hooks. You intercept the delete operation so it performs an UPDATE that sets the flag rather than issuing a DELETE, and you filter out flagged rows on every read. Hibernate provides annotations for exactly this: @SQLDelete rewrites the delete statement to an UPDATE that sets your flag, and a filter condition — historically @Where, and in current Hibernate @SQLRestriction — appends a “not deleted” predicate to every query for the entity. With these two in place, calling repository.delete(entity) quietly flags the row, and findAll and derived queries automatically exclude flagged rows, so the rest of your code is unaware that deletes are soft.
An alternative that stays entirely within JPA semantics is to avoid Hibernate-specific annotations and instead never call the physical delete at all. You add a boolean field, expose a service method that sets it and saves, and write your repository queries to always include an active = true condition — either through derived methods like findByActiveTrue or through @Query definitions that filter explicitly. This is more verbose because the filter must be repeated, but it is completely standard JPA and leaves no hidden query rewriting, which some teams prefer for clarity and auditability. A refinement is to define a base query or a custom repository fragment so the active predicate is not duplicated in every method.
Whichever approach you choose, there are important caveats to raise, because they are what a senior interviewer is listening for:
Acknowledging these pitfalls turns a simple “add a flag” answer into a genuinely senior one.
// Approach 1: Hibernate rewrites delete -> UPDATE, and filters reads
@Entity
@Table(name = "customers")
@SQLDelete(sql = "UPDATE customers SET deleted = true WHERE id = ?")
@SQLRestriction("deleted = false") // appended to every SELECT (was @Where)
public class Customer {
@Id @GeneratedValue private Long id;
private String name;
@Column(nullable = false)
private boolean deleted = false;
}
// customerRepository.delete(customer) -> UPDATE ... SET deleted = true
// customerRepository.findAll() -> WHERE deleted = false (automatic)
// Approach 2: pure JPA, explicit filter, no hidden rewriting
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByActiveTrue();
@Modifying
@Query("UPDATE Order o SET o.active = false WHERE o.id = :id")
void softDelete(@Param("id") Long id);
}| 🎯 Interview Insight: The caveat that impresses most: soft-deleted rows still count toward UNIQUE constraints. If a user with email “a@b.com” is soft-deleted and someone tries to register the same email, the insert fails on the unique index even though the app treats the old row as gone. The fix is a partial/filtered unique index that only applies where deleted = false. Raising this unprompted shows you have actually shipped soft deletes. |
Spring Data JPA gives you an enormous amount of leverage — declarative repositories, derived queries, and clean mappings — but that leverage cuts both ways. The same abstraction that lets you skip boilerplate is also the one that hides the SQL, the fetching, and the flushing that determine whether your application is fast or crawls. Understanding what Spring Data generates on your behalf, and being able to drop to the Hibernate level when needed, is what separates developers who use JPA from developers who master it.
Key takeaways from this article: