Deepen PracticeOrdered learning track

Caching Strategy: First-Level, Second-Level, Query Cache

Learn Java Persistence, Database Integration, and JPA - Part 023

Caching strategy for Java Persistence: first-level cache, second-level cache, query cache, invalidation, consistency risk, region design, Spring cache boundary, and production observability.

22 min read4208 words
PrevNext
Lesson 2335 lesson track2029 Deepen Practice
#java#persistence#jpa#jakarta-persistence+9 more

Part 023 — Caching Strategy: First-Level, Second-Level, Query Cache

Part 022 covered isolation and anomaly modelling: what can go wrong when concurrent transactions interleave.

This part moves to another form of hidden state: cache.

Caching is one of the most misunderstood areas in Java Persistence. Many engineers enable it because a dashboard is slow. Then, months later, the system has stale reads, inconsistent screens, mysterious bugs after bulk updates, or memory pressure that is harder to debug than the original query.

A senior persistence engineer treats cache as a consistency boundary, not only a performance feature.

The question is not:

“How do I cache this entity?”

The better question is:

“Which data can safely be reused, for which caller, under which invalidation rule, and what is the correctness cost if it is stale?”


1. Kaufman Framing: Learn Enough to Self-Correct

Kaufman's method asks us to deconstruct the skill and identify the smallest useful feedback loop.

For persistence caching, the real skill is not memorizing cache annotations. The skill is being able to answer these questions before enabling any cache:

QuestionWhy It Matters
Is this value immutable, rarely changing, or frequently changing?Mutation frequency drives invalidation cost.
Is stale data acceptable?Some screens tolerate stale data; enforcement decisions usually do not.
Who writes this data?Hibernate-only writes are easier to track than writes from ETL, SQL jobs, another service, or database triggers.
Is the cache local or clustered?Local cache may diverge across nodes.
Is the cached result an entity, query result, DTO, or computed view?Each has different invalidation semantics.
Is this read path inside a transaction that must make a decision?Decision reads need stronger consistency than display reads.
Can you measure hit ratio, eviction, stale read risk, and memory footprint?Unobserved cache is a production liability.

A useful rule:

Cache only data whose correctness model you can explain without hand-waving.


2. The Three Cache Layers You Must Separate

Java persistence applications commonly have at least three cache-like layers:

These layers are not equivalent.

LayerScopeStoresEnabled By DefaultMain Purpose
Persistence context / first-level cacheOne EntityManager / Hibernate SessionManaged entity instancesYesIdentity, unit of work, dirty checking
Second-level cacheShared across sessions in same app/cluster depending providerEntity state, collections, natural-id dataNoReuse entity data across transactions
Query cacheShared across sessions if enabledQuery result identifiers / scalar result metadataNoReuse query result sets
Spring Cache / application cacheApplication-definedDTOs, computed results, remote callsNoBusiness-level caching outside ORM semantics
Database buffer cacheDatabase enginePages/blocksDatabase-managedReduce physical I/O

The important mental model:

The first-level cache is required for JPA semantics. The second-level cache and query cache are optional optimization layers. Spring Cache is a different abstraction altogether.


3. First-Level Cache Recap: Identity, Not Performance Decoration

Part 012 already covered persistence context deeply, so this section is intentionally focused on caching strategy.

The first-level cache exists because JPA needs identity stability inside one persistence context.

Order a = entityManager.find(Order.class, orderId);
Order b = entityManager.find(Order.class, orderId);

assert a == b;

Within one persistence context, the same database row maps to the same managed Java object.

This is not optional.

It supports:

  • identity map semantics
  • dirty checking
  • cascading operations
  • write-behind
  • repeatable object reference inside the same persistence context
  • relationship consistency within the managed graph

But first-level cache can also mislead engineers.

Example:

Order order = entityManager.find(Order.class, id);

jdbcTemplate.update(
    "update orders set status = 'CANCELLED' where id = ?",
    id
);

Order again = entityManager.find(Order.class, id);

// still the managed object already in the persistence context
System.out.println(again.getStatus());

Depending on state and refresh behavior, the second find() may not observe the direct SQL update. The persistence context is not a real-time database mirror.

