Java Fundamentals Tutorial 0/33 lessons ~6 min read Lesson 24

    JPA & Hibernate

    JPA (Jakarta Persistence API) is the standard ORM interface; Hibernate is the implementation Spring Boot uses by default.

    Course progress0%
    Focus
    21 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    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:

    text
    @Transactional
    public TransferResult process(TransferCommand cmd) {
    Account from = accountRepo.findById(cmd.fromId()).orElseThrow(); // SELECT → managed entity
    from.debit(cmd.amount()); // dirty checking marks entity dirty (no UPDATE yet)
    accountRepo.save(from); // redundant if managed — flush handles it
    return result;
    } // flush + commit → UPDATE accounts SET balance=... WHERE id=?
    Persistence context (L1 cache):
    Map<EntityKey, Entity> — same id → same instance within TX
    Flush modes:
    AUTO (default) — flush before query if needed
    COMMIT — flush only at commit
    N+1 pattern:
    SELECT * FROM orders LIMIT 100; -- 1 query
    for each order:
    SELECT * FROM order_items WHERE order_id=? -- N queries
    Fix:
    @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:

    Entity lifecycle states
    Transient
    new Entity()
    Managed
    In PC
    Detached
    TX closed
    Removed
    delete()
    Operations valid depend on state — merge() reattaches detached.
    Dirty checking on flush
    Load entity
    Snapshot taken
    Modify field
    In memory
    Flush
    Compare snapshot
    UPDATE SQL
    If dirty
    No save() needed for managed entities — flush writes changes.
    N+1 query problem
    1 parent query
    100 orders
    Lazy getItems()
    Per order
    100 child queries
    N+1
    JOIN FETCH
    Fix: 1 query
    Access lazy collection in loop — one query per parent row.
    Cache layers
    L1 PC
    Per TX
    L2 cache
    Shared entities
    Query cache
    Result IDs
    Database
    Source of truth
    L2 speeds reads — invalidation complexity is the trade-off.

    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.
    java
    @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 locking
    private long version;
    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
    @BatchSize(size = 25) // batch lazy loads: IN (?,?,...) instead of N queries
    private 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
    @RequiredArgsConstructor
    public class AccountService {
    private final AccountRepository repo;
    @Transactional
    public AccountEntity getAccount(UUID id) {
    return repo.findById(id).orElseThrow(); // managed within TX
    }
    @Transactional
    public 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.
    java
    // 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 config
    spring.jpa.properties.hibernate.cache.use_second_level_cache=true
    spring.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
    @Transactional
    public 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.
    bash
    # application-dev.yml
    logging:
    level:
    org.hibernate.SQL: DEBUG
    org.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

    5
    1. 1What is the persistence context?
      Beginner

      Model 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?

    2. 2Explain entity states in JPA.
      Beginner

      Model 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()?

    3. 3What is dirty checking?
      Beginner

      Model 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?

    4. 4What is the N+1 problem?
      Beginner

      Model 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?

    5. 5Difference LAZY and EAGER fetch?
      Beginner

      Model 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

    5
    1. 6What is LazyInitializationException?
      Intermediate

      Model 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?

    2. 7Explain first vs second-level cache.
      Intermediate

      Model 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?

    3. 8How does @Version optimistic locking work?
      Intermediate

      Model 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?

    4. 9Fix N+1 for orders with items list endpoint.
      Intermediate

      Model 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?

    5. 10When is save() necessary in Spring Data JPA?
      Intermediate

      Model 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

    5
    1. 11Design batch archive job without OOM.
      Advanced

      Model 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?

    2. 12Compare JOIN FETCH and @BatchSize.
      Advanced

      Model 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?

    3. 13Should you cache Account entity with balance?
      Advanced

      Model 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?

    4. 14Explain spring.jpa.open-in-view=false impact.
      Advanced

      Model 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?

    5. 15Architect JPA layer for high-traffic read/write service.
      Advanced

      Model 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

    Starter Templates
    OutputRemote JVM (Piston · Java 15)
    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.
    Ready to mark this lesson complete?Track your journey across the entire course.