LearnAI ToolsCareerPractice BuildsPlayContact
HibernateIntermediate~2.5 hours

Cache-Optimized Catalog Service

Speed up a read-heavy product catalog using the second-level cache.

Second-Level CacheFetch TypesPerformance

Overview

A product catalog is read far more often than it is written — thousands of shoppers browse the same handful of products between any two admin updates — which makes it the textbook case for Hibernate's second-level cache. The first-level cache (the `Session` itself) only lives as long as one session does and is useless across requests; the second-level cache sits at the `SessionFactory` level, shared across every session, and can hand back a `Product` that was loaded five requests ago without touching the database at all.

By the end of this tutorial you will have a `Product` entity marked `@Cacheable` and backed by a JCache/Ehcache second-level cache region, a related `Category` entity mapped with `FetchType.LAZY`, and a hands-on demonstration of the N+1 query problem — fetching ten products the naive way, watching Hibernate issue eleven separate queries, then fixing it with a `JOIN FETCH` HQL query that does the same job in one.

What You'll Build
  • A `Category` entity and a `Product` entity connected by a `@ManyToOne`/`@OneToMany` pair.
  • A second-level cache provider (Ehcache via JCache) configured in `hibernate.cfg.xml`.
  • A `@Cacheable` `Product` entity using `CacheConcurrencyStrategy.READ_WRITE`.
  • A deliberately naive loop that reproduces the N+1 query problem when reading products and their categories.
  • A `JOIN FETCH` HQL query that collapses N+1 queries into a single SQL join.
  • A before/after query-count comparison proving both fixes actually work.

Prerequisites

  • Everything from the Student-Course Enrollment System and Order Management System projects — `@Entity`, `@ManyToOne`, `@OneToMany`, HQL.
  • The idea of caching in general — trading a small amount of staleness risk for a large reduction in repeated work.
  • What FetchType.LAZY vs. FetchType.EAGER means: whether a related entity loads immediately or only when actually accessed.
  • Basic performance reasoning — why one query touching the database once is cheaper than many queries touching it repeatedly.
  • A JCache-compatible caching library on the classpath (Ehcache is used in this project) alongside `hibernate-jcache`.

Project Structure

Two entities, one deliberate performance problem, and two independent fixes. `Category` is the "one" side of a one-to-many with `Product`; every product belongs to exactly one category, and a category can have many products. `Product` is where both techniques in this project meet: it is the entity marked cacheable, and it is also the entity whose lazy `category` reference is what triggers the N+1 problem if fetched carelessly.

EntityPurposeKey Fields / Mapping
CategoryOne row per product categoryid (PK), name, products (@OneToMany, mappedBy = "category")
ProductOne row per product, second-level cachedid (PK), name, price, category (@ManyToOne, FetchType.LAZY), @Cacheable

Step 1: Map the Category and Product Entities

`Product.category` is deliberately `FetchType.LAZY`, which is actually already the JPA default for `@ManyToMany` and `@OneToMany` but NOT for `@ManyToOne` (whose spec default is `EAGER`) — so it has to be set explicitly here to override that default. Loading a `Product` should not force-load its `Category` unless something actually asks for `product.getCategory().getName()`; leaving `@ManyToOne` at its default `EAGER` setting is one of the most common accidental performance mistakes in real Hibernate code, and Step 4 shows exactly what goes wrong even with `LAZY` set correctly.

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
@OneToMany(mappedBy = "category")
private List<Product> products = new ArrayList<>();
public Category() {}
public Category(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
public List<Product> getProducts() { return products; }
}
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private BigDecimal price;
// EAGER is @ManyToOne's JPA default — LAZY has to be set explicitly here, or
// every Product load would also force-load its Category whether anything
// needed it or not. This alone does not fix N+1 (see Step 4); it only makes
// the extra query happen on demand instead of unconditionally.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
public Product() {}
public Product(String name, BigDecimal price, Category category) {
this.name = name;
this.price = price;
this.category = category;
}
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public Category getCategory() { return category; }
}