When mixing ORM and direct SQL, the engineer must control synchronization explicitly:

entityManager.flush();
entityManager.clear();

or:

entityManager.refresh(order);

Practical Invariant

Inside a persistence context, JPA prioritizes identity and unit-of-work semantics over repeatedly re-reading the database.

This is desirable for write path correctness, but dangerous if you expect every read to hit the database.


4. Second-Level Cache: Shared Entity State Across Persistence Contexts

The second-level cache is different from the first-level cache.

It is not bound to one EntityManager. It is shared across persistence contexts, and depending on the cache provider/configuration, possibly across application nodes.

The second-level cache typically stores entity state, not live managed object instances.

A later transaction can load entity state from L2 and create a managed instance in its own persistence context.

What L2 Cache Is Good For

Good candidates:

  • country/currency/reference tables
  • product catalog data that changes through controlled paths
  • workflow definitions or static configuration
  • authorization metadata with explicit invalidation
  • rarely changing lookup entities
  • data read frequently by primary key

Poor candidates:

  • frequently updated counters
  • queue-like tables
  • audit/event tables
  • financial balances used for decisions
  • approval state with strict freshness requirements
  • rows modified by multiple systems outside Hibernate
  • user sessions or rapidly changing operational state

The Key Risk

The second-level cache is useful only if invalidation is trustworthy.

If another service, SQL script, trigger, ETL job, or admin tool updates the same rows without notifying the cache, the cache can serve stale state.

That is not a Hibernate bug. That is a consistency design bug.


5. Cacheability Is a Domain Decision

Do not decide cacheability based only on table size.

A small table can be unsafe to cache if it controls high-risk decisions.

A large table can be safe to cache partially if the cached subset is immutable.

A better classification:

Data ClassExampleCache Suitability
Immutable reference dataISO currency, country codeExcellent
Append-only immutable factshistorical audit eventUsually not entity-cache candidate; maybe query/read-model cache
Rarely changed configurationSLA policy, product typeGood with explicit invalidation/versioning
Mutable operational statecase status, reservation quantityRisky
Financial/regulatory decision statebalance, enforcement eligibilityUsually avoid unless design is very explicit
User-specific derived viewdashboard countCache DTO/result with TTL, not entity state
External service lookupgeocoding, risk score resultSpring/application cache may be better

Engineering Rule

Entity cache is best for stable identity-addressable state. Application cache is better for computed views. Database indexes/query tuning are better for most dynamic query performance problems.


6. Hibernate L2 Cache Regions

Hibernate organizes second-level cache entries into regions.

Typical region categories:

  • entity regions
  • collection regions
  • natural-id regions
  • query result regions
  • timestamp/update tracking regions

Region design matters because eviction, TTL, memory size, and consistency policies are often configured by region.

Example conceptual configuration:

hibernate.cache.use_second_level_cache=true
hibernate.cache.region.factory_class=jcache
hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider

Example entity cache annotation:

@Entity
@Cacheable
@org.hibernate.annotations.Cache(
    usage = CacheConcurrencyStrategy.READ_WRITE,
    region = "reference.currency"
)
public class Currency {

    @Id
    private String code;

    private String displayName;

    private int minorUnit;
}

@Cacheable is the JPA-level signal. Hibernate-specific @Cache gives finer control.

Region Naming Convention

A production system should avoid default region chaos.

Use explicit regions:

reference.country
reference.currency
reference.workflow-definition
catalog.product-summary
auth.permission

Avoid region names like:

com.example.domain.Currency
com.example.domain.Product

Class names leak implementation structure. Region names should express operational policy.


7. Cache Concurrency Strategies

Hibernate cache concurrency strategy defines how cached data coordinates with updates.

Common strategies:

StrategyUse CaseRisk
READ_ONLYImmutable dataBreaks if data changes
NONSTRICT_READ_WRITERare updates, stale reads acceptableStale window possible
READ_WRITEMutable data needing stronger consistencyMore overhead; provider support matters
TRANSACTIONALJTA/transactional cache providerComplex; provider/environment dependent

READ_ONLY

Use for truly immutable data.

