docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/Post.md
Caching is one of those topics that looks simple until an application starts scaling. The first version works fine with direct database reads. Then traffic grows, page loads become inconsistent, and suddenly the team is debating Redis, stale data, invalidation, and why one node sees fresh data while another still serves old results.
ABP Framework gives you a solid caching foundation, but the important part is choosing the right caching strategy for the job. Not everything should be cached the same way. A read-only lookup list, a tenant-specific settings object, and an entity that changes every minute do not have the same caching needs.
This article walks through the practical caching strategies in ABP Framework, what each one is good at, how to configure them, and the mistakes that usually show up in production.
ABP builds its caching support on top of Microsoft.Extensions.Caching.Distributed.IDistributedCache. That matters because ABP does not invent a completely separate caching universe. Instead, it adds practical features developers actually need in real systems:
Out of the box, the default distributed cache implementation is MemoryDistributedCache. Despite the name, this is still wired through the distributed cache abstraction, but the storage is in-memory for the current app instance.
That is fine for:
It is not enough for:
In those cases, you should move to a real distributed provider such as Redis.
For most ABP applications, the default and most useful strategy is the generic typed distributed cache.
ABP provides:
IDistributedCache<TCacheItem>IDistributedCache<TCacheItem, TCacheKey>These abstractions remove a lot of repetitive work. You do not have to manually serialize objects, invent every cache key shape yourself, or worry about tenant ID inclusion for common cases.
It works well when you want to cache:
This strategy is usually better than caching raw entities because cached application-facing models tend to be:
using Microsoft.Extensions.Caching.Distributed;
using Volo.Abp.Caching;
[CacheName("ProductSummary")]
public class ProductSummaryCacheItem
{
public Guid Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public bool IsAvailable { get; set; }
}
public class ProductAppService : ApplicationService
{
private readonly IDistributedCache<ProductSummaryCacheItem, Guid> _cache;
private readonly IRepository<Product, Guid> _productRepository;
public ProductAppService(
IDistributedCache<ProductSummaryCacheItem, Guid> cache,
IRepository<Product, Guid> productRepository)
{
_cache = cache;
_productRepository = productRepository;
}
public async Task<ProductSummaryCacheItem> GetSummaryAsync(Guid id)
{
return await _cache.GetOrAddAsync(
id,
async () =>
{
var product = await _productRepository.GetAsync(id);
return new ProductSummaryCacheItem
{
Id = product.Id,
Name = product.Name,
Price = product.Price,
IsAvailable = product.StockCount > 0
};
},
() => new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(10),
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
}
);
}
}
A few good things are happening here:
That last point is important. Sliding expiration alone can keep hot items alive indefinitely. Absolute expiration alone can evict popular items too aggressively. In many business cases, combining them gives you a better balance.
Use typed distributed cache when:
Avoid it when:
ABP also provides an entity cache abstraction for read-only entity-level caching. This is useful when you repeatedly fetch entities or entity-based DTOs by ID and want cache invalidation to happen automatically on update or delete.
This is where entity cache can save real effort. Instead of manually wiring remove calls in every update path, you lean on the framework's invalidation behavior.
Entity cache is a good fit for:
It is a bad fit for highly volatile entities where every read risks becoming stale within seconds.
Suppose your application repeatedly loads a Category record by ID from both HTTP requests and background jobs. That category changes maybe once a week. Entity cache is a better fit than manually managing many distributed cache entries across the codebase.
The main advantage is operational simplicity:
ABP supports entity versioning through IHasEntityVersion. If an entity implements it, ABP increments the EntityVersion on updates and uses that in invalidation-related behavior.
That is useful, but there is one common trap: direct SQL updates outside the normal application flow bypass entity versioning and the domain pipeline.
If your team runs scripts like this:
update Products set Name = 'New Name' where Id = '...'
then your cache may not be invalidated as expected.
If you use entity cache, make sure updates go through the application and domain stack whenever possible. If operational SQL scripts are unavoidable, explicitly account for cache invalidation.
Use entity cache when:
Avoid it when:
A lot of caching problems are not about API design. They are deployment problems.
If you run multiple application instances and still use the default in-memory distributed cache implementation, each node will maintain its own private cache state. That means:
For production scale-out, Redis is usually the practical answer.
ABP provides Redis integration through Volo.Abp.Caching.StackExchangeRedis.
Install the Redis caching package and configure distributed caching as your backing provider. ABP then continues to use its caching abstractions, while Redis stores the actual cache entries.
A typical module configuration looks like this:
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Caching;
using Volo.Abp.Modularity;
[DependsOn(typeof(AbpCachingStackExchangeRedisModule))]
public class MyProjectModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetConfiguration();
context.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = configuration["Redis:Configuration"];
});
Configure<AbpDistributedCacheOptions>(options =>
{
options.KeyPrefix = "MyApp";
options.GlobalCacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(20);
options.HideErrors = true;
});
}
}
If the same Redis server is shared by multiple applications or environments, a global key prefix is not optional in practice. Without it, key collisions become surprisingly easy.
Good examples:
MyApp-ProdSalesServiceTenantPortalBad example:
If you need to fetch many cache entries at once, ABP supports batch operations such as:
GetManyAsyncSetManyAsyncRemoveManyAsyncThis matters most in list and aggregation scenarios.
For example, imagine a product page that needs cached summaries for 50 product IDs. Doing 50 individual round-trips is not ideal. If the provider supports batch operations well, this can reduce latency significantly.
public async Task<IReadOnlyList<ProductSummaryCacheItem>> GetManySummariesAsync(Guid[] ids)
{
var cachedItems = await _cache.GetManyAsync(ids);
var missingIds = ids
.Where(id => !cachedItems.ContainsKey(id) || cachedItems[id] == null)
.ToArray();
if (missingIds.Any())
{
var products = await _productRepository.GetListAsync(x => missingIds.Contains(x.Id));
var newItems = products.ToDictionary(
x => x.Id,
x => new ProductSummaryCacheItem
{
Id = x.Id,
Name = x.Name,
Price = x.Price,
IsAvailable = x.StockCount > 0
});
await _cache.SetManyAsync(
newItems,
new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(10)
});
foreach (var item in newItems)
{
cachedItems[item.Key] = item.Value;
}
}
return ids
.Where(id => cachedItems.ContainsKey(id) && cachedItems[id] != null)
.Select(id => cachedItems[id])
.ToList();
}
Provider support matters here. With Redis and ABP's Redis package, batch operations are especially useful. If the underlying provider does not support them efficiently, ABP can fall back to single operations.
That means batch APIs are still worth using from an application-code perspective, but you should validate the real performance characteristics in your deployed environment.
One subtle but valuable ABP feature is the considerUow flag on typed distributed cache operations.
This is easy to overlook, but it can prevent a nasty class of bugs.
Imagine this sequence:
That is classic stale-or-phantom cache state.
When considerUow is enabled, ABP can defer cache writes until the Unit of Work completes successfully.
await _cache.SetAsync(
id,
cacheItem,
options: new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
},
considerUow: true
);
Use this when cache state depends on transactional data changes in the same operation.
Use considerUow when:
You may skip it when:
ABP automatically includes the current tenant ID in cache keys for typed distributed cache scenarios unless multi-tenancy is explicitly ignored.
This is one of those features that quietly prevents serious data leaks.
Without tenant-aware cache keys, this can happen:
That is not just a bug. In many systems, it is a security incident.
For multi-tenant systems:
If a cache item is intentionally global, make that decision explicit and document it.
A lot of bad caching behavior comes from expiration values chosen almost randomly.
ABP lets you define expiration using DistributedCacheEntryOptions, including:
AbsoluteExpirationAbsoluteExpirationRelativeToNowSlidingExpirationABP also supports global defaults through AbpDistributedCacheOptions. If you do not specify item-level options, a default sliding expiration is commonly configured as 20 minutes.
Configure<AbpDistributedCacheOptions>(options =>
{
options.GlobalCacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(20);
options.HideErrors = true;
options.KeyPrefix = "MyApp";
});
Reference data
User-specific dashboard data
Highly dynamic operational metrics
These are not universal numbers, but they are more realistic than setting every cache entry to 24 hours and calling it done.
Distributed caching always includes serialization and deserialization overhead. ABP handles this for you, with JSON serialization by default, but the cost still exists.
That means cache item design matters.
A cache entry should usually be optimized for read efficiency, not for domain completeness.
If a page needs only Name, Price, and Status, do not cache the entire entity graph with audit fields, children, and metadata.
ABP defaults to a practical stance: cache errors are hidden and logged so your application can continue functioning.
This default is often correct.
If Redis has a transient issue, it is usually better for the request to fall back to the database than to fail completely. Caching should improve performance, not become a single point of failure.
You can control this behavior globally with AbpDistributedCacheOptions.HideErrors and per operation with the hideErrors parameter.
Keep HideErrors = true when:
Consider stricter behavior when:
In most business applications, hidden-and-logged cache failures are the safer default.
You may have seen community implementations that add automatic method-level caching through interception and a [Cache] attribute.
That pattern can be attractive because it reduces boilerplate:
It is a useful pattern, but it is important to say clearly: this is not part of ABP core.
So treat it as an architectural choice, not a built-in feature.
If you adopt method-level caching, document it aggressively and be strict about invalidation rules. It can be productive, but only when the team fully understands the behavior.
Here are the mistakes that cause the most pain.
This is probably the most common one. It works in testing, then becomes inconsistent under scale-out.
Fix: use Redis or another true distributed cache provider.
This increases serialization cost and couples cache shape to domain shape.
Fix: cache DTOs or purpose-built cache items unless entity cache is clearly the better fit.
Manual caches live or die by invalidation quality.
Fix: centralize writes, remove cache entries on updates, and use entity cache where automatic invalidation helps.
Hot keys may stay forever.
Fix: combine sliding and absolute expiration for many scenarios.
This can leak data across tenants.
Fix: rely on ABP's tenant-aware key behavior and be very careful with custom key generation.
This creates cache values for changes that later roll back.
Fix: use considerUow for transactional cache writes.
Eventually, your cache provider will have a bad day.
Fix: decide upfront whether fallback or fail-fast behavior is right for each path.
If you just want a sensible default approach for most ABP projects, this is a good starting point:
considerUow for cache writes tied to transactions.That covers a large percentage of real-world ABP caching needs without overengineering the system.
Caching is a performance tool, not a default architecture layer for every service method.
MemoryDistributedCache is fine for single-instance apps, but scaled deployments should use Redis.considerUow to avoid subtle consistency bugs.