Configuring Grails Second-Level Cache with Ehcache

A Grails application often spends more time retrieving the same data than processing it. Repeated database queries, expensive view-model construction and remote service calls can make an otherwise efficient application feel slow. A second-level cache keeps selected results in memory so later requests can reuse them instead of repeating the full operation.

Ehcache is a practical choice for many Grails applications, particularly internal systems, content-heavy sites and modestly sized deployments. This guide explains how to connect the Grails cache abstraction to Ehcache, select sensible cache regions, apply annotations and manage invalidation without serving stale business data.

Choose The Correct Grails Cache Integration

Grails cache configuration depends on the framework generation and the plugin versions used by the application. Grails 2 projects commonly use the Grails cache plugin with an Ehcache provider, while Grails 3 and later projects may use a cache plugin version with Ehcache support or a separately declared provider. Start by checking BuildConfig.groovy, build.gradle or gradle.properties rather than copying a dependency from an unrelated tutorial.

For an older Grails application, a dependency may look similar to this:

dependencies {
    compile 'org.grails.plugins:cache:2.1.1'
}

A Grails 3 project might instead use:

dependencies {
    implementation 'org.grails.plugins:cache:3.0.5'
}

These versions are examples, not universal recommendations. Confirm the plugin’s compatibility with your Grails, Groovy and Hibernate versions. The Grails tutorials collection is useful for comparing framework-era conventions before changing an established build.

A cache plugin usually supplies annotations and a common API, while Ehcache manages storage, expiry and eviction. Keeping those responsibilities separate makes it easier to replace the provider later, but it also means that a configuration written for Ehcache 2 will not automatically work with Ehcache 3.

Define Cache Regions And Expiry Rules

A cache region is a named area for a particular kind of data. Good region names describe the data’s purpose, such as productById, navigation, exchangeRates or customerSummary. Separating regions lets you give frequently changing records a short time-to-live while allowing stable reference data to remain available for longer.

A typical cache configuration uses a closure in application.groovy or the configuration file expected by the installed plugin:

grails {
    cache {
        enabled = true
        config = {
            cache(
                name: 'productById',
                maxElementsInMemory: 1000,
                timeToIdleSeconds: 300,
                timeToLiveSeconds: 900,
                eternal: false
            )
        }
    }
}

The exact property names differ between plugin releases. Some integrations expect an XML file such as ehcache.xml, while others translate a Grails closure into provider settings. Treat the example as a model for the decisions you need to make: maximum entries, idle expiry, absolute expiry and whether entries may overflow to disk.

For an Australian retail application, a five-minute cache might suit stock displayed on a product page, while a daily cache could suit a list of states, postcodes or tax categories. Avoid storing sensitive personal information in a shared local cache unless the data handling, access boundaries and retention period have been reviewed against the Australian Privacy Principles.

Cache Service Methods Carefully

The most readable approach is to cache service methods that return stable, reusable results. Depending on the plugin version, annotations such as @Cacheable, @CachePut and @CacheEvict may be available. The annotation package and attribute names must match the installed plugin, so use the dependency’s documentation or generated API as the final authority.

A representative service might look like this:

import grails.plugin.cache.Cacheable

class ProductService {

    static transactional = false

    @Cacheable('productById')
    Product findProduct(Serializable id) {
        Product.get(id)
    }
}

With a method argument, the cache key is generally derived from the method name and arguments. That is convenient, but it can produce unexpected results when arguments are complex objects, maps or values whose equals() and hashCode() implementations are unstable. Prefer simple identifiers, or define a deliberate key strategy where the plugin supports one.

Caching a Hibernate domain object can also preserve more state than expected. A detached object may contain associations, lazy proxies or fields that no longer reflect the database. For read-heavy endpoints, a small immutable DTO or map containing the required fields is often safer. This is particularly useful when several Australian offices need the same public catalogue but customer-specific fields must never cross request boundaries.

Use Keys That Reflect The Business Result

A cache key must include every input that can change the returned value. If a service returns a price, the key may need the product ID, customer tier, currency and effective date. If it returns search results, include the normalised query, page, sort order and filters. A key based only on the product ID would be incorrect if prices differ between retail and wholesale customers.

Normalisation helps prevent needless duplicate entries. Convert case-insensitive search terms to a consistent case, sort filter values where order is irrelevant and use a stable representation for optional parameters. Keep keys short enough to inspect in logs, but descriptive enough to diagnose a cache hit or miss.

The separation between a cacheable computation and its presentation layer is a useful architectural principle beyond Grails. The discussion of separating application layers offers a related perspective: a reusable engine or service should have clear boundaries rather than carrying UI-specific assumptions into lower-level code.

Do not use a single “everything” region for unrelated values. A large shared region makes eviction broad and troubleshooting difficult. Separate product, user preference, configuration and external-response data so that a change in one area does not flush useful entries elsewhere.