@Entity
@Cacheable
@org.hibernate.annotations.Cache(
    usage = CacheConcurrencyStrategy.READ_ONLY,
    region = "reference.country"
)
public class Country {
    @Id
    private String code;
    private String name;
}

If the application attempts to update a read-only cached entity, that is a modelling error.

NONSTRICT_READ_WRITE

Use when stale reads are acceptable for a short period.

Example:

  • marketing category label
  • product description not used for fulfillment decision
  • UI display metadata

Do not use it for:

  • inventory
  • workflow state
  • enforcement decision
  • authorization boundary unless invalidation is explicit and reliable

READ_WRITE

Use when mutable cached state is updated through Hibernate and the cache provider supports safe coordination.

This is still not magic. It does not protect against external writers bypassing Hibernate.

TRANSACTIONAL

Use only when you really understand the cache provider, JTA setup, and cluster semantics.

In most Spring Boot service applications, READ_ONLY, selective READ_WRITE, or no entity L2 cache is usually simpler.


8. Query Cache: More Dangerous Than It Looks

The query cache is not the same thing as the second-level cache.

A query cache stores query result information, often identifiers of matching entities plus metadata. If the entities themselves are not in L2 cache, Hibernate may still need to load them.

When Query Cache Helps

Query cache may help when:

  • query result set is stable
  • query is executed frequently with same parameters
  • underlying tables change rarely
  • result entities are also cached
  • stale result risk is acceptable or invalidation is reliable

Good example:

@Query("""
    select c
    from Currency c
    where c.enabled = true
    order by c.code
""")
@QueryHints({
    @QueryHint(name = "org.hibernate.cacheable", value = "true"),
    @QueryHint(name = "org.hibernate.cacheRegion", value = "query.reference.enabled-currencies")
})
List<Currency> findEnabledCurrencies();

When Query Cache Hurts

Query cache can hurt when:

  • tables change frequently
  • many parameter combinations create low hit ratio
  • query cache invalidates too often
  • result set is large
  • query result is user-specific
  • query is already cheap because of indexes
  • stale result would break correctness

Bad candidate:

List<Case> findByAssigneeIdAndStatusAndPriorityAndCreatedAtBetween(...);

This likely has high cardinality parameter combinations and operationally fresh data. Indexing and query design are usually better.


9. Cache Invalidation: The Hard Problem

Cache invalidation is where persistence caching becomes architecture.

There are only a few broad strategies:

StrategyDescriptionFit
Write-through ORM invalidationHibernate updates/evicts cache when it writesGood if Hibernate is the only writer
TTLCached value expires after timeGood for stale-tolerant reads
Explicit evictionApplication evicts known regions/keysGood for admin/config updates
Versioned configurationNew version published; readers use version keyGood for policy/workflow definitions
Event-driven invalidationData-change event evicts cache across nodesGood but operationally complex
Avoid cacheUse DB/index/materialized viewBest when correctness risk dominates

External Writers Break Assumptions

Assume this entity is cached:

@Entity
@Cacheable
public class WorkflowDefinition {
    @Id
    private UUID id;
    private String stateMachineJson;
    private boolean active;
}

If an admin SQL script runs:

update workflow_definition
set active = false
where id = '...';

Hibernate does not automatically know this update happened. Existing L2 cache entries may remain stale.

Possible fixes:

  • disallow direct SQL writes
  • route admin writes through application service
  • evict affected cache region after SQL update
  • include versioned effective date model
  • avoid entity cache and use DB read
  • move config to external configuration store with versioning

The right choice depends on operational control.


10. Bulk Updates and Cache Consistency

Bulk JPQL/SQL operations are a common cache hazard.

@Modifying
@Query("""
    update Case c
    set c.status = :newStatus
    where c.status = :oldStatus
""")
int bulkTransition(
    @Param("oldStatus") CaseStatus oldStatus,
    @Param("newStatus") CaseStatus newStatus
);

Bulk operations bypass normal entity dirty checking. They can also leave persistence context state stale.

After bulk update/delete:

entityManager.flush();
entityManager.clear();

For second-level cache, consider region eviction:

