Spring Data & Persistence

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/@OneToOne to EAGER, which is the single most common cause of accidental over-fetching and N+1 chains in production codebases.
  • @Version enables optimistic locking — essential for any entity touched by concurrent updates (avoids lost-update anomalies without resorting to pessimistic DB locks).
  • Use mappedBy on 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 LAZY everywhere, including overriding the EAGER defaults on @ManyToOne/@OneToOne explicitly.
  • Fetch exactly what’s needed per use case via JOIN FETCH in 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.