Cache Grails service results with SimpleCacheManager
Grails services commonly combine GORM lookups, pricing rules and remote API calls. Repeating identical work on every request adds latency, database load and avoidable provider charges. A cache stores an already computed result for a short period, while SimpleCacheManager keeps that result in the application process and exposes it through a named cache.
A practical example is an Australian retail catalogue. A shopper checking an AUD price on a phone while commuting in Melbourne should get a fast response, but cached stock or pricing must not become stale or cross account boundaries. The goal is therefore to cache stable, expensive reads behind a clear key, lifetime and invalidation policy.
Why service-level caching matters
Place caching at the service boundary rather than in a controller. Controllers serve many request types, while services define what a business operation means and which callers may reuse it. A cached service result is also easier to exercise in tests than response markup embedded in a view.
On a hit, the service can return without repeating its database or API work. That reduces tail latency during busy periods and protects rate-limited integrations. It also gives operators a single place to observe hit rate, load time and eviction counts.
The trade-off is consistency and memory. An in-process manager cannot coordinate entries across several Grails instances, and a cache without expiry can preserve outdated data. Treat every cached value as a temporary performance aid, never as the authoritative record.
Cache mutable domain objects only when callers cannot alter them. Returning the same list instance to several requests can let one caller modify what another sees. Store immutable summaries or defensively copy collections at the service boundary.
Designing safe cache keys
A cache key must identify the complete result being requested. For a catalogue search, a stable key might combine tenant, category, currency, locale and a normalised search term. Include an authorisation scope when visibility differs by user, and hash long or sensitive inputs instead of placing names, email addresses or identifiers directly in keys. Normalise case and whitespace, and define safe delimiters so two different queries cannot produce the same key.
Australian content can add another segmentation test. State rules differ for online pokies, with NSW and ACT prohibiting them while Victoria permits them only through approved licensed venues and adults must be at least 18. A publisher must therefore keep age-ineligible audiences away from a panel promoting high volatility pokies, even if another visitor has already warmed the same page.
Keep key construction in one helper and version it with the result shape. When the data model changes, a new version prevents old serialised objects from being interpreted as current values. Store the key metadata needed for safe eviction rather than trying to reconstruct it from memory.
Configuring the SimpleCacheManager
SimpleCacheManager is suited to lightweight, in-process caching. It avoids a network hop and is easy to operate for a small single-node application, but each Grails node maintains an independent copy. It cannot share entries with a Melbourne node when the active instance is in another region.
Enable the cache plugin used by your Grails release and select the simple manager where that release supports it. Older Grails configurations commonly use grails.plugin.cache.manager = 'simple'; Grails 3 and 4 expose manager settings through their supported cache-plugin DSL. The exact package and bean registration differ between releases, so use the class supplied by your plugin. The service code can still rely on the standard CacheManager contract.
Give the manager a named catalogue cache and a five-minute time to live. Where supported, enforce an entry ceiling such as 500 items; otherwise restrict the key namespace and monitor heap usage. Configure size and expiry on the named cache rather than in every method. A bounded lifetime limits stale inventory, while a ceiling prevents an unusual search mix from consuming the heap.
Caching a service method
Code against the cache manager's Cache contract so the service remains testable and portable. The example below assumes the bean is named simpleCacheManager; change the qualifier if your plugin registers a different bean name.
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.cache.Cache
import org.springframework.cache.CacheManager
import java.util.concurrent.Callable
class CatalogueSearchService {
private final Cache catalogueCache
CatalogueSearchService(
@Qualifier('simpleCacheManager') CacheManager cacheManager) {
catalogueCache = cacheManager.getCache('catalogue')
}
List<ProductSummary> search(
String tenantId, String term, String currency, String locale) {
String cacheKey =
"$tenantId|$currency|$locale|${normalise(term)}"
catalogueCache.get(cacheKey, {
loadPublishedProducts(term, currency)
} as Callable)
}
private List<ProductSummary> loadPublishedProducts(
String term, String currency) {
ProductSummary.findAllByTermAndCurrency(term, currency)
}
private String normalise(String value) {
value.trim().toLowerCase(Locale.ROOT)
}
}
The callable form gives the manager an opportunity to coordinate the miss and load. A simple implementation may still calculate the same value more than once under concurrent misses, so popular keys need loaders that are safe to run repeatedly. Decide whether an empty result should be cached; negative caching can hide newly created records unless its lifetime is short and writes invalidate it.
Keep the loader in a repository or separate transactional service when it performs database work. Opening a transaction around the entire cached method can retain a database connection while a remote call is in progress. Separate the slow calculation from cache orchestration, then invalidate entries after any write that changes the result.
Managing freshness and invalidation
Expiry is a safety net, not the only invalidation mechanism. Delete or version an entry immediately after a relevant product, price or permission change. For broad catalogue updates, a namespace version lets old keys expire naturally without scanning every key, while precise product updates should target known keys where possible.
A travel feature such as world through a viewer illustrates the same freshness problem: a page assembled from older observations should not be presented as current. Service caches need the same distinction between a reusable snapshot and live information, particularly when prices, stock or availability can change.
Australian privacy law adds another boundary. The Privacy Act 1988 and Australian Privacy Principles require reasonable security and limited use of personal information. Avoid caching customer profiles, health details or full identifiers. Where a personalised result is essential, use a short lifetime, restrict access and apply the same controls used for the primary datastore. Cached data remains subject to the Act.
Testing and observing cache behaviour
Test hits, misses, expired values and failed loaders with an isolated cache instance. A unit test should prove that two equivalent requests produce one loader call, while a changed currency or tenant produces a different key. Add concurrency tests for popular keys because several requests can miss together and stampede the database.
Expose metrics for hits, misses, load duration, evictions and errors. Pair them with the uncached operation duration so a low hit rate or expensive invalidation does not hide behind improved average latency. Logs should include a safe key identifier, not the raw cached payload or personal data. A response header or trace attribute can also reveal whether a request was served from cache.
Plan for regional deployment before enabling the cache. An application hosted in AWS Sydney's ap-southeast-2 region may fail over to Melbourne's ap-southeast-4; an in-process cache starts empty on the new node, and warm-up traffic can briefly create a database surge. Limit entry size, stagger warm-up requests and provide a fallback that bypasses the cache when necessary.
A practical rollout checklist
Begin with a read-heavy operation whose source data changes less often than it is requested. Product category listings and reference configuration are safer starting points than payments, stock reservations or personalised recommendations. Establish the expected saving before choosing a lifetime.
A rollout should make ownership and failure behaviour explicit:
- Cache only results that are safe to reuse throughout the chosen time window.
- Construct keys from tenant, locale, currency and authorisation scope as needed.
- Set a short expiry and enforce a bounded entry count before enabling traffic.
- Invalidate or version affected keys after every write that changes cached data.
- Keep personal information and unrecoverable payment data outside cached values.
- Test concurrent misses, node restarts, cold starts and fallback behaviour under realistic load.
Release the cache behind a feature flag or equivalent rollout control. Compare database load and end-to-end latency, verify that invalidation reaches every write path, and retain a switch that bypasses cached reads if data integrity is ever in doubt.
Pick one expensive, stable Grails service method and instrument it today. Open a feature branch, implement the named cache, prove the miss-and-hit path in a test, then publish the measured hit ratio and database-load change for your team.