entityManagerFactory.getCache().evict(Case.class);

With Hibernate native API:

sessionFactory.getCache().evictEntityData(Case.class);
sessionFactory.getCache().evictCollectionData(Case.class.getName() + ".tasks");

Production Rule

Any bulk operation should have an explicit cache and persistence-context synchronization plan.

Do not hide bulk updates in repository methods without documenting the side effects.


11. Collection Cache: Be Very Selective

Hibernate can cache collection associations.

Example:

@Entity
public class Role {

    @Id
    private UUID id;

    @ManyToMany
    @org.hibernate.annotations.Cache(
        usage = CacheConcurrencyStrategy.READ_WRITE,
        region = "auth.role-permissions"
    )
    private Set<Permission> permissions = new HashSet<>();
}

Collection cache is useful when:

  • the collection changes rarely
  • membership is small or bounded
  • stale membership is acceptable or invalidated explicitly
  • loading the collection is frequent and expensive

Dangerous when:

  • collection is large
  • collection changes frequently
  • collection is used for critical authorization or eligibility decisions
  • many-to-many membership is updated externally
  • collection order/filters vary per query

Collection cache stores association membership, not arbitrary filtered subsets.

If you frequently ask:

permissions for role X where permission is active and scope = Y

that may not fit simple collection caching.


12. Natural ID Cache

Some entities are frequently looked up by natural key.

Example:

@Entity
@NaturalIdCache
@Cacheable
@org.hibernate.annotations.Cache(
    usage = CacheConcurrencyStrategy.READ_WRITE,
    region = "reference.currency"
)
public class Currency {

    @Id
    @GeneratedValue
    private Long id;

    @NaturalId
    @Column(nullable = false, unique = true, updatable = false)
    private String code;
}

Lookup:

Currency currency = session.byNaturalId(Currency.class)
    .using("code", "IDR")
    .load();

Natural-id cache is useful when:

  • natural key is stable
  • lookup by natural key is frequent
  • natural key is unique and immutable

Avoid mutable natural ids unless you have a strong reason. Mutable business keys complicate cache invalidation and equality semantics.


13. Spring Cache vs Hibernate Cache

Do not confuse these two abstractions.

Hibernate L2 Cache

Caches persistence-layer entity state.

@Entity
@Cacheable
public class ProductCategory { ... }

It participates in ORM loading.

Spring Cache

Caches method return values.

@Cacheable(cacheNames = "dashboard.case-counts", key = "#assigneeId")
public CaseDashboardSummary loadDashboard(UUID assigneeId) {
    return repository.loadDashboard(assigneeId);
}

It does not know entity lifecycle unless you design that integration.

Which One Should You Use?

NeedPrefer
Reuse stable entity by primary keyHibernate L2 cache
Cache computed DTO/report summarySpring Cache
Cache external API resultSpring Cache
Avoid repeated query for reference dataHibernate L2 or Spring Cache depending access pattern
Cache user-specific dashboardSpring Cache with TTL/key design
Cache mutable decision stateUsually avoid both; tune DB/query/isolation

A common high-quality design:

  • keep entities mostly uncached except reference/config data
  • use DTO/read-model cache for expensive display screens
  • use explicit TTL/invalidation for application cache
  • use database constraints/isolation for decision correctness

14. Cache Key Design for Application Cache

When using Spring Cache or Redis, key design becomes correctness design.

Bad key:

@Cacheable("case-search")
public Page<CaseSummary> search(CaseSearchRequest request, Pageable pageable) { ... }

Why bad?

  • request may not have stable equals/hashCode
  • pageable sort may be ignored accidentally
  • tenant/security scope may be missing
  • stale results after updates may be unacceptable
  • high cardinality can destroy hit ratio

Better key:

@Cacheable(
    cacheNames = "case-dashboard-summary",
    key = "T(java.lang.String).format('%s:%s:%s', #tenantId, #assigneeId, #asOfDate)"
)
public CaseDashboardSummary loadDashboard(
    UUID tenantId,
    UUID assigneeId,
    LocalDate asOfDate
) {
    return repository.loadDashboard(tenantId, assigneeId, asOfDate);
}