Step 2: Enable the Second-Level Cache

The second-level cache is off by default and has to be turned on in configuration before any `@Cacheable` annotation does anything at all. `hibernate.cache.use_second_level_cache` is the master switch, `hibernate.cache.region.factory_class` tells Hibernate which caching library implements the actual storage (Ehcache, via the JCache standard, here), and `hibernate.javax.cache.provider` points at that library's specific `CachingProvider` implementation class.

<!-- Additions to hibernate.cfg.xml from the first project's Step 3 -->
<property name="hibernate.cache.use_second_level_cache">true</property>
<property name="hibernate.cache.region.factory_class">jcache</property>
<property name="hibernate.javax.cache.provider">org.ehcache.jsr107.EhcacheCachingProvider</property>
<!-- Optional but recommended: also cache query results, not just entities by id -->
<property name="hibernate.cache.use_query_cache">true</property>
Second-Level Cache vs. First-Level Cache

The first-level cache (every `Session` has one automatically, no configuration needed) only avoids re-querying the same entity twice within one session. The second-level cache configured here lives at the `SessionFactory` level and is shared across every session in the application, which is what lets it actually save a round trip to the database between two completely unrelated requests.

Step 3: Mark Product as Cacheable

`@Cacheable` opts `Product` into the second-level cache now that Step 2 has turned the feature on globally; without it, `Product` would still be fetched from the database on every `session.get()` call even with caching enabled. `CacheConcurrencyStrategy.READ_WRITE` is chosen deliberately over the faster `READ_ONLY` strategy: a product catalog's prices and names do occasionally change (an admin edits a price), and `READ_WRITE` keeps cached data consistent with those writes at a small locking cost, whereas `READ_ONLY` would require evicting and re-caching an entity by hand after every update.

import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import java.math.BigDecimal;
@Entity
@Table(name = "products")
@Cacheable // Opts this entity into the second-level cache enabled in Step 2
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // READ_WRITE keeps cached rows consistent after an update, at a small locking cost
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
public Product() {}
public Product(String name, BigDecimal price, Category category) {
this.name = name;
this.price = price;
this.category = category;
}
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public Category getCategory() { return category; }
}
session.get() Behavior After Caching

Click Run to see what this code prints.

Step 4: See the N+1 Problem Firsthand

This loop looks completely reasonable and is exactly how the N+1 problem sneaks into real code: one query loads ten products (`FROM Product`), and then `product.getCategory().getName()` inside the loop triggers one additional lazy-load query per product, because `category` is `FetchType.LAZY` and Hibernate only fetches it the moment it is actually accessed. Ten products means one query to fetch them, plus ten more to resolve each one's category — eleven queries total to display data that could have been fetched in one.

// N+1 problem: 1 query for the products, then 1 more query PER product to
// resolve its lazy-loaded category the moment getCategory().getName() runs.
String hql = "FROM Product";
List<Product> products = session.createQuery(hql, Product.class).getResultList(); // Query #1
for (Product p : products) {
// Each iteration triggers its own SELECT against categories, because
// "category" was never loaded up front — this is the "+N" in "N+1".
System.out.println(p.getName() + " - " + p.getCategory().getName());
}
Generated SQL — 10 Products, 11 Queries Total

Click Run to see what this code prints.

Step 5: Fix It With JOIN FETCH and the Cache

`JOIN FETCH` is HQL's explicit instruction to load an association eagerly for this one query, overriding the entity's normal `FetchType.LAZY` mapping without changing that mapping globally — a query-level override rather than a mapping-level one. Combined with `@Cacheable` from Step 3, the fix is layered: the first request pays for one efficient joined query instead of eleven separate ones, and every subsequent request for the same products can be served from the second-level cache without hitting the database again at all.

