Spring · 07

Spring Data JPA

The data layer is where most production incidents are born: a surprise UPDATE nobody called, a page that fires 201 queries, a LazyInitializationException in the JSON serializer, a lost update under concurrency. This page opens the black box: what the persistence context really tracks, how a repository interface becomes a running proxy, how derived queries are parsed, and how to find and kill the N+1 problem for good. It ends with a plain-Java persistence context you can compile and run.

13
Chapters
N+1
Deep dive
1
Runnable demo
Boot 3
Hibernate 6
01
Chapter One · Why It Exists

From JDBC Boilerplate to Repositories

OrderRepository (proxy) EntityManager / Session Persistence context JDBC + HikariCP PostgreSQL / MySQL

Plain JDBC makes you the object-relational mapper. You write the SQL, open and close resources, walk a ResultSet column by column, and rebuild object graphs by hand. JPA (Jakarta Persistence) is the specification that automates this; Hibernate is the implementation Spring Boot ships by default; Spring Data JPA sits on top and removes even the DAO boilerplate by generating repositories from interfaces.

Before — JDBC by hand (one method, one query)
public List<Order> findByStatus(String status) throws SQLException { String sql = "select id, customer_id, status, total from orders where status = ?"; List<Order> result = new ArrayList<>(); try (Connection con = dataSource.getConnection(); PreparedStatement ps = con.prepareStatement(sql)) { ps.setString(1, status); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Order o = new Order(); o.setId(rs.getLong("id")); o.setCustomerId(rs.getLong("customer_id")); o.setStatus(rs.getString("status")); o.setTotal(rs.getBigDecimal("total")); result.add(o); } } } return result; // and every update needs its own hand-written UPDATE statement }
After — Spring Data JPA
public interface OrderRepository extends JpaRepository<Order, Long> { List<Order> findByStatus(OrderStatus status); // generated at startup, no implementation class } @Service @Transactional public class OrderService { private final OrderRepository orders; public OrderService(OrderRepository orders) { this.orders = orders; } public void markShipped(long id) { Order o = orders.findById(id).orElseThrow(); o.setStatus(OrderStatus.SHIPPED); // no save(), no UPDATE statement written by you } }

That second snippet is the whole point and the whole danger: the UPDATE happens although you never wrote one and never called save(). Understanding why is understanding JPA.

Analogy: JPA is a smart clipboard held for the duration of a transaction. When you load a row, a photocopy (the snapshot) is filed away and the working copy goes onto the clipboard. You scribble on the working copy freely; at flush time the clerk compares each clipboard sheet with its photocopy and writes SQL for the differences. Forget the clipboard exists and every "magic" behaviour of JPA looks like a bug.
LayerWhoResponsibility
SpecificationJakarta Persistence (jakarta.persistence.*)Annotations, EntityManager API, JPQL
ProviderHibernate ORM 6Persistence context, SQL generation, proxies, dirty checking, caches
AbstractionSpring Data JPARepository proxies, derived queries, paging, specifications, auditing
GlueSpring Boot HibernateJpaAutoConfigurationCreates EntityManagerFactory, JpaTransactionManager, scans entities
Pool / driverHikariCP + JDBC driverActual connections and wire protocol
02
Chapter Two · Mental Model

The Persistence Context: Identity Map, Snapshot, Action Queue

A transaction opens an EntityManager and with it a persistence context
▶
The context is a per-transaction (per-EntityManager) map: (entity type, id) → managed instance. Spring binds it to the thread via JpaTransactionManager.
find() / query loads a row
▶
Hibernate builds the entity, puts it in the identity map and stores a snapshot of every column value (loaded state).
Asking for the same id again
▶
Returns the same Java instance straight from the identity map: no SQL. This is the first-level cache and it cannot be disabled.
You change a setter on a managed entity
▶
Nothing happens yet. Hibernate does not intercept setters; it compares later. (Bytecode enhancement can track writes, but default is snapshot comparison.)
Flush: before a query that touches affected tables, on flush(), and at commit
▶
Dirty checking walks every managed entity, diffs it against its snapshot and queues INSERT/UPDATE/DELETE actions, then sends them in a fixed order.
Commit / rollback / close
▶
The context is cleared and every entity becomes detached. A detached entity is just a POJO; touching an uninitialized lazy association now throws LazyInitializationException.
One transaction: SQL only at the edges, change tracking in between
time inside one @Transactional method BEGIN find(1) setTier("PLAT") query(...) return COMMIT SELECT customer SQL UPDATE customer SELECT ... auto flush BEFORE the query runs COMMIT Persistence context (EntityManager) — what Hibernate holds in memory Identity map Customer#1 → @a1b2 Customer#7 → @c3d4 same id = same instance Snapshots (loaded state) Customer#1 → [Asha, GOLD] Customer#7 → [Ravi, SILVER] compared at flush time Action queue UPDATE Customer#1 (tier GOLD → PLAT) filled by flush, then sent
Three facts that explain most JPA surprises
  • Writes are deferred. save() on a new entity may not run INSERT until flush (with IDENTITY ids it must run immediately to learn the key).
  • Reads are cached per transaction. A second findById returns the same object and ignores changes another transaction committed meanwhile.
  • Managed means tracked. Any mutation of a managed entity is persisted at flush whether or not you call save().
03
Chapter Three · Entity States

Transient, Managed, Detached, Removed — and Flush

Every JPA entity instance is in exactly one of four states relative to a persistence context. Nearly every JPA bug is a mismatch between the state you think an object is in and the state it is actually in.

Entity lifecycle — the four states and the calls that move between them
Transientnew Order() Managedtracked, dirty-checked Detachedplain POJO again Removedscheduled for DELETE persist() save() on new tx ends / clear() detach() / close() merge() → returns a NEW managed copy remove() flush: DELETE, then detached find() / queryDB row → managed Spring Data: save(e) = isNew(e) ? persist(e) : merge(e) isNew: id == null (or @Version wrapper == null, or Persistable.isNew())
CallEntity must beWhat it doesReturns
persist(e)transientMakes e itself managed; INSERT queued (immediate for IDENTITY)void (same instance)
merge(e)detached or transientLoads/creates a managed copy, copies state of e onto it. e stays detachedthe managed copy
repo.save(e)anypersist if isNew, else mergethe managed instance: always use the return value
repo.saveAndFlush(e)anysave, then em.flush() immediatelymanaged instance
remove(e)managedSchedules DELETE; cascades REMOVEvoid
detach(e) / clear()managedStops tracking one / all entities. Pending changes are lost unless flushed firstvoid
refresh(e)managedRe-reads the row and overwrites in-memory changesvoid
What flush actually does
Triggered by commit, flush(), or (FlushModeType.AUTO) before a JPQL/criteria query whose tables overlap pending changes.
StepHibernate internalEffect
1Cascade PERSIST/MERGE through managed graphNew children reachable from managed parents become managed
2DefaultFlushEntityEventListener dirty checks each managed entityCompares current property values with the snapshot
3Dirty entities enqueue EntityUpdateActionVersion incremented here if @Version
4Execute ActionQueue in fixed orderinserts → updates → collection changes → deletes
5Snapshots refreshedA second flush with no changes issues zero SQL
Dirty checking in action — no save() anywhere
@Transactional public void upgradeTier(long customerId) { Customer c = customers.findById(customerId).orElseThrow(); // SELECT, snapshot = [Asha, GOLD] c.setTier(Tier.PLATINUM); // only the Java object changes c.setTier(Tier.GOLD); // changed back: now equals the snapshot } // flush at commit: nothing differs, so NO update is issued
⚠ Common bugs & edge cases:
  • Ignoring the return value of save() / merge(). On a detached entity, the object you passed is not managed; the returned copy is. Later changes to your original are silently never persisted.
  • LazyInitializationException: could not initialize proxy - no Session means you touched a lazy association on a detached entity (usually in a controller or serializer after the service transaction ended). Fix by fetching what you need inside the transaction (Chapter 08) and returning DTOs.
  • Flush ordering surprises. Hibernate runs inserts before deletes. Replacing a row that has a unique constraint (delete old, insert new with the same key) in one transaction fails with DataIntegrityViolationException unless you call flush() between the two.
  • Mutating an entity "just for the response". Setting a display field or truncating a string on a managed entity inside a @Transactional method writes it to the database at commit. Map to a DTO first.
  • Huge loops without clear(). Every loaded entity stays in the persistence context and is dirty-checked at every flush: memory and CPU grow quadratically. Process in chunks and flush(); clear();.
04
Chapter Four · Mapping

Ids, Embeddables, Relations and Inheritance

Mapping is where schema decisions meet object decisions. The goal is a model that is correct (right constraints, right ownership of relationships), predictable (no hidden queries) and cheap (batchable ids, small fetch plans). The example below is one coherent domain reused through the rest of this page.

Entity-relationship view and which side owns each foreign key
Customer id PK name tier (enum STRING) address (embedded) orders id PK customer_id FK ◄ owner status, total version, created_at order_line id PK order_id FK ◄ owner sku, qty unit_price 1 * 1 * Order: @ManyToOne(LAZY) Customer customer <-- owning side (has the FK column) Customer: @OneToMany(mappedBy = "customer") List<Order> <-- inverse side (no column, read-mostly) Order: @OneToMany(mappedBy="order", cascade=ALL, orphanRemoval=true) List<OrderLine> <-- aggregate Only the owning side writes the FK. Setting just customer.getOrders().add(o) saves nothing.
Complete aggregate — Order with lines, embedded money, version and auditing base
@MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class AuditedEntity { @CreatedDate @Column(nullable = false, updatable = false) private Instant createdAt; @LastModifiedDate @Column(nullable = false) private Instant updatedAt; public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } } @Embeddable public class Address { private String street; private String city; @Column(length = 10) private String postalCode; protected Address() {} // JPA needs a no-arg constructor public Address(String street, String city, String postalCode) { this.street = street; this.city = city; this.postalCode = postalCode; } } @Entity @Table(name = "customer") public class Customer { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "customer_seq") @SequenceGenerator(name = "customer_seq", sequenceName = "customer_seq", allocationSize = 50) private Long id; @Column(nullable = false) private String name; @Enumerated(EnumType.STRING) @Column(nullable = false) private Tier tier; // never ORDINAL @Embedded private Address address; protected Customer() {} public Customer(String name, Tier tier) { this.name = name; this.tier = tier; } public Long getId() { return id; } // equals/hashCode: see mistake-box below } @Entity @Table(name = "orders") public class Order extends AuditedEntity { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq") @SequenceGenerator(name = "order_seq", sequenceName = "order_seq", allocationSize = 50) private Long id; @ManyToOne(fetch = FetchType.LAZY, optional = false) // default is EAGER: always override @JoinColumn(name = "customer_id") private Customer customer; @Enumerated(EnumType.STRING) @Column(nullable = false) private OrderStatus status = OrderStatus.NEW; @Column(nullable = false, precision = 12, scale = 2) private BigDecimal total = BigDecimal.ZERO; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderLine> lines = new ArrayList<>(); @Version private long version; // optimistic locking, Chapter 09 protected Order() {} public Order(Customer customer) { this.customer = customer; } // keep BOTH sides of the bidirectional association in sync in one place public void addLine(String sku, int qty, BigDecimal unitPrice) { OrderLine line = new OrderLine(this, sku, qty, unitPrice); lines.add(line); total = total.add(unitPrice.multiply(BigDecimal.valueOf(qty))); } public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); } } @Entity @Table(name = "order_line") public class OrderLine { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "line_seq") @SequenceGenerator(name = "line_seq", sequenceName = "line_seq", allocationSize = 50) private Long id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "order_id") private Order order; private String sku; private int qty; @Column(precision = 12, scale = 2) private BigDecimal unitPrice; protected OrderLine() {} OrderLine(Order order, String sku, int qty, BigDecimal unitPrice) { this.order = order; this.sku = sku; this.qty = qty; this.unitPrice = unitPrice; } }
RelationshipDefault fetchVerdict
@ManyToOneEAGEROverride to LAZY
@OneToOneEAGEROverride to LAZY (owning side); the inverse side cannot really be lazy without bytecode enhancement
@OneToManyLAZYKeep lazy and fetch explicitly per use case
@ManyToManyLAZYAvoid — model the join table as an entity so it can carry columns and be queried
@ElementCollectionLAZYOK for small value lists; every change may delete and re-insert the whole collection
Id generation strategies
The id strategy decides whether Hibernate can batch inserts.
StrategyHow the id is obtainedBatchingNotes
IDENTITYDB auto-increment; id only known after the INSERT executesDisabledForces an immediate INSERT on persist; simple but the slowest for bulk writes
SEQUENCEHibernate reads the next value from a DB sequence; with allocationSize=50 (pooled optimizer) one call hands out 50 idsWorksHibernate 6 default for AUTO on databases with sequences; sequence INCREMENT BY must equal allocationSize
UUID (@GeneratedValue on a UUID field)Generated in the JVMWorksNo round trip; random v4 ids fragment B-tree indexes, consider time-ordered ids
AssignedYou set the id (natural/business key)Workssave() then issues a SELECT-then-merge unless the entity implements Persistable or has a @Version wrapper
Inheritance strategyTablesPolymorphic queryTrade-off
@MappedSuperclassone per concrete classnot polymorphic (superclass is not an entity)Best for shared columns like audit fields; no query on the base type
SINGLE_TABLE (default)one table + discriminator columnone SELECTFastest; subclass columns must be nullable
JOINEDone table per class, joined by PKJOINs per hierarchy levelNormalised and strict; slower reads
TABLE_PER_CLASSone full table per concrete classUNION of all tablesRarely worth it; poor query plans
⚠ Common bugs & edge cases:
  • Enum mapped with the default ORDINAL. Inserting a constant in the middle of the enum silently corrupts every existing row. Always @Enumerated(EnumType.STRING).
  • Wrong equals/hashCode. Using all fields breaks when a lazy proxy is compared; using a generated id breaks in HashSet because the id changes on persist. A safe pattern: equals via other instanceof Order o && id != null && id.equals(o.getId()) (call the getter so proxies work) and hashCode returning getClass().hashCode().
  • Mutating only the inverse side. customer.getOrders().add(order) without order.setCustomer(customer) writes a NULL (or violates NOT NULL) in customer_id. Provide helper methods that update both sides.
  • Final classes / final methods / records as entities. Hibernate must subclass the entity for lazy proxies and needs a no-arg constructor (protected is fine). Java records cannot be entities (Hibernate 6.2+ can use a record as an @Embeddable).
  • ddl-auto=update in production. It never drops or renames, can lock tables, and hides drift. Use Flyway or Liquibase and spring.jpa.hibernate.ddl-auto=validate.
  • Using LocalDateTime for instants. It carries no zone; prefer Instant (or OffsetDateTime) and store UTC.
05
Chapter Five · Repositories

How an Interface Becomes a Running Repository

You write an interface; at startup Spring Data creates a JDK dynamic proxy implementing it. There is no generated source file. Understanding the pieces tells you why a typo in a method name fails the application at startup, why repository methods are already transactional, and where persistence exceptions get translated.

What orderRepository.findByStatus(NEW) really goes through
Servicerepo.findByStatus(NEW) JDK proxy of OrderRepository (JpaRepositoryFactory → ProxyFactory) TransactionInterceptorreadOnly=true by default ExceptionTranslationPersistenceException → DAE QueryExecutor…Interceptorruns the RepositoryQuery PartTreeJpaQueryparsed from findByStatus at startup SimpleJpaRepositorysave, findById, findAll... (the target) Derived/@Query methods go to a RepositoryQuery; CRUD methods go to SimpleJpaRepository. Both end in EntityManager. SimpleJpaRepository is annotated @Transactional(readOnly = true); its write methods re-declare @Transactional.
Startup stepClassWhat happens
1@EnableJpaRepositories (Boot auto-config) → JpaRepositoriesRegistrarClasspath scan from the @SpringBootApplication package for interfaces extending Repository
2JpaRepositoryFactoryBean per interfaceA FactoryBean; one bean definition per repository
3JpaRepositoryFactory.getRepository()Builds the proxy, picks SimpleJpaRepository as target, registers interceptors
4QueryLookupStrategy (default CREATE_IF_NOT_FOUND)For each non-CRUD method: use @Query/named query if present, else derive from the method name
5PartTree parses the namefindByStatusAndTotalGreaterThanOrderByCreatedAtDesc → subject + predicates + sort; invalid property → PropertyReferenceException: No property ... found for type ... at startup
The hierarchy and what each level adds
Repository<T, ID> // marker only CrudRepository<T, ID> // save, saveAll, findById, existsById, findAll, count, deleteById, delete ListCrudRepository<T, ID> // same, but findAll returns List instead of Iterable (Spring Data 3) PagingAndSortingRepository<T, ID> // findAll(Sort), findAll(Pageable); does NOT extend CrudRepository since 3.0 JpaRepository<T, ID> // = ListCrudRepository + ListPagingAndSortingRepository + flush, saveAndFlush, // deleteAllInBatch, getReferenceById, Query-by-Example
Choose the narrowest interface you need
// Expose only what the domain should do: no deleteAll() lying around interface ReadOnlyCustomers extends Repository<Customer, Long> { Optional<Customer> findById(Long id); List<Customer> findByTier(Tier tier); } // Share a base without exposing it: @NoRepositoryBean @NoRepositoryBean interface BaseRepository<T, ID> extends JpaRepository<T, ID> {}
MethodSQLReturnsUse when
findById(id)SELECT now (or cache hit)Optional<T> real entityYou need the data
getReferenceById(id)none until a property is readlazy proxySetting a FK: order.setCustomer(customers.getReferenceById(id)) avoids a SELECT
existsById(id)select count(...)booleanCheap existence check
deleteById(id)SELECT then DELETEvoidLoads first so cascades and lifecycle callbacks work
deleteAllInBatch()one delete fromvoidBulk, bypasses persistence context and cascades
⚠ Common bugs & edge cases:
  • PropertyReferenceException: No property 'statuss' found for type 'Order' on startup: a typo in a derived-query method name. This is the good kind of failure: it happens at boot, not in production.
  • NoSuchBeanDefinitionException for a repository in a modular project: repositories outside the scanned base package. Add @EnableJpaRepositories(basePackages = ...) and @EntityScan.
  • Calling save() in a loop for 10 000 entities with IDENTITY ids: one INSERT round trip each, plus one SimpleJpaRepository.save transaction each if you are not inside a service transaction. Wrap in one transaction, use sequences, enable batching (Chapter 10).
  • Expecting deleteById on a missing id to throw. Since Spring Data 3 it silently does nothing; earlier versions threw EmptyResultDataAccessException.
  • Repository method inherits readOnly=true. A custom @Query with @Modifying needs its own @Transactional (or a calling service transaction) or it fails with TransactionRequiredException.
06
Chapter Six · Queries

Derived Queries, @Query, Projections and Specifications

Spring Data gives four ways to say which rows and three ways to say which shape. Pick by how static the query is and how much of the entity you really need.

StyleExampleValidatedWhen to use
Derived namefindByStatusAndTotalGreaterThan(s, t)startup (parsed)One to three simple predicates
@Query JPQL@Query("select o from Order o where o.customer.id = :cid")startup (syntax checked)Joins, fetch joins, aggregates, readable intent
@Query native@Query(value = "...", nativeQuery = true)runtime onlyDB-specific SQL: window functions, SKIP LOCKED, JSON operators
Specificationrepo.findAll(spec, pageable)compile time (Criteria API)Optional filters assembled at runtime (search screens)
Derived queries and the keywords PartTree understands
List<Order> findByStatus(OrderStatus status); List<Order> findByCustomerNameIgnoreCase(String name); // traverses customer.name -> JOIN List<Order> findByStatusAndTotalGreaterThanEqual(OrderStatus s, BigDecimal min); List<Order> findByCreatedAtBetween(Instant from, Instant to); List<Order> findByStatusInOrderByCreatedAtDesc(Collection<OrderStatus> statuses); Optional<Order> findFirstByCustomerIdOrderByCreatedAtDesc(Long customerId); // Top/First limit boolean existsByCustomerIdAndStatus(Long customerId, OrderStatus status); long countByStatus(OrderStatus status); long deleteByStatus(OrderStatus status); // SELECT then delete each entity
@Query with named parameters, fetch join and a modifying statement
public interface OrderRepository extends JpaRepository<Order, Long> { @Query("select o from Order o join fetch o.customer where o.status = :status") List<Order> findWithCustomer(@Param("status") OrderStatus status); @Query("select o.status as status, count(o) as cnt from Order o group by o.status") List<StatusCount> countPerStatus(); // interface projection @Modifying(flushAutomatically = true, clearAutomatically = true) @Transactional @Query("update Order o set o.status = :to where o.status = :from and o.createdAt < :before") int expire(@Param("from") OrderStatus from, @Param("to") OrderStatus to, @Param("before") Instant before); }

A bulk @Modifying query runs straight against the database and does not update managed entities. Without clearAutomatically = true the persistence context keeps serving the stale status for the rest of the transaction; without flushAutomatically = true pending changes might not be visible to the statement.

Projections: fetch only the shape you need
Loading a full entity to render two columns wastes SQL width, memory and dirty-checking time.
KindDeclarationSQL selectsCaveat
Closed interfaceinterface OrderRow { Long getId(); BigDecimal getTotal(); }only id, totalNested getters on associations may select more
Open interface@Value("#{target.id + target.status}") on a getterwhole entitySpEL needs the entity; no column pruning
Record / class DTOrecord OrderSummary(Long id, BigDecimal total)only constructor argsNeeds a matching constructor; can be used with JPQL select new ...
Dynamic<T> List<T> findByStatus(OrderStatus s, Class<T> type)depends on typeSame method serves entity and DTOs
Record projection, constructor expression and dynamic projection
public record OrderSummary(Long id, BigDecimal total, String customerName) {} public interface OrderRepository extends JpaRepository<Order, Long> { // derived query returning a DTO record: Spring Data builds the select list from the record List<OrderSummary> findByStatus(OrderStatus status); // explicit JPQL constructor expression (fully qualified class name) @Query("select new com.acme.shop.OrderSummary(o.id, o.total, c.name) from Order o join o.customer c where o.total > :min") List<OrderSummary> expensive(@Param("min") BigDecimal min); <T> List<T> findByCustomerId(Long customerId, Class<T> type); // caller picks the shape }
Specifications for dynamic filters
A search form with eight optional fields should not need 256 repository methods.
JpaSpecificationExecutor + small composable predicates
public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {} public final class OrderSpecs { private OrderSpecs() {} public static Specification<Order> hasStatus(OrderStatus s) { return (root, query, cb) -> s == null ? null : cb.equal(root.get("status"), s); // null = no predicate } public static Specification<Order> totalAtLeast(BigDecimal min) { return (root, query, cb) -> min == null ? null : cb.greaterThanOrEqualTo(root.get("total"), min); } public static Specification<Order> customerNameLike(String q) { return (root, query, cb) -> q == null ? null : cb.like(cb.lower(root.join("customer").get("name")), "%" + q.toLowerCase() + "%"); } } // usage Specification<Order> spec = Specification.allOf( OrderSpecs.hasStatus(filter.status()), OrderSpecs.totalAtLeast(filter.min()), OrderSpecs.customerNameLike(filter.q())); Page<Order> page = orders.findAll(spec, PageRequest.of(0, 20, Sort.by("createdAt").descending()));
⚠ Common bugs & edge cases:
  • Interface projection with an open @Value getter silently loads the full entity, defeating the projection. Use a closed projection or a record.
  • Native query + Pageable without countQuery. Spring Data cannot rewrite native SQL into a count and fails at runtime; supply countQuery = "select count(*) ...".
  • @Query with LIKE %:q% concatenation in JPQL: write like concat('%', :q, '%') or pass the pattern from Java; never build JPQL by string concatenation (injection risk).
  • Stale data after a bulk update: test shows the old status although the row changed. The persistence context is not aware of @Modifying queries: use clearAutomatically or reload.
  • Derived query too clever: findByCustomerAddressCityAndLinesSkuIn... is unreadable and joins unpredictably. Past three predicates switch to @Query.
  • InvalidDataAccessApiUsageException: Parameter value did not match expected type: passing a String to an enum or Long parameter. Check the parameter type against the attribute type.
07
Chapter Seven · Paging & Auditing

Page vs Slice vs Keyset, and Automatic Audit Columns

Pageable turns a query into a bounded one; auditing stamps who and when without a line of service code. Both look trivial and both have sharp edges at scale.

Paging, slicing and sorting
Page<Order> findByStatus(OrderStatus status, Pageable pageable); // data query + count(*) query Slice<Order> findByCustomerId(Long id, Pageable pageable); // data query only, fetches size+1 rows to know hasNext List<Order> findTop10ByStatusOrderByCreatedAtDesc(OrderStatus s); // fixed limit, no Pageable // controller @GetMapping("/orders") Page<OrderSummary> list(@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { return orders.findByStatus(OrderStatus.NEW, pageable).map(OrderSummary::from); }
Return typeQueriesKnows total?Cost
Page<T>page + select countyesThe count scans the whole filtered set; slow on big tables
Slice<T>page of size+1no, only hasNextCheap; right for "load more" and infinite scroll
List<T> + limitone querynoCheapest
Window<T> (Spring Data 3.1+)keyset scroll via ScrollPosition.keyset()noConstant cost per page; needs a stable unique sort key
What OFFSET really costs (SQL view)
-- page 5000 of size 20: the database still walks and discards 100 000 rows select * from orders order by created_at desc limit 20 offset 100000; -- keyset: seek straight to the cursor using the index on (created_at, id) select * from orders where (created_at, id) < (:lastCreatedAt, :lastId) order by created_at desc, id desc limit 20;
Auditing — who and when, automatically
@Configuration @EnableJpaAuditing(auditorAwareRef = "auditorProvider") class JpaConfig { @Bean AuditorAware<String> auditorProvider() { return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) .filter(Authentication::isAuthenticated) .map(Authentication::getName); } } @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class Auditable { @CreatedDate @Column(updatable = false) private Instant createdAt; @LastModifiedDate private Instant updatedAt; @CreatedBy @Column(updatable = false) private String createdBy; @LastModifiedBy private String updatedBy; }

AuditingEntityListener is a JPA lifecycle callback (@PrePersist / @PreUpdate). It runs during flush, only for entities that are actually dirty, and not at all for bulk @Modifying queries.

⚠ Common bugs & edge cases:
  • Sorting by a non-unique column with offset paging (e.g. createdAt) returns duplicates or skips rows between pages when values tie. Always append a unique tiebreaker such as id.
  • Page + join fetch of a collection. Hibernate logs firstResult/maxResults specified with collection fetch; applying in memory: it loads every row and slices in Java. Page the parent ids first, then fetch children (Chapter 08).
  • Client-controlled size. ?size=1000000 is a denial-of-service. Spring MVC caps at 2000 by default (spring.data.web.pageable.max-page-size); set it to something sane.
  • Auditing columns stay null because @EnableJpaAuditing is missing, the entity lacks @EntityListeners, or the update was a bulk query.
  • Sort property injection: ?sort=password maps straight to an attribute. Whitelist sortable fields instead of passing the client Sort through.
08
Chapter Eight · Fetching

Lazy Loading, the N+1 Problem and Five Fixes

The N+1 problem is a fetch-plan problem: you ask for N parents with one query, then touch a lazy association on each, and Hibernate fires one extra query per parent. It is invisible in unit tests with three rows and catastrophic with three thousand. This chapter shows how to see it, why it happens and the five ways to remove it, with the trade-off of each.

One query for the list, then one per row: how a loop turns into hundreds of statements
Naive: orderRepo.findAll() then order.getCustomer().getName() in a loop 1: orders cust #1 cust #2 cust #3 cust #4 cust #5 ... N N = 5 distinct customers → 1 + 5 = 6 statements (1 + 1 000 customers = 1 001) Fixed: join fetch / EntityGraph, or batch fetching 1: orders JOIN customer (one wide row each) or: 1 orders + 1 customer WHERE id IN (...50) Statements per request vs rows: 1 + N (N=200) join fetch batch size 50 (4 batches) 201 1 5
The loop that causes it — innocent-looking service and mapper
@Transactional(readOnly = true) public List<OrderDto> recent() { return orders.findAll(PageRequest.of(0, 200)).stream() // 1 query: select * from orders ... .map(o -> new OrderDto(o.getId(), o.getCustomer().getName(), // lazy proxy: select ... from customer where id=? (per distinct id) o.getLines().size())) // lazy collection: select ... from order_line where order_id=? (per order) .toList(); }
Hibernate SQL log for that one request (200 orders, 150 distinct customers)
select o1_0.id, o1_0.customer_id, ... from orders o1_0 offset ? rows fetch first ? rows only -- 1 select c1_0.id, c1_0.name, c1_0.tier from customer c1_0 where c1_0.id=? -- x150 select l1_0.order_id, l1_0.id, l1_0.sku ... from order_line l1_0 where l1_0.order_id=? -- x200 # 351 statements, 1 request. The persistence context cached repeated customers, otherwise 401.
How lazy loading works (and why the exception exists)
A lazy @ManyToOne holds a generated subclass proxy (ByteBuddy); a lazy collection holds a PersistentBag wrapper.
StepWhat happens
Load order #5Hibernate sets customer to a proxy that only knows the id (customer_id column is already in the row)
o.getCustomer().getId()Answered from the proxy: no SQL (id access is special-cased for property access / getters)
o.getCustomer().getName()Proxy interceptor asks the persistence context for Customer#7; cache miss → SELECT; target initialised
Same proxy, session closedInitialisation needs a session → LazyInitializationException: could not initialize proxy [Customer#7] - no Session
o.getLines().size()PersistentBag.initialize() → SELECT ... where order_id=?
Detecting N+1 before production
Make query counts visible in dev and assert on them in tests.
application-dev.properties
# SQL as a logger (not spring.jpa.show-sql, which uses System.out) logging.level.org.hibernate.SQL=DEBUG # bound parameter values (Hibernate 6 category) logging.level.org.hibernate.orm.jdbc.bind=TRACE # per-session statistics logged when the session closes: number of JDBC statements, entities loaded... spring.jpa.properties.hibernate.generate_statistics=true logging.level.org.hibernate.stat=DEBUG
Guard rail in a test: fail if a use case issues too many statements
@DataJpaTest class OrderQueryCountTest { @Autowired EntityManagerFactory emf; @Autowired OrderService service; @Test void recentOrdersUsesAtMostThreeStatements() { Statistics stats = emf.unwrap(SessionFactory.class).getStatistics(); stats.setStatisticsEnabled(true); stats.clear(); service.recent(); assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(3); } }

Fix 1 — fetch join. Tell JPQL to select the association in the same SQL. Best when you always need the association for this use case.

Join fetch and @EntityGraph
public interface OrderRepository extends JpaRepository<Order, Long> { @Query("select o from Order o join fetch o.customer where o.status = :s") List<Order> withCustomer(@Param("s") OrderStatus s); // many-to-one: safe, no row multiplication @EntityGraph(attributePaths = {"customer", "lines"}) // declarative, works with derived queries List<Order> findByStatus(OrderStatus status); @EntityGraph(attributePaths = "customer") Page<Order> findAllByStatus(OrderStatus status, Pageable pageable); // collection NOT in the graph: paging stays in SQL }

Fix 2 — batch fetching. Keep the lazy mapping but make initialisation load many proxies per statement. One setting often removes most N+1 in an existing codebase.

Global batch fetching (Hibernate 6)
spring.jpa.properties.hibernate.default_batch_fetch_size=50 # -> select ... from customer where id in (?, ?, ?, ... 50 ids) instead of 50 separate selects # per association alternative: @BatchSize(size = 50) on the entity or collection

Fix 3 — DTO projection. If the screen needs orderId, customerName, lineCount, ask the database for exactly that.

One query, no entities, nothing to lazily load
@Query(""" select new com.acme.shop.OrderRow(o.id, c.name, size(o.lines), o.total) from Order o join o.customer c where o.status = :status """) List<OrderRow> rows(@Param("status") OrderStatus status);

Fix 4 — paging a collection graph in two steps. You cannot page over a join fetch of a collection without in-memory pagination, so fetch ids first and the graph second.

Two-query pagination for collection fetches
@Query("select o.id from Order o where o.status = :s order by o.createdAt desc") Page<Long> pageIds(@Param("s") OrderStatus s, Pageable pageable); // SQL pagination on ids only @Query("select distinct o from Order o left join fetch o.lines where o.id in :ids") List<Order> withLines(@Param("ids") Collection<Long> ids); // one query for just this page
FixStatementsProsCons
join fetch / @EntityGraph1Explicit per use case, simplest SQLCollection fetch multiplies rows and breaks paging; two bag fetches in one query throw MultipleBagFetchException
Batch fetching (default_batch_fetch_size)1 + ceil(N/batch)Global, no code change, works with pagingStill several queries; fetches whatever is touched
DTO projection1Smallest payload, read-only by designNot entities: no dirty checking; one query per screen shape
Two-query paging2Correct pagination with collectionsMore code
@Fetch(FetchMode.SUBSELECT)2Loads all collections of the previous result in one queryRe-runs the original query as a subselect; unsuitable for huge results
Open Session In View (OSIV)
Spring Boot enables spring.jpa.open-in-view=true by default.

With OSIV an OpenEntityManagerInViewInterceptor keeps the EntityManager open until the HTTP response is written, so lazy loading in a controller or the Jackson serializer "just works". At startup Boot warns: spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. The cost: N+1 hides in the web layer, and a database connection can be held while slow serialization and downstream calls run. Set spring.jpa.open-in-view=false, load everything the use case needs inside the service transaction and return DTOs: the LazyInitializationException you then get is a bug report, not a nuisance.

⚠ Common bugs & edge cases:
  • MultipleBagFetchException: cannot simultaneously fetch multiple bags: two List collections in one fetch graph. Switch to Set, add @OrderColumn, or fetch one collection per query (the batch-size setting then loads the second).
  • Jackson serializes an entity graph and triggers lazy loading (or LazyInitializationException with OSIV off, or infinite recursion on bidirectional links). Never return entities from controllers.
  • EAGER "fix". Switching the mapping to EAGER makes every query on that entity load the association, even for use cases that do not need it, and it still issues N+1 selects for JPQL queries. Fix the query, not the mapping.
  • @Transactional on a private/self-invoked method so the lazy access happens outside any transaction (see the transactions page for the proxy rules).
  • size() on a lazy collection just to count. It loads every child. Use a count query (countByOrderId) or @Formula/projection with size(o.lines).
  • Distinct and join fetch with paging in Hibernate 6: duplicates of the parent are already de-duplicated in memory by the persistence context; distinct is no longer needed for that purpose but still sent to SQL, so you may skip it for plain entity graphs.
09
Chapter Nine · Concurrency

Optimistic and Pessimistic Locking

Two requests loading the same row, editing it and saving give the classic lost update: the second write overwrites the first without ever seeing it. Spring Data JPA offers two defences. Optimistic locking detects the conflict at write time; pessimistic locking prevents it up front with database locks.

Optimistic locking: the version column is the compare-and-swap
User 1 User 2 Row in DB read: bal=100 v=0 read: bal=100 v=0 UPDATE ... bal=70, v=1WHERE id=? AND version=0 UPDATE ... bal=50, v=1WHERE id=? AND version=0 bal=70, v=1 (1 row) 0 rows updated User 2 gets ObjectOptimisticLockingFailureException: reload and retry, or tell the user
@Version and handling the conflict
@Entity public class Account { @Id @GeneratedValue private Long id; private BigDecimal balance; @Version private long version; // Hibernate adds "and version=?" and "version=version+1" } @Service public class AccountService { @Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3) // spring-retry, needs @EnableRetry @Transactional public void withdraw(long id, BigDecimal amount) { Account a = accounts.findById(id).orElseThrow(); a.setBalance(a.getBalance().subtract(amount)); // flush at commit: update ... where id=? and version=? } }
Optimistic (@Version)Pessimistic (PESSIMISTIC_WRITE)
MechanismCompare-and-swap on a version columnSELECT ... FOR UPDATE row lock
Conflict outcomeException at flush/commit, retrySecond transaction waits (or fails after timeout)
Best forLow contention, long user think time, web formsHigh contention on hot rows (inventory, counters), short transactions
RiskRetry storms under heavy contentionDeadlocks, lock waits, connection pool exhaustion
Spring exceptionObjectOptimisticLockingFailureExceptionPessimisticLockingFailureException (CannotAcquireLockException, LockTimeoutException)
Pessimistic lock on a repository method, with a timeout and a work-queue pattern
public interface InventoryRepository extends JpaRepository<Inventory, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) // ms; vendor/DB dependent @Query("select i from Inventory i where i.sku = :sku") Optional<Inventory> lockBySku(@Param("sku") String sku); // select ... for update } // work queue: many workers each claim different rows without blocking each other @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2")) // -2 = SKIP LOCKED (Hibernate) List<Job> findTop10ByStatusOrderByIdAsc(JobStatus status);
⚠ Common bugs & edge cases:
  • Lock requested outside a transaction: TransactionRequiredException: no transaction is in progress. Locks live as long as the transaction; call from a @Transactional service.
  • Swallowing the optimistic failure. It is thrown at flush, often at commit, after your method has returned: a try/catch inside the method never sees it. Catch it in the caller of the transactional proxy (or use retry).
  • Version column missing on the table or entity detached for days: merging a stale detached entity throws on purpose. That is the feature working.
  • Two locks, two orders → deadlock. Always lock rows in a consistent order (e.g. by id). The database aborts one transaction; the application sees DeadlockLoserDataAccessException. Retry it.
  • Bulk updates bypass @Version. update Account a set ... does not bump the version unless you write a.version = a.version + 1 or use the versioned keyword in HQL.
10
Chapter Ten · Writes at Scale

Batching, Bulk Operations and Keeping the Context Small

Reads get the attention; bulk writes quietly hurt. The defaults are safe but one-row-at-a-time. A handful of settings and habits changes a 60 second import into a 3 second one.

Enable JDBC batching (Hibernate settings under spring.jpa.properties)
spring.jpa.properties.hibernate.jdbc.batch_size=50 spring.jpa.properties.hibernate.order_inserts=true spring.jpa.properties.hibernate.order_updates=true spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true # PostgreSQL driver: rewrite batches into multi-row inserts spring.datasource.url=jdbc:postgresql://db/shop?reWriteBatchedInserts=true # MySQL driver equivalent: rewriteBatchedStatements=true
Condition for batchingWhy
Id strategy is not IDENTITYWith IDENTITY Hibernate must execute each INSERT to read the generated key, so statements cannot be grouped
jdbc.batch_size > 0Default is unset: every statement is its own round trip
order_inserts / order_updatesGroups statements by entity type so interleaved parents and children do not break the batch into size-1 pieces
Same transaction, same flushA flush boundary (a JPQL query in the middle) ends the current batch
Chunked import: flush and clear to keep the persistence context small
@Transactional public void importCustomers(Iterable<CustomerRow> rows) { int n = 0; for (CustomerRow r : rows) { em.persist(new Customer(r.name(), r.tier())); if (++n % 50 == 0) { // same as jdbc.batch_size em.flush(); // send the batch em.clear(); // forget the 50 managed entities: memory stays flat } } }
NeedApproachNote
Insert 100k rowssequence ids + batching + chunked flush/clearOr JDBC JdbcTemplate.batchUpdate when you need raw speed
Update one column for many rows@Modifying JPQL/native, then clearOne statement; bypasses callbacks, auditing and @Version
Delete manydeleteAllInBatch() / bulk delete fromdeleteAll() loads each entity and deletes one by one
Read-only report@Transactional(readOnly = true) + DTO projection or Stream<T>readOnly sets Hibernate flush mode MANUAL: no dirty checking at the end
Pure speed read of millionsStreaming query inside a transaction, or keyset pagingStream results must be closed (try-with-resources)
⚠ Common bugs & edge cases:
  • Batch size set but still one INSERT per row in the log: the entity uses GenerationType.IDENTITY, or order_inserts is off while cascading parents and children interleave.
  • Second-level cache bought as a fix. The L2 cache (@Cacheable entities) does not cure N+1 or slow queries, adds invalidation bugs in clusters and is off by default. Measure first.
  • Large IN (...) lists with thousands of ids exceed driver/database parameter limits (e.g. 32 767 on PostgreSQL) or ruin plan caching. Chunk to a few hundred.
  • Long import in one huge transaction holds locks and grows the undo/WAL. Commit per chunk using a separate transactional bean or TransactionTemplate.
  • Connection leak with Stream return types: forgetting to close the stream keeps the cursor and connection until the transaction ends. Always try-with-resources.
11
Chapter Eleven · Build It Yourself

A Persistence Context in 100 Lines of Plain Java

The fastest way to stop fearing the persistence context is to build a toy one. The program below has no Spring and no Hibernate: a fake database that logs every SQL string, an identity map, a snapshot per loaded entity, flush() dirty checking, a Lazy<T> holder that behaves like a Hibernate proxy, and the same list query run twice, naively and with a join fetch. Compile it with javac MiniPersistenceContext.java and run java MiniPersistenceContext.

MiniPersistenceContext.java (complete, standard library only)
import java.util.*; import java.util.function.Supplier; public class MiniPersistenceContext { // ---- a fake database that records every SQL statement ---- static class Db { final Map<Long, String[]> customers = new HashMap<>(); // id -> {name, tier} final Map<Long, long[]> orders = new LinkedHashMap<>(); // id -> {customerId, totalCents} final List<String> log = new ArrayList<>(); void sql(String s) { log.add(s); System.out.println(" SQL> " + s); } } static class Customer { long id; String name, tier; } static class Order { long id; long total; Lazy<Customer> customer; } // lazy reference: first get() hits the database (what a Hibernate proxy does) static class Lazy<T> { private final Supplier<T> loader; private T value; private boolean loaded; Lazy(Supplier<T> l) { loader = l; } T get() { if (!loaded) { value = loader.get(); loaded = true; } return value; } } // ---- the persistence context: identity map + snapshots + flush ---- static class Session { final Db db; final Map<String, Object> identityMap = new HashMap<>(); // first-level cache final Map<String, String> snapshot = new HashMap<>(); // loaded state per entity Session(Db db) { this.db = db; } Customer findCustomer(long id) { String key = "Customer#" + id; if (identityMap.containsKey(key)) { System.out.println(" (cache hit " + key + ", no SQL)"); return (Customer) identityMap.get(key); } db.sql("select * from customer where id = " + id); String[] row = db.customers.get(id); Customer c = new Customer(); c.id = id; c.name = row[0]; c.tier = row[1]; identityMap.put(key, c); snapshot.put(key, state(c)); return c; } List<Order> findAllOrders(boolean joinFetch) { List<Order> out = new ArrayList<>(); if (joinFetch) db.sql("select o.*, c.* from orders o join customer c on c.id = o.customer_id"); else db.sql("select * from orders"); for (Map.Entry<Long, long[]> e : db.orders.entrySet()) { Order o = new Order(); o.id = e.getKey(); o.total = e.getValue()[1]; long cid = e.getValue()[0]; if (joinFetch) { String[] row = db.customers.get(cid); String key = "Customer#" + cid; Customer c = (Customer) identityMap.computeIfAbsent(key, k -> { Customer n = new Customer(); n.id = cid; n.name = row[0]; n.tier = row[1]; snapshot.put(k, state(n)); return n; }); o.customer = new Lazy<>(() -> c); } else { o.customer = new Lazy<>(() -> findCustomer(cid)); // proxy, not loaded yet } out.add(o); } return out; } static String state(Customer c) { return c.name + "|" + c.tier; } // dirty checking: compare current state with the loaded snapshot void flush() { for (Map.Entry<String, Object> e : identityMap.entrySet()) { Customer c = (Customer) e.getValue(); if (!state(c).equals(snapshot.get(e.getKey()))) { db.sql("update customer set name = '" + c.name + "', tier = '" + c.tier + "' where id = " + c.id); db.customers.put(c.id, new String[]{c.name, c.tier}); snapshot.put(e.getKey(), state(c)); } } } } static Db seed() { Db db = new Db(); db.customers.put(1L, new String[]{"Asha", "GOLD"}); db.customers.put(2L, new String[]{"Ravi", "SILVER"}); db.customers.put(3L, new String[]{"Meera", "GOLD"}); long id = 100; for (long cid : new long[]{1, 2, 3, 1, 2}) db.orders.put(id++, new long[]{cid, 5000 + id}); return db; } public static void main(String[] args) { System.out.println("== 1. identity map: second find() never reaches the database"); Session s = new Session(seed()); Customer a = s.findCustomer(1); Customer b = s.findCustomer(1); System.out.println(" same instance? " + (a == b)); System.out.println("== 2. dirty checking: no save() call, flush() still writes"); a.tier = "PLATINUM"; s.flush(); s.flush(); // second flush: snapshot is up to date, nothing to write System.out.println("== 3. N+1: 5 orders, lazy customer"); Session s2 = new Session(seed()); int before = s2.db.log.size(); for (Order o : s2.findAllOrders(false)) System.out.println(" order " + o.id + " -> " + o.customer.get().name); System.out.println(" statements: " + (s2.db.log.size() - before)); System.out.println("== 4. join fetch: same loop"); Session s3 = new Session(seed()); before = s3.db.log.size(); for (Order o : s3.findAllOrders(true)) System.out.println(" order " + o.id + " -> " + o.customer.get().name); System.out.println(" statements: " + (s3.db.log.size() - before)); } }
Real output
== 1. identity map: second find() never reaches the database SQL> select * from customer where id = 1 (cache hit Customer#1, no SQL) same instance? true == 2. dirty checking: no save() call, flush() still writes SQL> update customer set name = 'Asha', tier = 'PLATINUM' where id = 1 == 3. N+1: 5 orders, lazy customer SQL> select * from orders SQL> select * from customer where id = 1 order 100 -> Asha SQL> select * from customer where id = 2 order 101 -> Ravi SQL> select * from customer where id = 3 order 102 -> Meera (cache hit Customer#1, no SQL) order 103 -> Asha (cache hit Customer#2, no SQL) order 104 -> Ravi statements: 4 == 4. join fetch: same loop SQL> select o.*, c.* from orders o join customer c on c.id = o.customer_id order 100 -> Asha order 101 -> Ravi order 102 -> Meera order 103 -> Asha order 104 -> Ravi statements: 1
Section 1: second findCustomer(1)
▶
Printed "cache hit" with no SQL and a == b is true: that is the identity map and the first-level cache.
Section 2: a.tier = "PLATINUM" then flush()
▶
The snapshot differs from the current state, so exactly one update is emitted; the second flush is silent because the snapshot was refreshed.
Section 3: five orders, lazy customer
▶
Four statements, not six: 1 for the list + one per distinct customer (3). Customers 1 and 2 were served from the identity map the second time: the persistence context softens N+1 but does not remove it.
Section 4: join fetch
▶
One statement. The same loop, the same output, a fetch plan decided by the query instead of by lazy access.
Extend it
  • Add an Action Queue: collect INSERT/UPDATE/DELETE in lists and flush them inserts-first to reproduce the delete-then-insert unique-key bug.
  • Add clear() and show LazyInitializationException by making Lazy.get() throw when the session is closed.
  • Count statements per "request" and fail if more than N: the same idea as the Hibernate Statistics test guard.
12
Chapter Twelve · Interview Angles

Questions That Come Up, With the Follow-Up Trap

Short answers that survive follow-up questions. Each ends with the trap interviewers usually probe next.

  • What is the persistence context and why does an unchanged save() not matter?
    A per-transaction identity map plus snapshots of loaded state. Managed entities are dirty-checked at flush, so changes are written without save(). Trap: the context is per EntityManager, i.e. per transaction in Spring; outside a transaction every call gets a fresh one.
  • persist vs merge vs Spring Data save?
    persist makes the instance itself managed (transient only); merge copies state onto a managed instance and returns it; save calls persist when isNew (id null / null version) else merge. Trap: with assigned ids save merges, causing a SELECT first; implement Persistable.
  • What is N+1 and how do you fix it?
    One query for N parents plus one lazy load per parent. Fix with join fetch/@EntityGraph, default_batch_fetch_size, DTO projections. Trap: switching to EAGER does not fix it; collection fetch + paging forces in-memory pagination.
  • Why is @ManyToOne EAGER by default a problem?
    Every query on the entity pulls in the parent, and JPQL queries run extra selects for it. Override to LAZY and fetch deliberately. Trap: find() uses a join for EAGER but JPQL does not.
  • What is LazyInitializationException?
    Initialising a lazy proxy or collection after its session closed. Load inside the transaction and return DTOs. Trap: OSIV masks it in the web layer but hides N+1 and holds connections longer.
  • Owning vs inverse side of a relationship?
    The side with the foreign key column (no mappedBy) owns it; only changes to the owning side are persisted. Trap: keep both sides in sync with a helper method.
  • How does @Version work?
    Hibernate adds and version=? to the UPDATE and bumps it; zero rows updated throws ObjectOptimisticLockingFailureException. Trap: thrown at flush, not inside your try/catch; bulk updates skip it.
  • When would you use pessimistic locking?
    High contention, short transactions on hot rows (select ... for update), queue claiming with SKIP LOCKED. Trap: deadlocks and pool exhaustion; lock in a consistent order.
  • How are derived queries created?
    PartTree parses the method name at startup into a JPA criteria query; invalid property names fail the boot. Trap: keep them short; use @Query beyond three predicates.
  • Page vs Slice?
    Page runs an extra count query; Slice fetches size+1 to know if there is a next page. Trap: offset paging degrades with depth; use keyset scrolling for deep pages.
  • Why do bulk @Modifying queries return stale entities?
    They go directly to SQL and bypass the persistence context. Use clearAutomatically = true (and flushAutomatically). Trap: callbacks, auditing and @Version are skipped too.
  • Interface projection vs DTO record?
    Closed interface projections select only the declared columns; open ones (SpEL) load the entity; records/classes use a constructor expression. Trap: projections are not managed, so no dirty checking.
  • How do you get JDBC batching working?
    hibernate.jdbc.batch_size, order_inserts/order_updates, a non-IDENTITY id strategy and periodic flush/clear. Trap: IDENTITY silently disables insert batching.
13
Chapter Thirteen · Summary

Cheat Sheet and What to Read Next

SymptomLikely causeFirst move
Hundreds of SELECTs per requestN+1 through lazy associationsEnable org.hibernate.SQL logging; join fetch / EntityGraph / default_batch_fetch_size
LazyInitializationExceptionLazy access after the transactionFetch in the service, return DTOs, open-in-view=false
UPDATE I never asked forManaged entity mutated (setter or "just for the response")Map to a DTO; use readOnly = true
Row not saved after save()Used the original detached object, not the return valueUse saved = repo.save(e)
Stale data after bulk updatePersistence context not cleared@Modifying(clearAutomatically = true)
Inserts not batchedIDENTITY ids or batching offSEQUENCE + jdbc.batch_size + order_inserts
ObjectOptimisticLockingFailureExceptionConcurrent edit detectedReload and retry; surface a 409 to the user
Paging warning "applying in memory"Collection join fetch with PageableTwo-query paging or batch fetching

Takeaways

  • A transaction owns a persistence context: identity map + snapshots + action queue. Managed entities are written at flush with no save().
  • save() is persist-or-merge; always use its return value; getReferenceById avoids SELECTs for foreign keys.
  • Map @ManyToOne and @OneToOne as LAZY, enums as STRING, keep both sides of bidirectional links in sync, use sequences for ids.
  • A repository is a JDK proxy over SimpleJpaRepository with transaction and exception-translation interceptors; derived queries are parsed at startup.
  • Fix N+1 by choosing the fetch plan per use case: join fetch / EntityGraph, batch fetching, or DTO projections. Never return entities from controllers.
  • Use @Version for low-contention edits and PESSIMISTIC_WRITE (with SKIP LOCKED for queues) for hot rows.
  • Bulk work needs batching settings, non-IDENTITY ids and periodic flush(); clear();.

Everything above happens inside a transaction whose boundaries are decided by @Transactional. How that annotation works, what propagation and isolation really mean and how rollback rules bite is the next page.