Invalidate Entries When Data Changes

Expiry is a safety net, not a complete invalidation strategy. If an administrator changes a product description, the application should remove or refresh the relevant cached entry immediately. Otherwise, users may see the old value until the time-to-live expires.

A write operation can evict a key or an entire region:

import grails.plugin.cache.CacheEvict

class ProductService {

    @CacheEvict(value = 'productById', key = '#id')
    Product updateName(Long id, String newName) {
        Product product = Product.get(id)
        product.name = newName
        product.save(failOnError: true)
        product
    }
}

Expression syntax varies between cache libraries, and some Grails cache plugins use different annotation attributes. If key-level eviction is unavailable, clear the region after a successful transaction or call the cache service directly. Eviction should happen after the database commit when possible; clearing first can allow a concurrent request to repopulate the region with old data.

Bulk imports require special care. A nightly catalogue load might update thousands of products, making individual eviction expensive. In that case, clear the product region once the import completes, then allow demand to repopulate it. If the application runs across multiple nodes, local Ehcache instances need coordinated invalidation or a shared cache provider.

Configure Memory For The Deployment

Ehcache is generally a local in-process cache. That gives excellent access speed, but each application node has its own entries. A three-node cluster can therefore hold three copies of the same data, and a deployment can temporarily produce different results on different nodes.

For a single virtual machine or a small Grails service, local Ehcache is often adequate. For a production application hosted in an Australian cloud region such as Sydney, estimate heap usage from the number of entries and the size of each value. Do not allocate most of the JVM heap to caching. Leave room for Hibernate sessions, request processing, compiled classes and garbage collection.

A useful operational configuration includes:

cache(
    name: 'navigation',
    maxElementsInMemory: 500,
    timeToLiveSeconds: 3600,
    overflowToDisk: false,
    eternal: false
)

Disk overflow is not automatically a performance improvement. Serialisation, filesystem access and recovery behaviour can cost more than another database query, especially on a container with limited ephemeral storage. For frequently accessed small values, a bounded heap cache is usually easier to operate.

Monitor hit rate, miss rate, eviction count, load time and heap pressure. A cache that reports many misses may have keys that are too specific, an expiry that is too short or a region that is undersized. A cache with an excellent hit rate can still be harmful if it retains oversized object graphs.

Test Behaviour Across Requests And Nodes

Unit tests should verify both the returned value and the cache interaction. Call a cacheable method twice with the same key, change the underlying data, and confirm that the expected stale or refreshed behaviour occurs. Then test eviction after a write. Tests should also cover missing records, null results and exceptions, since providers differ in how they treat each case.

Integration tests are important because annotations may rely on proxies. A cache annotation applied to a method called from another method in the same class may be bypassed when the call does not pass through the framework-managed bean proxy. Calling the method through the service bean in an integration test exposes this issue earlier than a production incident.

Run a realistic load test with the same concurrency and data shapes expected in production. Test after a restart because an in-memory cache is normally empty when the JVM starts. Test a rolling deployment as well, particularly if Sydney and Melbourne users can be routed to different application instances during the release.

Application lifecycle hooks can help warm stable reference data or clear regions during controlled deployment tasks. When that work becomes part of a repeatable operational process, custom lifecycle scripts can keep startup and shutdown behaviour explicit rather than hiding it in ad hoc commands.

Operate Ehcache Safely In Production

Caching should have an observable failure mode. If Ehcache cannot initialise, decide whether the application should fail fast or continue without caching. For a catalogue lookup, bypassing the cache may be acceptable. For a system that depends on cached configuration, startup failure may be safer than silently using incomplete data.

Log cache configuration at startup without exposing private values. Record region names, configured limits and provider versions, but avoid dumping customer data or full cache keys into application logs. In an Australian environment, centralised logs may cross state or national boundaries, so check retention and access policies before sending diagnostic data to an external platform.

Time settings also deserve local attention. Cache durations are elapsed seconds, but business rules often use local dates. A promotion ending at midnight in Perth is not equivalent to one ending at midnight in Sydney. Store timestamps consistently, convert at the business boundary and avoid relying on the server’s default timezone when deciding whether a cached result is still valid.

Finally, document what can be cached, who owns invalidation and what happens during a cache outage. A short runbook should explain how to clear a region, inspect hit rates, reduce memory use and disable the provider during an incident. That turns Ehcache from an invisible optimisation into a controlled part of the Grails application.

Add the cache dependency that matches your Grails release, create one small region, and measure its behaviour before expanding coverage. Apply caching to stable read operations first, pair every mutable dataset with an invalidation rule, and verify the result with integration tests and production metrics. With those safeguards in place, Ehcache can reduce database pressure while keeping Grails responses fast for users across Australia.