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.
From JDBC Boilerplate to Repositories
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.
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.
| Layer | Who | Responsibility |
|---|---|---|
| Specification | Jakarta Persistence (jakarta.persistence.*) | Annotations, EntityManager API, JPQL |
| Provider | Hibernate ORM 6 | Persistence context, SQL generation, proxies, dirty checking, caches |
| Abstraction | Spring Data JPA | Repository proxies, derived queries, paging, specifications, auditing |
| Glue | Spring Boot HibernateJpaAutoConfiguration | Creates EntityManagerFactory, JpaTransactionManager, scans entities |
| Pool / driver | HikariCP + JDBC driver | Actual connections and wire protocol |
The Persistence Context: Identity Map, Snapshot, Action Queue
(entity type, id) → managed instance. Spring binds it to the thread via JpaTransactionManager.find() / query loads a rowflush(), and at commitINSERT/UPDATE/DELETE actions, then sends them in a fixed order.LazyInitializationException.- Writes are deferred.
save()on a new entity may not runINSERTuntil flush (withIDENTITYids it must run immediately to learn the key). - Reads are cached per transaction. A second
findByIdreturns 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().
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.
| Call | Entity must be | What it does | Returns |
|---|---|---|---|
persist(e) | transient | Makes e itself managed; INSERT queued (immediate for IDENTITY) | void (same instance) |
merge(e) | detached or transient | Loads/creates a managed copy, copies state of e onto it. e stays detached | the managed copy |
repo.save(e) | any | persist if isNew, else merge | the managed instance: always use the return value |
repo.saveAndFlush(e) | any | save, then em.flush() immediately | managed instance |
remove(e) | managed | Schedules DELETE; cascades REMOVE | void |
detach(e) / clear() | managed | Stops tracking one / all entities. Pending changes are lost unless flushed first | void |
refresh(e) | managed | Re-reads the row and overwrites in-memory changes | void |
flush(), or (FlushModeType.AUTO) before a JPQL/criteria query whose tables overlap pending changes.| Step | Hibernate internal | Effect |
|---|---|---|
| 1 | Cascade PERSIST/MERGE through managed graph | New children reachable from managed parents become managed |
| 2 | DefaultFlushEntityEventListener dirty checks each managed entity | Compares current property values with the snapshot |
| 3 | Dirty entities enqueue EntityUpdateAction | Version incremented here if @Version |
| 4 | Execute ActionQueue in fixed order | inserts → updates → collection changes → deletes |
| 5 | Snapshots refreshed | A second flush with no changes issues zero SQL |
- 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 Sessionmeans 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
DataIntegrityViolationExceptionunless you callflush()between the two. - Mutating an entity "just for the response". Setting a display field or truncating a string on a managed entity inside a
@Transactionalmethod 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 andflush(); clear();.
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.
| Relationship | Default fetch | Verdict |
|---|---|---|
@ManyToOne | EAGER | Override to LAZY |
@OneToOne | EAGER | Override to LAZY (owning side); the inverse side cannot really be lazy without bytecode enhancement |
@OneToMany | LAZY | Keep lazy and fetch explicitly per use case |
@ManyToMany | LAZY | Avoid — model the join table as an entity so it can carry columns and be queried |
@ElementCollection | LAZY | OK for small value lists; every change may delete and re-insert the whole collection |
| Strategy | How the id is obtained | Batching | Notes |
|---|---|---|---|
IDENTITY | DB auto-increment; id only known after the INSERT executes | Disabled | Forces an immediate INSERT on persist; simple but the slowest for bulk writes |
SEQUENCE | Hibernate reads the next value from a DB sequence; with allocationSize=50 (pooled optimizer) one call hands out 50 ids | Works | Hibernate 6 default for AUTO on databases with sequences; sequence INCREMENT BY must equal allocationSize |
UUID (@GeneratedValue on a UUID field) | Generated in the JVM | Works | No round trip; random v4 ids fragment B-tree indexes, consider time-ordered ids |
| Assigned | You set the id (natural/business key) | Works | save() then issues a SELECT-then-merge unless the entity implements Persistable or has a @Version wrapper |
| Inheritance strategy | Tables | Polymorphic query | Trade-off |
|---|---|---|---|
@MappedSuperclass | one per concrete class | not 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 column | one SELECT | Fastest; subclass columns must be nullable |
JOINED | one table per class, joined by PK | JOINs per hierarchy level | Normalised and strict; slower reads |
TABLE_PER_CLASS | one full table per concrete class | UNION of all tables | Rarely worth it; poor query plans |
- 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 inHashSetbecause the id changes on persist. A safe pattern:equalsviaother instanceof Order o && id != null && id.equals(o.getId())(call the getter so proxies work) andhashCodereturninggetClass().hashCode(). - Mutating only the inverse side.
customer.getOrders().add(order)withoutorder.setCustomer(customer)writes a NULL (or violates NOT NULL) incustomer_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 (
protectedis fine). Java records cannot be entities (Hibernate 6.2+ can use a record as an@Embeddable). ddl-auto=updatein production. It never drops or renames, can lock tables, and hides drift. Use Flyway or Liquibase andspring.jpa.hibernate.ddl-auto=validate.- Using
LocalDateTimefor instants. It carries no zone; preferInstant(orOffsetDateTime) and store UTC.
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.
| Startup step | Class | What happens |
|---|---|---|
| 1 | @EnableJpaRepositories (Boot auto-config) → JpaRepositoriesRegistrar | Classpath scan from the @SpringBootApplication package for interfaces extending Repository |
| 2 | JpaRepositoryFactoryBean per interface | A FactoryBean; one bean definition per repository |
| 3 | JpaRepositoryFactory.getRepository() | Builds the proxy, picks SimpleJpaRepository as target, registers interceptors |
| 4 | QueryLookupStrategy (default CREATE_IF_NOT_FOUND) | For each non-CRUD method: use @Query/named query if present, else derive from the method name |
| 5 | PartTree parses the name | findByStatusAndTotalGreaterThanOrderByCreatedAtDesc → subject + predicates + sort; invalid property → PropertyReferenceException: No property ... found for type ... at startup |
| Method | SQL | Returns | Use when |
|---|---|---|---|
findById(id) | SELECT now (or cache hit) | Optional<T> real entity | You need the data |
getReferenceById(id) | none until a property is read | lazy proxy | Setting a FK: order.setCustomer(customers.getReferenceById(id)) avoids a SELECT |
existsById(id) | select count(...) | boolean | Cheap existence check |
deleteById(id) | SELECT then DELETE | void | Loads first so cascades and lifecycle callbacks work |
deleteAllInBatch() | one delete from | void | Bulk, bypasses persistence context and cascades |
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.NoSuchBeanDefinitionExceptionfor 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 withIDENTITYids: one INSERT round trip each, plus oneSimpleJpaRepository.savetransaction each if you are not inside a service transaction. Wrap in one transaction, use sequences, enable batching (Chapter 10). - Expecting
deleteByIdon a missing id to throw. Since Spring Data 3 it silently does nothing; earlier versions threwEmptyResultDataAccessException. - Repository method inherits
readOnly=true. A custom@Querywith@Modifyingneeds its own@Transactional(or a calling service transaction) or it fails withTransactionRequiredException.
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.
| Style | Example | Validated | When to use |
|---|---|---|---|
| Derived name | findByStatusAndTotalGreaterThan(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 only | DB-specific SQL: window functions, SKIP LOCKED, JSON operators |
Specification | repo.findAll(spec, pageable) | compile time (Criteria API) | Optional filters assembled at runtime (search screens) |
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.
| Kind | Declaration | SQL selects | Caveat |
|---|---|---|---|
| Closed interface | interface OrderRow { Long getId(); BigDecimal getTotal(); } | only id, total | Nested getters on associations may select more |
| Open interface | @Value("#{target.id + target.status}") on a getter | whole entity | SpEL needs the entity; no column pruning |
| Record / class DTO | record OrderSummary(Long id, BigDecimal total) | only constructor args | Needs a matching constructor; can be used with JPQL select new ... |
| Dynamic | <T> List<T> findByStatus(OrderStatus s, Class<T> type) | depends on type | Same method serves entity and DTOs |
- Interface projection with an open
@Valuegetter silently loads the full entity, defeating the projection. Use a closed projection or a record. - Native query +
PageablewithoutcountQuery. Spring Data cannot rewrite native SQL into a count and fails at runtime; supplycountQuery = "select count(*) ...". @QuerywithLIKE %:q%concatenation in JPQL: writelike 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
@Modifyingqueries: useclearAutomaticallyor 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 aStringto an enum orLongparameter. Check the parameter type against the attribute type.
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.
| Return type | Queries | Knows total? | Cost |
|---|---|---|---|
Page<T> | page + select count | yes | The count scans the whole filtered set; slow on big tables |
Slice<T> | page of size+1 | no, only hasNext | Cheap; right for "load more" and infinite scroll |
List<T> + limit | one query | no | Cheapest |
Window<T> (Spring Data 3.1+) | keyset scroll via ScrollPosition.keyset() | no | Constant cost per page; needs a stable unique sort key |
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.
- 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 asid. Page+join fetchof a collection. Hibernate logsfirstResult/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=1000000is 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
@EnableJpaAuditingis missing, the entity lacks@EntityListeners, or the update was a bulk query. - Sort property injection:
?sort=passwordmaps straight to an attribute. Whitelist sortable fields instead of passing the clientSortthrough.
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.
@ManyToOne holds a generated subclass proxy (ByteBuddy); a lazy collection holds a PersistentBag wrapper.| Step | What happens |
|---|---|
| Load order #5 | Hibernate 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 closed | Initialisation needs a session → LazyInitializationException: could not initialize proxy [Customer#7] - no Session |
o.getLines().size() | PersistentBag.initialize() → SELECT ... where order_id=? |
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.
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.
Fix 3 — DTO projection. If the screen needs orderId, customerName, lineCount, ask the database for exactly that.
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.
| Fix | Statements | Pros | Cons |
|---|---|---|---|
join fetch / @EntityGraph | 1 | Explicit per use case, simplest SQL | Collection 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 paging | Still several queries; fetches whatever is touched |
| DTO projection | 1 | Smallest payload, read-only by design | Not entities: no dirty checking; one query per screen shape |
| Two-query paging | 2 | Correct pagination with collections | More code |
@Fetch(FetchMode.SUBSELECT) | 2 | Loads all collections of the previous result in one query | Re-runs the original query as a subselect; unsuitable for huge results |
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.
MultipleBagFetchException: cannot simultaneously fetch multiple bags: twoListcollections in one fetch graph. Switch toSet, 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
LazyInitializationExceptionwith OSIV off, or infinite recursion on bidirectional links). Never return entities from controllers. - EAGER "fix". Switching the mapping to
EAGERmakes 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. @Transactionalon 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 withsize(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;
distinctis no longer needed for that purpose but still sent to SQL, so you may skip it for plain entity graphs.
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 (@Version) | Pessimistic (PESSIMISTIC_WRITE) | |
|---|---|---|
| Mechanism | Compare-and-swap on a version column | SELECT ... FOR UPDATE row lock |
| Conflict outcome | Exception at flush/commit, retry | Second transaction waits (or fails after timeout) |
| Best for | Low contention, long user think time, web forms | High contention on hot rows (inventory, counters), short transactions |
| Risk | Retry storms under heavy contention | Deadlocks, lock waits, connection pool exhaustion |
| Spring exception | ObjectOptimisticLockingFailureException | PessimisticLockingFailureException (CannotAcquireLockException, LockTimeoutException) |
- Lock requested outside a transaction:
TransactionRequiredException: no transaction is in progress. Locks live as long as the transaction; call from a@Transactionalservice. - Swallowing the optimistic failure. It is thrown at flush, often at commit, after your method has returned: a
try/catchinside 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 writea.version = a.version + 1or use theversionedkeyword in HQL.
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.
| Condition for batching | Why |
|---|---|
| Id strategy is not IDENTITY | With IDENTITY Hibernate must execute each INSERT to read the generated key, so statements cannot be grouped |
jdbc.batch_size > 0 | Default is unset: every statement is its own round trip |
order_inserts / order_updates | Groups statements by entity type so interleaved parents and children do not break the batch into size-1 pieces |
| Same transaction, same flush | A flush boundary (a JPQL query in the middle) ends the current batch |
| Need | Approach | Note |
|---|---|---|
| Insert 100k rows | sequence ids + batching + chunked flush/clear | Or JDBC JdbcTemplate.batchUpdate when you need raw speed |
| Update one column for many rows | @Modifying JPQL/native, then clear | One statement; bypasses callbacks, auditing and @Version |
| Delete many | deleteAllInBatch() / bulk delete from | deleteAll() 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 millions | Streaming query inside a transaction, or keyset paging | Stream results must be closed (try-with-resources) |
- Batch size set but still one INSERT per row in the log: the entity uses
GenerationType.IDENTITY, ororder_insertsis off while cascading parents and children interleave. - Second-level cache bought as a fix. The L2 cache (
@Cacheableentities) 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
Streamreturn types: forgetting to close the stream keeps the cursor and connection until the transaction ends. Always try-with-resources.
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.
findCustomer(1)a == b is true: that is the identity map and the first-level cache.a.tier = "PLATINUM" then flush()update is emitted; the second flush is silent because the snapshot was refreshed.- Add an
Action Queue: collectINSERT/UPDATE/DELETEin lists and flush them inserts-first to reproduce the delete-then-insert unique-key bug. - Add
clear()and showLazyInitializationExceptionby makingLazy.get()throw when the session is closed. - Count statements per "request" and fail if more than N: the same idea as the Hibernate
Statisticstest guard.
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 withoutsave(). Trap: the context is perEntityManager, i.e. per transaction in Spring; outside a transaction every call gets a fresh one. persistvsmergevs Spring Datasave?persistmakes the instance itself managed (transient only);mergecopies state onto a managed instance and returns it;savecalls persist whenisNew(id null / null version) else merge. Trap: with assigned idssavemerges, causing a SELECT first; implementPersistable.- What is N+1 and how do you fix it?
One query for N parents plus one lazy load per parent. Fix withjoin 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
@ManyToOneEAGER by default a problem?
Every query on the entity pulls in the parent, and JPQL queries run extra selects for it. Override toLAZYand 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 (nomappedBy) owns it; only changes to the owning side are persisted. Trap: keep both sides in sync with a helper method. - How does
@Versionwork?
Hibernate addsand version=?to the UPDATE and bumps it; zero rows updated throwsObjectOptimisticLockingFailureException. 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 withSKIP LOCKED. Trap: deadlocks and pool exhaustion; lock in a consistent order. - How are derived queries created?
PartTreeparses the method name at startup into a JPA criteria query; invalid property names fail the boot. Trap: keep them short; use@Querybeyond three predicates. PagevsSlice?Pageruns an extra count query;Slicefetches 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
@Modifyingqueries return stale entities?
They go directly to SQL and bypass the persistence context. UseclearAutomatically = true(andflushAutomatically). Trap: callbacks, auditing and@Versionare 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 periodicflush/clear. Trap: IDENTITY silently disables insert batching.
Cheat Sheet and What to Read Next
| Symptom | Likely cause | First move |
|---|---|---|
| Hundreds of SELECTs per request | N+1 through lazy associations | Enable org.hibernate.SQL logging; join fetch / EntityGraph / default_batch_fetch_size |
LazyInitializationException | Lazy access after the transaction | Fetch in the service, return DTOs, open-in-view=false |
| UPDATE I never asked for | Managed 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 value | Use saved = repo.save(e) |
| Stale data after bulk update | Persistence context not cleared | @Modifying(clearAutomatically = true) |
| Inserts not batched | IDENTITY ids or batching off | SEQUENCE + jdbc.batch_size + order_inserts |
ObjectOptimisticLockingFailureException | Concurrent edit detected | Reload and retry; surface a 409 to the user |
| Paging warning "applying in memory" | Collection join fetch with Pageable | Two-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;getReferenceByIdavoids SELECTs for foreign keys.- Map
@ManyToOneand@OneToOneasLAZY, enums asSTRING, keep both sides of bidirectional links in sync, use sequences for ids. - A repository is a JDK proxy over
SimpleJpaRepositorywith 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
@Versionfor low-contention edits andPESSIMISTIC_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.