Even better for serious systems: build a typed key.

public record CaseDashboardCacheKey(
    UUID tenantId,
    UUID assigneeId,
    LocalDate asOfDate,
    String schemaVersion
) {}

Include:

  • tenant
  • user or role scope when relevant
  • locale if output is localized
  • currency/time zone if output depends on them
  • filter parameters
  • pagination/sort parameters
  • data version/schema version
  • authorization scope when output differs by permission

Key Invariant

If two callers can see different correct answers, they must not share the same cache key.


15. TTL Is Not a Correctness Strategy by Itself

TTL means stale data eventually disappears.

It does not mean stale data is safe.

TTL is acceptable when:

  • data is used for display only
  • stale value does not trigger irreversible action
  • stale window is explicit
  • user experience can tolerate it
  • metrics expose stale/hit behavior

TTL is not enough when:

  • approving, rejecting, charging, allocating, reserving, or enforcing
  • authorization or permission boundary changes
  • legal/regulatory deadline decisions are made
  • external side effects depend on the value

For high-risk decisions, do not rely on cached reads. Re-read under the right transaction/isolation/lock or encode invariant as a database constraint.


16. Decision Read vs Display Read

This distinction is crucial.

A display read answers:

“What should the UI show?”

A decision read answers:

“Am I allowed to perform this state transition?”

Example:

public CaseSummaryView showCase(UUID caseId) {
    return cache.get(caseId); // maybe acceptable
}

But:

@Transactional
public void approveCase(UUID caseId, UUID reviewerId) {
    Case c = repository.findForDecision(caseId);
    c.approveBy(reviewerId);
}

The decision path should not depend on stale cached view data.

Rule

Cache views aggressively only when commands revalidate invariants against authoritative state.

This is the same principle used in high-scale systems: read models can be stale, command models must enforce correctness.


17. Cache and Transaction Boundaries

Caching inside transactions can be subtle.

Consider:

@Transactional
public ProductView updateProductName(UUID id, String newName) {
    Product product = productRepository.findById(id).orElseThrow();
    product.rename(newName);
    return productViewCache.get(id);
}

The cache read may return stale view before commit.

Another common issue:

@Transactional
@CacheEvict(cacheNames = "product-view", key = "#id")
public void updateProduct(UUID id, ProductPatch patch) {
    Product product = repository.findById(id).orElseThrow();
    product.apply(patch);
}

Depending on cache abstraction behavior, eviction may happen before transaction commit. If the transaction rolls back, cache was still evicted. That is usually safe but can be inefficient. Worse is populating cache before commit with data that later rolls back.

For critical flows, use after-commit hooks.

@Transactional
public void updatePolicy(UUID id, PolicyPatch patch) {
    Policy policy = repository.findById(id).orElseThrow();
    policy.apply(patch);

    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                policyCache.evict(id);
            }
        }
    );
}

Rule

Publish cache invalidations after commit when external observers could act on them.

This mirrors the transactional outbox mindset from later Part 030.


18. Multi-Node Cache Consistency

A local cache per node is easy but can diverge.

If Node A updates a cached row and only Node A evicts its local cache, Node B may keep stale state.

Options:

OptionTrade-Off
No local cache for mutable dataSimpler correctness
Short TTLLimits stale window, does not remove it
Distributed cacheMore operational complexity
Pub/sub invalidationRequires reliable event delivery
Versioned keysAvoids direct invalidation but increases storage/cardinality
Database-backed readOften best for critical mutable state

Cluster Rule

A local cache is a per-node truth approximation, not a system-wide truth.

Use it accordingly.


19. Cache Stampede and Thundering Herd

Caching can create a new failure mode: many requests miss at once and hit the database.

Examples:

  • cache entry expires during traffic spike
  • deployment clears all local caches
  • Redis restart empties hot keys
  • query cache invalidates after mass update

Mitigation:

  • stagger TTL with jitter
  • use request coalescing/single-flight
  • prewarm known reference data
  • use stale-while-revalidate for display-only data
  • rate-limit expensive recomputation
  • cap concurrent cache rebuilds

Example single-flight style concept:

