JPA & Hibernate
JPA (Jakarta Persistence API) is the standard ORM interface; Hibernate is the implementation Spring Boot uses by default.
Introduction
JPA (Jakarta Persistence API) is the standard ORM interface; Hibernate is the implementation Spring Boot uses by default. They translate Java objects to SQL — and introduce a persistence context (first-level cache), automatic dirty checking, lazy loading that causes N+1 queries, and optional second-level cache that can save your read replica or corrupt data if misconfigured.
This lesson explains how Hibernate actually works inside a @Transactional service method: when entities become managed, how flush synchronizes state to SQL, why accessing a lazy collection triggers extra queries, and how to fix N+1 with fetch joins, @EntityGraph, and batch size. Staff engineers debug JPA by reading SQL logs — not by adding @Cacheable everywhere.
JPA is not "database-free" — it is SQL with an entity graph on top. Understanding persistence context boundaries prevents the detached entity, LazyInitializationException, and stale cache incidents that dominate Spring Data support tickets.
Business problem
JPA misuse causes the most common Spring production performance bugs:
- N+1 queries: 1 query for 100 orders + 100 for items — 101 round trips, p99 latency 10x.
- LazyInitializationException: Lazy collection accessed outside @Transactional — 500 errors in production only.
- Stale second-level cache: @Cacheable entity updated via native SQL — users see old balances until TTL expires.
- Unbounded persistence context: Batch job loads 500K entities — OOM before flush clears context.
- Lost updates: No @Version optimistic lock — two TX overwrite each other silently.
Why this topic exists
JPA exists to reduce JDBC boilerplate while keeping relational power:
- Object-relational mapping: @Entity maps class to table — focus on domain, not row mappers everywhere.
- Persistence context: Unit of work pattern — track changes, flush once at commit.
- Lazy loading: Load associations on demand — efficient when used inside TX, disaster when misused.
- Spring Data repositories: Derived queries, paging — CRUD without ceremony.
- Portable JPQL: Entity queries abstract dialect — native SQL when needed for optimization.
Core concepts
Five JPA/Hibernate pillars:
- Persistence context: Session-scoped cache of managed entities — identity map ensures one Java instance per row per TX.
- Entity states: Transient (new), Managed (in context), Detached (was managed, TX closed), Removed (scheduled delete).
- Dirty checking: Hibernate compares managed entity fields to snapshot — generates UPDATE on flush without explicit save().
- N+1 problem: Lazy @OneToMany — parent query once, one query per child when accessed — fix with fetch join/batch.
- Caching: L1 (persistence context, always on), L2 (session factory, shared — entity cache), query cache (controversial).
Internal architecture
Hibernate inside @Transactional service method:
@Transactionalpublic TransferResult process(TransferCommand cmd) {Account from = accountRepo.findById(cmd.fromId()).orElseThrow(); // SELECT → managed entityfrom.debit(cmd.amount()); // dirty checking marks entity dirty (no UPDATE yet)accountRepo.save(from); // redundant if managed — flush handles itreturn result;} // flush + commit → UPDATE accounts SET balance=... WHERE id=?Persistence context (L1 cache):Map<EntityKey, Entity> — same id → same instance within TXFlush modes:AUTO (default) — flush before query if neededCOMMIT — flush only at commitN+1 pattern:SELECT * FROM orders LIMIT 100; -- 1 queryfor each order:SELECT * FROM order_items WHERE order_id=? -- N queriesFix:@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE ...")or @BatchSize(size=25) on collection
Persistence context, dirty checking, N+1, and cache layers:
Code walkthrough
JPA entities, repository, fetch strategies, and N+1 fix:
- @Version: Optimistic lock — UPDATE fails if version changed — StaleObjectStateException.
- LAZY default for collections: Load transfers only when accessed — inside @Transactional.
- JOIN FETCH: Single query with join — avoid N+1 when you need collection in same TX.
- @BatchSize: Compromise — batches lazy loads into groups of 25.
@Entity@Table(name = "accounts")public class AccountEntity {@Id@GeneratedValue(strategy = GenerationType.UUID)private UUID id;@Column(name = "balance_cents", nullable = false)private long balanceCents;@Version // optimistic lockingprivate long version;@OneToMany(mappedBy = "account", fetch = FetchType.LAZY)@BatchSize(size = 25) // batch lazy loads: IN (?,?,...) instead of N queriesprivate List<TransferEntity> transfers = new ArrayList<>();public void debit(long amount) {if (balanceCents < amount) throw new InsufficientFundsException();balanceCents -= amount;}}public interface AccountRepository extends JpaRepository<AccountEntity, UUID> {@Query("SELECT a FROM AccountEntity a JOIN FETCH a.transfers WHERE a.id = :id")Optional<AccountEntity> findByIdWithTransfers(@Param("id") UUID id);@EntityGraph(attributePaths = {"transfers"})Optional<AccountEntity> findWithGraphById(UUID id);}@Service@RequiredArgsConstructorpublic class AccountService {private final AccountRepository repo;@Transactionalpublic AccountEntity getAccount(UUID id) {return repo.findById(id).orElseThrow(); // managed within TX}@Transactionalpublic void debit(UUID id, long amount) {AccountEntity account = repo.findById(id).orElseThrow();account.debit(amount); // dirty — flush at commit generates UPDATE// no save() required — entity is managed}}// N+1 detection — enable in dev// spring.jpa.properties.hibernate.generate_statistics=true
Production example
Production JPA — second-level cache, read-only queries, batch processing:
- READ_ONLY cache: Immutable reference data — Currency, Country codes.
- Projections: DTO interface query — Hibernate selects only needed columns.
- flush + clear: Batch processing pattern — release memory between chunks.
- OSIV=false: Lazy collections must be fetched in service TX — not in controller render.
// Second-level cache — reference data only (rarely changes)@Entity@Cacheable@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_ONLY)public class CurrencyEntity {@Id private String code;private int decimalPlaces;}// spring configspring.jpa.properties.hibernate.cache.use_second_level_cache=truespring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory// Read-only query optimization@Transactional(readOnly = true)public List<TransferDto> listTransfers(UUID accountId, Pageable page) {return repo.findProjectedByAccountId(accountId, page); // interface projection — no entity}// Batch job — don't load 500K entities in one PC@Transactionalpublic void archiveOldTransfers(LocalDate cutoff) {int batch = 500;Page<TransferEntity> page;do {page = repo.findByCreatedAtBefore(cutoff, PageRequest.of(0, batch));page.forEach(t -> { archive(t); repo.delete(t); });repo.flush();// entityManager.clear(); // clear L1 between batches — prevent OOM} while (page.hasContent());}// Open Session In View — disable in prod (default false Boot 3)spring.jpa.open-in-view=false
Enterprise case study
Marketplace order API — N+1 and cache poisoning: GET /orders returned Order entities with lazy OrderItems. Jackson serialization in controller (OSIV enabled) masked issue in dev. Production with OSIV=false after Boot 3 upgrade → mass LazyInitializationException. Parallel issue: @Cacheable ProductEntity — price updated via admin native SQL bypassing Hibernate — storefront showed stale prices for 1 hour (TTL). Fix: JOIN FETCH in service for order detail endpoint, DTO projection for list, evict cache on admin update via @CacheEvict, disable L2 on frequently mutated entities.
- N+1: 101 queries per order list page — APM showed JDBC spike.
- OSIV: Disabled for correctness — forced fetch in service layer.
- Cache: Evict on write path; L2 only on read-mostly reference entities.
Performance considerations
JPA performance:
- Fetch plan explicit: Don't rely on defaults — JOIN FETCH, @EntityGraph, or DTO projection per use case.
- Batch inserts: hibernate.jdbc.batch_size=25 + order_inserts — bulk persist faster.
- Pagination: Never .findAll() unbounded — Pageable always on list endpoints.
- Statement caching: Prepared statement cache at pool + Hibernate level.
- Avoid EAGER collections: Always-loaded associations — hidden join on every query.
Security considerations
JPA security notes:
- JPQL injection: Use named parameters — never concat user input into @Query string.
- Mass assignment: Don't bind HTTP request to @Entity — use DTO then map.
- Multi-tenant: @Filter or discriminator column — test tenant isolation in integration tests.
- Audit fields: @CreatedDate, @LastModifiedBy — Envers for compliance trail.
Scalability considerations
Scaling JPA applications:
- Read models: CQRS — write JPA, read optimized SQL or Elasticsearch projection.
- Connection per TX: Keep TX short — pool size bounds concurrent DB work.
- Stateless sessions: Distribute pods — no server-side session affinity for JPA.
- Partition archival: Hibernate less effective on billion-row tables — archive cold data.
Production challenges
Common JPA production issues:
- LazyInitializationException: Access lazy property after TX closed — fetch in service or use DTO.
- MultipleBagFetchException: Two JOIN FETCH collections — use @BatchSize or two queries.
- StaleObjectStateException: Concurrent @Version conflict — retry or show conflict to user.
- Session closed on stream: Stream query must be consumed inside @Transactional.
- Equals/hashCode on entity: Business key only — never lazy collections in equals.
Common mistakes
- Calling save() on every field change — managed entities dirty-checked automatically.
- EAGER fetch on @ManyToOne everywhere — loads unnecessary joins.
- @Cacheable on mutable financial entities — stale balance risk.
- Open Session In View masking lazy load bugs until OSIV disabled.
- Using entity in HashSet with database-generated ID before flush — identity broken.
Debugging guide
Debug JPA/Hibernate:
- SQL logging: spring.jpa.show-sql=true (dev) or p6spy for formatted SQL with params.
- Statistics: hibernate.generate_statistics=true — SessionMetrics query count per request.
- Lazy load trace: logging.level.org.hibernate.SQL=DEBUG — see when extra SELECTs fire.
- Persistence context size: entityManager.getPersistenceContext() debug in tests.
# application-dev.ymllogging:level:org.hibernate.SQL: DEBUGorg.hibernate.orm.jdbc.bind: TRACE# Assert query count in test@Autowired TestEntityManager em;Statistics stats = em.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics();stats.clear();service.listOrders();assertEquals(1, stats.getPrepareStatementCount()); // not 101
Best practices
- Default LAZY on collections — fetch explicitly per use case in service layer.
- Use @Version on entities subject to concurrent updates.
- DTO projections for list endpoints — entities for write path only.
- spring.jpa.open-in-view=false — fetch within @Transactional service methods.
- L2 cache only on read-mostly reference data — always evict on admin update path.
- Test query count for hot endpoints — regression guard against N+1.
Anti-patterns
- God entity with 30 @OneToMany relationships — split aggregate boundaries.
- CascadeType.ALL everywhere — accidental delete propagation.
- @Transactional on repository interface — belongs on service.
- Hibernate auto-ddl in production — Flyway owns schema.
- Manual session management in Spring — let @Transactional manage boundaries.
Staff engineer notes
- N+1 is the default failure mode of lazy loading — assume it until EXPLAIN proves otherwise.
- Dirty checking means save() is often redundant — understand managed vs detached before calling save.
- Second-level cache is a distributed systems problem — invalidation harder than it looks.
- OSIV=false is correct for APIs — lazy load in controller was always a bug waiting to happen.
- In interview and code review: "How many SQL statements does this endpoint execute?" — always ask.
Interview questions
Interview preparation
15 questions grouped by difficulty — expand any card for a model answer and follow-up probe.
Beginner
1What is the persistence context?
BeginnerModel answer
- First-level cache scoped to JPA EntityManager/Hibernate Session within a transaction. Tracks managed entities
- identity map ensures one Java object per DB row. Enables dirty checking and lazy loading. Cleared when TX ends.
Follow-up probe
EntityManager vs Session?
2Explain entity states in JPA.
BeginnerModel answer
- Transient: new, no ID, not tracked. Managed: in persistence context, changes tracked. Detached: was managed, TX closed
- changes not tracked until merge(). Removed: scheduled for deletion on flush. Operations like persist() vs merge() depend on state.
Follow-up probe
When use merge()?
3What is dirty checking?
BeginnerModel answer
- Hibernate snapshots managed entity state at load. On flush, compares current field values to snapshot
- generates UPDATE for changed entities without explicit save(). Enables unit-of-work pattern
- modify objects, commit TX, SQL auto-generated.
Follow-up probe
Flush vs commit?
4What is the N+1 problem?
BeginnerModel answer
- One query loads N parent entities, then one additional query per parent when lazy association accessed
- 1+N total. Common with @OneToMany LAZY in loops or serialization. Fix: JOIN FETCH, @EntityGraph, @BatchSize, or DTO projection with explicit join query.
Follow-up probe
JOIN FETCH cartesian?
5Difference LAZY and EAGER fetch?
BeginnerModel answer
- LAZY: association loaded on first access (within open persistence context). EAGER: loaded with parent always
- can cause unnecessary joins and MultipleBagFetch issues. Default LAZY for collections, EAGER for @ManyToOne in JPA spec (override to LAZY recommended).
Follow-up probe
LazyInitializationException?
Intermediate
6What is LazyInitializationException?
IntermediateModel answer
- Accessing uninitialized lazy proxy outside active persistence context/transaction. Hibernate cannot query DB
- session closed. Fix: fetch in @Transactional service (JOIN FETCH), use DTO, or enable OSIV (discouraged for APIs). Root cause: TX boundary too narrow.
Follow-up probe
Open Session In View?
7Explain first vs second-level cache.
IntermediateModel answer
- L1: persistence context per TX
- always on, not shared across TX. L2: SessionFactory-level shared cache across transactions/users
- entity data by ID. Query cache stores result entity IDs. L2 needs careful invalidation
- stale data risk on updates.
Follow-up probe
CacheConcurrencyStrategy?
8How does @Version optimistic locking work?
IntermediateModel answer
- Entity has @Version field (number/timestamp). UPDATE includes WHERE version=?
- if another TX incremented version, update count 0 → OptimisticLockException/StaleObjectStateException. Retry or return conflict. Better than pessimistic lock for low-contention reads-heavy workloads.
Follow-up probe
vs pessimistic lock?
9Fix N+1 for orders with items list endpoint.
IntermediateModel answer
- Option 1: @Query JOIN FETCH o.items with DISTINCT. Option 2: @EntityGraph on repository method. Option 3: @BatchSize on items collection. Option 4: DTO projection query selecting only needed columns. Option 5: two queries
- ids first, then IN clause batch load.
Follow-up probe
MultipleBagFetchException?
10When is save() necessary in Spring Data JPA?
IntermediateModel answer
- For new transient entities: save() calls persist(). For managed entities in active TX: save() redundant
- dirty checking handles updates. For detached entities: save() calls merge(). Best: load-modify within single @Transactional
- no save() needed.
Follow-up probe
saveAndFlush?
Advanced
11Design batch archive job without OOM.
AdvancedModel answer
Paginate query (500 rows).
clear() to detach all and free L1.
Never findAll() 500K rows.
Consider native DELETE with WHERE for bulk if no entity logic needed.
Follow-up probe
StatelessSession?
12Compare JOIN FETCH and @BatchSize.
AdvancedModel answer
- JOIN FETCH: single query with join
- can cause cartesian product with multiple collections. @BatchSize: separate batched IN queries (groups of N)
- more queries but safer with multiple bags. Choose based on EXPLAIN and collection count.
Follow-up probe
Subselect fetch?
13Should you cache Account entity with balance?
AdvancedModel answer
- No
- balance mutates frequently. L2 cache stale reads = financial incorrectness. Cache read-mostly reference data (Currency, config). For account reads use short TTL application cache with explicit evict on write if needed
- or always read from DB within TX.
Follow-up probe
@CacheEvict pattern?
14Explain spring.jpa.open-in-view=false impact.
AdvancedModel answer
- Closes persistence context after service TX
- not after HTTP response. Forces lazy associations loaded in service layer. Prevents hidden N+1 during view/JSON serialization. Correct for REST APIs. Requires intentional fetch strategy
- LazyInitializationException if controller accesses lazy field.
Follow-up probe
When OSIV true?
15Architect JPA layer for high-traffic read/write service.
AdvancedModel answer
Write path: entities + short TX + @Version.
Read list: DTO projections, no entity graph.
Read detail: JOIN FETCH specific associations in readOnly TX.
No L2 on hot mutable data.
Query count tests in CI.
Native SQL for reports on replica.
Cache at HTTP/CDN layer for public catalog data.
Follow-up probe
Spring Data JDBC alternative?
Hands-on exercise
Lab: JPA internals
- Create Account + Transfer @OneToMany LAZY — reproduce N+1 with statistics enabled.
- Fix with JOIN FETCH — assert query count drops to 1.
- Add @Version — simulate concurrent update — catch OptimisticLockException.
- Debit in @Transactional without save() — verify UPDATE in SQL log on commit.
- Batch job: process 1000 rows with flush/clear every 100 — monitor heap.
JavaJPA & Hibernate: Persistence Context, Dirty Checking, N+1, Caching
Press Run to execute. Real javac + JVM via remote API. Falls back to simulator on network failure.Architecture trade-offs
- JPA vs JDBC/MyBatis: JPA productivity vs SQL control — hybrid common.
- JOIN FETCH vs DTO projection: Entity graph vs slim read model.
- L2 cache vs application cache: L2 transparent but invalidation hard; Redis explicit.
- Optimistic vs pessimistic lock: Retry vs block — contention profile decides.
Summary
JPA and Hibernate are powerful when you understand the persistence context, dirty checking, fetch strategies, and cache layers. Read the SQL, count the queries, and fetch deliberately — then ORM accelerates development instead of hiding performance bombs. This completes the Spring and data arc: Core → Boot → REST → SQL → JPA.
Key takeaways
- Persistence context tracks managed entities — dirty checking generates UPDATE on flush.
- Entity states: transient, managed, detached, removed — merge() for detached.
- N+1 is lazy loading's default trap — JOIN FETCH, @BatchSize, or DTO projections fix it.
- L2 cache only for read-mostly data — evict on every write path for mutable entities.
- OSIV=false, short transactions, @Version, query count tests — production JPA hygiene.