LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.