public ProductCatalogView getCatalog(UUID tenantId) {
    return cache.getOrLoad(
        key(tenantId),
        () -> catalogLoadLocks.withLock(tenantId, () -> repository.loadCatalog(tenantId))
    );
}

Do not let cache misses become a coordinated database attack.


20. Memory Footprint and Object Graph Risk

L2 cache entries consume memory somewhere.

Application cache entries consume memory somewhere.

Large object graphs are especially dangerous.

Bad idea:

@Cacheable("case-detail")
public CaseDetailView loadCaseDetail(UUID caseId) {
    return repository.loadHugeCaseGraph(caseId);
}

If CaseDetailView contains:

  • hundreds of tasks
  • attachments metadata
  • comments
  • audit trail
  • dynamic permissions
  • computed SLA state

then caching it may create memory pressure and invalidation complexity.

Better:

  • split view into smaller fragments
  • cache stable subcomponents only
  • use pagination for child collections
  • use materialized summary table
  • cache small DTO summary, not the full graph

Size Rule

Cache entries should be bounded, understandable, and measurable.

If you cannot estimate entry size and cardinality, you do not yet have a cache design.


21. Cache Observability

Every production cache needs metrics.

At minimum:

MetricWhy
hit count / hit ratioIs cache actually helping?
miss countIs load path expensive?
eviction countAre entries churned too quickly?
put countAre writes excessive?
entry countMemory/cardinality risk
load timeCost of cache miss
stale incident countCorrectness risk
invalidation event lagMulti-node consistency risk
top keys/regionsHotspot diagnosis

For Hibernate:

  • enable statistics in non-production/perf env carefully
  • inspect second-level cache region stats
  • inspect query cache hit/miss/put stats
  • correlate with DB query count and latency

Conceptual diagnostic:

Statistics stats = sessionFactory.getStatistics();

SecondLevelCacheStatistics region =
    stats.getSecondLevelCacheStatistics("reference.currency");

System.out.println(region.getHitCount());
System.out.println(region.getMissCount());
System.out.println(region.getPutCount());

Exact API names may differ by Hibernate version, but the concept remains: observe per-region behavior.

Observability Rule

No cache should be considered production-ready until you can prove hit ratio, miss cost, eviction behavior, and memory footprint.


22. Testing Cache Behavior

Unit tests rarely catch caching bugs.

You need integration tests that use multiple persistence contexts.

Test L2 Cache Hit Across Transactions

@Test
void loadsReferenceDataFromSecondLevelCache() {
    Currency first;
    Currency second;

    tx.executeWithoutResult(status -> {
        first = entityManager.find(Currency.class, "IDR");
    });

    tx.executeWithoutResult(status -> {
        second = entityManager.find(Currency.class, "IDR");
    });

    // Assert Hibernate statistics or SQL count.
}

Do not test L2 cache by loading twice in the same transaction. That only proves first-level cache behavior.

Test Stale Risk Explicitly

@Test
void directSqlUpdateRequiresCacheEviction() {
    tx.executeWithoutResult(s -> {
        repository.findById(currencyId).orElseThrow();
    });

    jdbcTemplate.update(
        "update currency set display_name = ? where code = ?",
        "Rupiah Baru",
        "IDR"
    );

    tx.executeWithoutResult(s -> {
        Currency cached = repository.findById(currencyId).orElseThrow();
        // This test should document expected behavior:
        // stale until eviction, or fresh because cache is disabled/evicted.
    });
}

A good persistence test does not only assert happy path. It documents consistency assumptions.


23. Cache Decision Framework

Use this decision tree before enabling cache.

This tree intentionally starts with database/query improvement. Caching should not hide bad query shape unless caching is part of the intended architecture.


24. Anti-Patterns

Anti-Pattern 1: Enabling L2 Cache Globally

hibernate.cache.use_second_level_cache=true

Then annotating many entities because performance is bad.

Problem:

  • unclear consistency model
  • memory growth
  • stale data risk
  • hidden region behavior
  • unpredictable cache churn

Better:

  • enable selectively
  • region by region
  • one use case at a time
  • measure before/after

@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
Page<Case> search(...many filters...);

