Spring Data & Persistence
Spring Data JPA
Spring Data JPA is an abstraction layer over JPA (and transitively, Hibernate as the implementation) that eliminates boilerplate DAO code via repository interfaces — you declare an interface, Spring generates the implementation proxy at runtime.
public interface OrderRepository extends JpaRepository<Order, Long> {
}
This single interface gives you save(), findById(), findAll(), delete(), pagination, and sorting — with zero implementation code. The architectural value is removing repetitive CRUD plumbing so the team’s effort goes into actual query/business logic.
Hibernate Integration
JPA is a specification (jakarta.persistence.*); Hibernate is the default implementation Spring Boot wires in via spring-boot-starter-data-jpa. Spring Data JPA sits on top of JPA, which sits on top of Hibernate (or any other JPA provider — EclipseLink is a rare alternative).
Spring Data JPA (repository abstraction)
↓
JPA (specification: EntityManager, annotations)
↓
Hibernate (implementation: SessionFactory, dialect, caching)
↓
JDBC ↔ Database
Key Hibernate-specific concepts architects should distinguish from pure JPA:
| Concept | Layer | Note |
|---|---|---|
EntityManager |
JPA | Standard interface for persistence operations |
Session |
Hibernate | Hibernate’s native API, extends EntityManager capability under the hood |
| First-level cache | Both (JPA mandates it) | Per-EntityManager/transaction, automatic, not configurable off |
| Second-level cache | Hibernate-specific (JPA allows but doesn’t mandate) | Cross-session, needs explicit setup (Ehcache/Caffeine/Redis provider) |
| Dialect | Hibernate | Translates JPQL/HQL to DB-specific SQL — set via spring.jpa.database-platform or auto-detected |
spring:
jpa:
hibernate:
ddl-auto: validate # never 'update' in production — see note below
show-sql: false
properties:
hibernate:
format_sql: true
jdbc:
batch_size: 50
Architect-level rule: ddl-auto: update/create belongs only in local dev/test. Production schema changes should go through versioned migration tools (Flyway/Liquibase) — letting Hibernate auto-alter production schema is a reliability and audit-trail risk that comes up frequently in architecture reviews.
Entity Mapping
@Entity
@Table(name = "orders", indexes = @Index(name = "idx_customer", columnList = "customer_id"))
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_number", nullable = false, unique = true, length = 30)
private String orderNumber;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@CreationTimestamp
private Instant createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Version
private Long version; // optimistic locking
}
Relationship mapping cheat-sheet:
| Annotation | Cardinality | Default fetch | Key attributes |
|---|---|---|---|
@OneToOne |
1:1 | EAGER | mappedBy on inverse side |
@OneToMany |
1:N | LAZY | mappedBy, cascade, orphanRemoval |
@ManyToOne |
N:1 | EAGER | @JoinColumn for FK |
@ManyToMany |
N:N | LAZY | @JoinTable for join table |
Architect-level points:
- Always set
@ManyToOne(fetch = FetchType.LAZY)explicitly — JPA defaults@ManyToOne/@OneToOneto EAGER, which is the single most common cause of accidental over-fetching and N+1 chains in production codebases. @Versionenables optimistic locking — essential for any entity touched by concurrent updates (avoids lost-update anomalies without resorting to pessimistic DB locks).- Use
mappedByon the non-owning side of bidirectional relationships; the owning side (with@JoinColumn) is what actually controls the FK in generated SQL.
Repositories
public interface OrderRepository extends JpaRepository<Order, Long> {
// Derived query — method name parsed into JPQL automatically
List<Order> findByCustomerIdAndStatus(Long customerId, OrderStatus status);
Optional<Order> findByOrderNumber(String orderNumber);
// Explicit JPQL when derivation gets unwieldy
@Query("SELECT o FROM Order o WHERE o.createdAt >= :since AND o.status = :status")
List<Order> findRecentByStatus(@Param("since") Instant since, @Param("status") OrderStatus status);
// Native SQL when JPQL can't express it (DB-specific functions, hints)
@Query(value = "SELECT * FROM orders WHERE total_amount > :amount ORDER BY created_at DESC LIMIT 10",
nativeQuery = true)
List<Order> findTopByAmount(@Param("amount") BigDecimal amount);
// Bulk update — needs @Modifying, runs outside the persistence context cache
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") OrderStatus status);
}
Repository hierarchy: Repository (marker) → CrudRepository (basic CRUD) → PagingAndSortingRepository (adds pagination/sort) → JpaRepository (adds JPA-specific batch ops, flushing). Extend JpaRepository by default unless you deliberately want a narrower contract (e.g., exposing a read-only repository interface to a different module without write methods).
Architect-level nuance: @Modifying queries bypass the persistence context — entities already loaded into the current session are not automatically refreshed to reflect a bulk update/delete, leading to stale in-memory state unless you call entityManager.clear() or set @Modifying(clearAutomatically = true). This is a frequent source of “the data updated in DB but my code still sees the old value” bugs.
JPQL, Criteria API
JPQL — object-oriented query language, operates on entity/field names rather than table/column names, portable across DB dialects.
@Query("SELECT o FROM Order o JOIN o.customer c WHERE c.region = :region AND o.status <> 'CANCELLED'")
List<Order> findActiveByRegion(@Param("region") String region);
// Projections to DTOs — avoids fetching entire entity graph when only a few fields are needed
@Query("SELECT new com.acme.dto.OrderSummary(o.id, o.orderNumber, o.totalAmount) FROM Order o")
List<OrderSummary> findSummaries();
Criteria API — type-safe, programmatic query construction, useful when query shape is determined at runtime (dynamic filters from a search UI) rather than fixed at compile time.
public List<Order> search(OrderSearchCriteria criteria) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Order> query = cb.createQuery(Order.class);
Root<Order> order = query.from(Order.class);
List<Predicate> predicates = new ArrayList<>();
if (criteria.customerId() != null) {
predicates.add(cb.equal(order.get("customer").get("id"), criteria.customerId()));
}
if (criteria.status() != null) {
predicates.add(cb.equal(order.get("status"), criteria.status()));
}
if (criteria.minAmount() != null) {
predicates.add(cb.greaterThanOrEqualTo(order.get("totalAmount"), criteria.minAmount()));
}
query.where(predicates.toArray(new Predicate[0]));
return entityManager.createQuery(query).getResultList();
}
Architect decision criteria:
- JPQL/
@Query— fixed, known-at-compile-time queries; more readable, easier to maintain, what 90% of code should use. - Criteria API — dynamic query construction with optional/composable filters (search/filter UIs with many optional fields); type-safe (compile errors instead of runtime string-typo JPQL errors) at the cost of verbosity.
- Spring Data
Specification<T>is usually the better middle ground in real codebases — wraps Criteria API into composable, reusable predicate objects (and()/or()chaining) without raw Criteria boilerplate everywhere. Worth mentioning if asked “how would you handle dynamic search filters” — it shows awareness beyond the textbook two options.
Transactions (@Transactional)
@Service
public class OrderService {
@Transactional
public OrderResponse placeOrder(OrderRequest request) {
Order order = buildOrder(request);
orderRepository.save(order);
inventoryService.reserve(order); // if this throws, the save() above rolls back too
return toResponse(order);
}
@Transactional(readOnly = true)
public List<OrderResponse> listOrders() {
// readOnly hints Hibernate to skip dirty-checking, flush mode = MANUAL — perf optimization
return orderRepository.findAll().stream().map(this::toResponse).toList();
}
}
How it actually works: @Transactional is implemented via a Spring AOP proxy (same BeanPostProcessor mechanism from bean lifecycle) — a proxy wraps the method call, opens a transaction before invocation, commits on normal return, rolls back on a matching runtime exception.
Critical pitfall — self-invocation (a top interview trap):
@Service
public class OrderService {
public void process(Order order) {
this.save(order); // INTERNAL call — bypasses the proxy entirely!
}
@Transactional
public void save(Order order) { ... } // transaction NEVER starts
}
Because the proxy wraps the bean, not the class — calling this.save() from within the same class calls the raw method directly, skipping the proxy and silently running with no transaction. Fix: move the @Transactional method to a separate bean and inject/call it externally, or use AopContext.currentProxy() (less clean) or restructure with self-injection.
Propagation & isolation — what architects are actually expected to reason about:
| Propagation | Behavior |
|---|---|
REQUIRED (default) |
Join existing transaction, or create new one if none exists |
REQUIRES_NEW |
Always suspend current transaction, start a fresh independent one (e.g., audit logging that must persist even if the outer transaction rolls back) |
NESTED |
Savepoint within the existing transaction — partial rollback possible |
SUPPORTS |
Join if exists, run non-transactionally if not |
Rollback rules: by default, only unchecked exceptions (RuntimeException, Error) trigger rollback; checked exceptions do not, unless explicitly configured:
@Transactional(rollbackFor = CustomCheckedException.class)
This default-rollback-on-unchecked-only behavior is frequently misunderstood and a good “gotcha” interview answer to have ready.
Lazy vs Eager Loading
| Lazy | Eager | |
|---|---|---|
| When loaded | On first access of the association (proxy substituted until then) | Immediately, as part of the parent query (typically via JOIN) |
| Default for | @OneToMany, @ManyToMany |
@ManyToOne, @OneToOne |
| Risk | LazyInitializationException if accessed outside an active session/transaction |
Over-fetching — pulling large/unneeded graphs on every load |
@Transactional(readOnly = true)
public OrderDetail getOrderDetail(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
return new OrderDetail(order.getOrderNumber(), order.getItems().size());
// items collection accessed HERE, inside the transaction — safe
}
If getItems() were accessed after the transactional method returns (e.g., in a controller or view layer after the session closes), it throws LazyInitializationException — the classic “Open Session in View” debate stems from teams patching around this by keeping the Hibernate session open through view rendering, which architects generally advise against (it hides N+1 problems and couples persistence lifetime to the web layer).
Architect-level guidance:
- Default to
LAZYeverywhere, including overriding the EAGER defaults on@ManyToOne/@OneToOneexplicitly. - Fetch exactly what’s needed per use case via
JOIN FETCHin JPQL, an explicit@EntityGraph, or DTO projections — rather than relying on a single entity’s static fetch strategy to serve every use case. - Avoid “Open Session in View” as a blanket fix; it trades a visible, fixable exception for an invisible, harder-to-diagnose performance problem.
N+1 Problem
The problem: fetching a parent list, then triggering one additional query per row to lazily load each parent’s association.
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order order : orders) {
order.getCustomer().getName(); // N additional queries, one per order!
}
1 query to fetch orders + N queries to fetch each order’s customer = N+1 queries — a top production performance killer that scales badly as data grows, often invisible in dev/test with small datasets and only surfacing under production load.
Fixes, in order of preference:
1. JOIN FETCH (JPQL) — most explicit and controllable:
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);
2. @EntityGraph — declarative, reusable across multiple query methods:
@EntityGraph(attributePaths = {"customer", "items"})
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusWithGraph(@Param("status") OrderStatus status);
3. Batch fetching — Hibernate-specific, mitigates without full eager loading:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 20
Instead of N individual queries, Hibernate batches lazy loads into chunks (WHERE id IN (...)), turning N+1 into roughly N/20+1 — a pragmatic mitigation when JOIN FETCH isn’t feasible everywhere.
4. DTO projections — sidestep the entity graph entirely for read-only views:
@Query("SELECT new com.acme.dto.OrderListView(o.id, o.orderNumber, c.name) FROM Order o JOIN o.customer c")
List<OrderListView> findAllForListing();
Architect-level detection strategy worth stating in interviews: enable spring.jpa.properties.hibernate.session_factory.statement_inspector or simply turn on show-sql/format_sql in a staging environment and review query counts per request via a tool like p6spy or Hibernate’s statistics (hibernate.generate_statistics: true + SessionFactory.getStatistics()). Catching N+1 in code review by eyeballing entity graphs alone is unreliable — the actual fix process should be query-count-driven, not guesswork, and N+1 detection is increasingly built into APM/observability tooling (e.g., flagging query-count-per-request spikes) for exactly this reason.