// JOIN FETCH: one query, eagerly loading category alongside each product —
// a query-scoped override of Product.category's normal LAZY mapping.
String hql = "SELECT p FROM Product p JOIN FETCH p.category";
List<Product> products = session.createQuery(hql, Product.class).getResultList(); // Just 1 query total
for (Product p : products) {
System.out.println(p.getName() + " - " + p.getCategory().getName()); // No extra SELECT: category was already loaded
}
Generated SQL — 10 Products, 1 Query Total

Click Run to see what this code prints.

ApproachQueries for 10 ProductsSecond Read (Cached)
Naive lazy loop (Step 4)11 (1 + 10)11 again — no caching involved
JOIN FETCH (Step 5)11 again unless the query cache from Step 2 also applies
JOIN FETCH + @Cacheable1 (first time only)0 — served from the second-level cache

Complete Code

The full `Category` and `Product` entities, cache configuration, and both the naive and fixed query methods, assembled from every step above.

@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
@OneToMany(mappedBy = "category")
private List<Product> products = new ArrayList<>();
public Category() {}
public Category(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
public List<Product> getProducts() { return products; }
}
@Entity
@Table(name = "products")
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
public Product() {}
public Product(String name, BigDecimal price, Category category) {
this.name = name;
this.price = price;
this.category = category;
}
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public Category getCategory() { return category; }
}
public class ProductCatalogService {
// Naive: reproduces the N+1 problem from Step 4. Kept here for comparison only.
public List<Product> getAllProductsNaive(Session session) {
return session.createQuery("FROM Product", Product.class).getResultList();
}
// Fixed: JOIN FETCH collapses the N+1 queries into one (Step 5).
public List<Product> getAllProductsFetched(Session session) {
return session.createQuery(
"SELECT p FROM Product p JOIN FETCH p.category", Product.class
).getResultList();
}
// Cached single lookup: served from the second-level cache after the first call (Step 3).
public Product getProductById(Session session, Long id) {
return session.get(Product.class, id);
}
}

Sample Run

Three operations showing the cache and the fetch fix working together against a catalog of ten products across two categories.

// 1. Naive read of all products (Step 4's N+1 problem)
List<Product> naive = catalogService.getAllProductsNaive(session);
naive.forEach(p -> System.out.println(p.getName() + " - " + p.getCategory().getName()));
Result

Click Run to see what this code prints.

// 2. Fixed read using JOIN FETCH (Step 5)
List<Product> fetched = catalogService.getAllProductsFetched(session);
fetched.forEach(p -> System.out.println(p.getName() + " - " + p.getCategory().getName()));
Result

Click Run to see what this code prints.

// 3. Repeated single-product lookup, showing the second-level cache at work
Product first = catalogService.getProductById(session, 5L); // First call: hits the database
Product again = catalogService.getProductById(session2, 5L); // Different session, same id
Result

Click Run to see what this code prints.

Extend This Project

  • Add `@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)` to `Category` as well, and measure whether it reduces queries in the `JOIN FETCH` path further.
  • Enable `hibernate.generate_statistics` and read `sessionFactory.getStatistics().getSecondLevelCacheHitCount()` to get exact cache hit/miss counts instead of estimating from query logs.
  • Add a `Set<Product> findByCategory(String categoryName)` method using `JOIN FETCH` and measure its query count against an equivalent naive lazy-loading version.
  • Switch `Category` from `CacheConcurrencyStrategy.READ_WRITE` to `READ_ONLY` and explain in a comment why that would be unsafe if category names could ever be renamed.
  • Add pagination (`setFirstResult`/`setMaxResults`) to `getAllProductsFetched()` and confirm the query count stays at 1 regardless of catalog size.

Summary

You built a read-heavy catalog service that fights the two most common Hibernate performance problems from opposite directions: `@Cacheable` with `CacheConcurrencyStrategy.READ_WRITE` avoids re-querying the database for entities that rarely change, while `JOIN FETCH` collapses the classic N+1 query pattern into a single efficient join when a lazy association is genuinely needed on every row. Recognizing when data is read far more than it is written, and profiling actual query counts rather than guessing, is the same instinct that drives performance work in every real Hibernate application beyond this project.