Problem:

  • low hit ratio
  • high cardinality
  • frequent invalidation
  • stale results
  • memory churn

Better:

  • indexes
  • keyset pagination
  • DTO projection
  • search engine/read model if needed

Anti-Pattern 3: Caching Command Decisions

if (cachedEligibilityService.isEligible(caseId)) {
    approve(caseId);
}

Problem:

  • stale eligibility can approve invalid transition
  • cache TTL does not guarantee correctness

Better:

@Transactional
public void approve(UUID caseId) {
    Case c = repository.findForUpdateOrVersionCheck(caseId);
    c.assertEligibleForApproval();
    c.approve();
}

Cache the UI hint, not the command invariant.


Anti-Pattern 4: Missing Tenant/Security Dimension in Cache Key

@Cacheable("case-summary")
public CaseSummary summary(UUID caseId) { ... }

If two users see different fields based on permission, this leaks data.

Better:

@Cacheable(
    cacheNames = "case-summary",
    key = "#tenantId + ':' + #viewerId + ':' + #caseId + ':' + #permissionVersion"
)
public CaseSummary summary(UUID tenantId, UUID viewerId, UUID caseId, long permissionVersion) { ... }

Or avoid caching permission-shaped output.


Anti-Pattern 5: Cache Without Eviction Ownership

A team enables cache but no team owns invalidation.

Symptoms:

  • “works after restart” bugs
  • inconsistent nodes
  • stale reference data
  • manual Redis deletion during incidents

Better:

  • document cache ownership
  • document writers
  • document invalidation triggers
  • expose admin eviction endpoint with audit
  • version keys for high-risk reference data

25. Production Cache Review Checklist

Before shipping a persistence cache, answer these:

Correctness

  • What is the authoritative source?
  • Is stale data acceptable?
  • Which use cases consume cached data?
  • Are any command decisions based on cached data?
  • Are all writers known?
  • What happens after direct SQL/bulk updates?
  • What happens across multiple application nodes?

Design

  • What is cached: entity, collection, query result, DTO, computed result?
  • What is the cache key?
  • Does key include tenant/security/filter/version dimensions?
  • What is the TTL?
  • What is the invalidation trigger?
  • Is region naming explicit?
  • Is entry cardinality bounded?

Operations

  • How do you inspect hit/miss ratio?
  • How do you evict one key/region safely?
  • How do you detect stale-read incidents?
  • What is the memory budget?
  • What happens after deploy/restart/cache loss?
  • What happens if cache provider is down?

Testing

  • Is cache behavior tested across transactions?
  • Is direct SQL/bulk update behavior tested?
  • Is multi-tenant key separation tested?
  • Is authorization-shaped output tested?
  • Is cache stampede behavior considered?

For most enterprise Java systems:

  1. Keep first-level cache as normal JPA behavior.
  2. Do not enable L2 cache globally for all entities.
  3. Cache only reference/config entities with explicit regions.
  4. Prefer READ_ONLY for immutable reference data.
  5. Use READ_WRITE only when all writers are controlled and provider behavior is understood.
  6. Avoid query cache until you have measured repeated identical query patterns.
  7. Prefer DTO/application cache for expensive display summaries.
  8. Never use stale cache as the authority for commands.
  9. Treat direct SQL and bulk updates as cache invalidation events.
  10. Monitor hit ratio, miss cost, evictions, and memory.

27. Example: Reference Data Cache Done Well

@Entity
@Table(name = "currency")
@Cacheable
@org.hibernate.annotations.Cache(
    usage = CacheConcurrencyStrategy.READ_ONLY,
    region = "reference.currency"
)
public class Currency {

    @Id
    @Column(length = 3)
    private String code;

    @Column(nullable = false)
    private String displayName;

    @Column(nullable = false)
    private int minorUnit;

    protected Currency() {
    }

    public String code() {
        return code;
    }

    public int minorUnit() {
        return minorUnit;
    }
}

Repository:

public interface CurrencyRepository extends JpaRepository<Currency, String> {

    @Query("""
        select c
        from Currency c
        order by c.code
    """)
    @QueryHints({
        @QueryHint(name = "org.hibernate.cacheable", value = "true"),
        @QueryHint(name = "org.hibernate.cacheRegion", value = "query.reference.all-currencies")
    })
    List<Currency> findAllOrdered();
}

Operational policy:

Region: reference.currency
Strategy: READ_ONLY
Writers: migration only
Invalidation: application restart after migration, or explicit region eviction in admin maintenance
Stale tolerance: acceptable only between deployment and migration? No runtime mutation expected
Owner: Platform data team
Metrics: L2 hit/miss, query cache hit/miss

This is a real cache design because it includes ownership and mutation policy.


28. Example: Dashboard Cache Done Outside ORM

public record CaseDashboardSummary(
    UUID assigneeId,
    long openCases,
    long overdueCases,
    long escalatedCases,
    Instant generatedAt
) {}
@Service
public class CaseDashboardService {

    private final CaseDashboardRepository repository;

    public CaseDashboardService(CaseDashboardRepository repository) {
        this.repository = repository;
    }

    @Cacheable(
        cacheNames = "dashboard.case-summary",
        key = "#tenantId + ':' + #assigneeId + ':' + #date"
    )
    @Transactional(readOnly = true)
    public CaseDashboardSummary loadSummary(
        UUID tenantId,
        UUID assigneeId,
        LocalDate date
    ) {
        return repository.loadSummary(tenantId, assigneeId, date);
    }
}

Why this is better than entity cache:

  • result is a DTO
  • data is display-only
  • key includes tenant/user/date
  • stale tolerance can be defined
  • no managed entity leakage
  • invalidation can be TTL-based or event-based

Command path still revalidates:

@Transactional
public void escalateCase(UUID caseId, UUID actorId) {
    Case c = caseRepository.findById(caseId).orElseThrow();
    c.escalate(actorId, clock.instant());
}

The cached dashboard is not the source of truth.


29. Failure Mode Table

SymptomLikely CauseFix
User sees old reference labelL2 cache not evicted after admin updateRoute writes through app or evict region
Cache hit ratio near zeroHigh-cardinality query cacheRemove query cache; tune query/index
Memory grows after enabling cacheUnbounded keys/large graphsBound cache, split entries, measure size
Different nodes show different valueLocal cache invalidation missingDistributed invalidation or avoid local mutable cache
Command approves invalid stateDecision read used stale cacheRevalidate inside transaction
“Works after restart”Stale L2/application cacheAdd explicit invalidation and observability
Bulk update not reflectedPersistence context/L2 cache staleFlush/clear and evict affected regions
Cache miss storm after deployCold cache stampedePrewarm, jitter, request coalescing

30. Key Takeaways

  • The first-level cache is part of JPA's identity and unit-of-work model.
  • The second-level cache is optional shared entity-state caching.
  • The query cache caches query results, not necessarily entity state.
  • Spring Cache is application-level method-result caching, not ORM lifecycle caching.
  • Cache correctness depends on invalidation, writer ownership, and stale-read tolerance.
  • Do not use cached display data as command authority.
  • Cache stable reference/config data first; avoid caching highly mutable operational state.
  • Bulk updates and external writers must have explicit cache synchronization plans.
  • Multi-node local caches can diverge without distributed invalidation.
  • A cache without metrics is not an engineering feature; it is an unobserved bet.

31. Practice Drill

Pick one entity from your current system and classify it:

Entity:
Mutation frequency:
All writers:
Used for display, decision, or both:
Stale tolerance:
Cache candidate: yes/no
Cache type: L2 / query cache / Spring cache / none
Invalidation rule:
Metrics:
Failure mode if stale:

Then force yourself to write the sentence:

“It is safe to cache this because ...”

If the sentence is vague, do not enable the cache yet.


32. Where This Leads Next

Caching often appears inside repository methods and service calls. That means the next design boundary is the repository layer itself.

Part 024 moves into Spring Data JPA repositories:

  • what repositories should own
  • what they should not own
  • derived query limits
  • @Query and @Modifying
  • projections and specifications
  • repository leak prevention
  • custom repository implementations
  • transaction and service boundary interaction
Lesson Recap

You just completed lesson 23 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.