EF Core Second Level Cache Interceptor
EF Core Second Level Cache Interceptor
Second-level caching is a query cache. The results of Entity Framework (EF) commands are stored in the cache so that the same EF commands will retrieve their data from the cache rather than executing them against the database again.
Using the second-level cache involves three mandatory steps: installing a provider, registering it, and adding the interceptor to your DbContext.
First, you need to add the main package:
dotnet add package EFCoreSecondLevelCacheInterceptorThis library supports multiple caching providers, each available as a separate NuGet package. You must install at least one.
dotnet add package EFCoreSecondLevelCacheInterceptor.MemoryCachedotnet add package EFCoreSecondLevelCacheInterceptor.StackExchange.Redisdotnet add package EFCoreSecondLevelCacheInterceptor.FusionCachedotnet add package EFCoreSecondLevelCacheInterceptor.HybridCachedotnet add package EFCoreSecondLevelCacheInterceptor.EasyCaching.Coredotnet add package EFCoreSecondLevelCacheInterceptor.CacheManager.CoreIf you encounter an issue with a transitive dependency before the upstream library author releases an update, you can force NuGet to use a patched version using the "Nearest Wins" rule. A direct package reference in your project file will always override deeper, transitive references.
To force the project to use a safe or updated version (e.g., MessagePack v3.1.7+), explicitly add it to your .csproj file:
This workaround is perfectly safe for minor patches (e.g., 3.1.6 to 3.1.7) due to Semantic Versioning (SemVer) guarantees. However, forcing a major version jump (e.g., v2.x to v3.x) will likely introduce breaking changes and crash the parent library.
In your Startup.cs or Program.cs, you need to register the EF Core second-level cache services and configure your chosen provider.
Example: Using the Built-in In-Memory Provider
…(For detailed configuration examples of other providers, see the Available Cache Providers section below.)
Modify your DbContext registration to add the SecondLevelCacheInterceptor. This service is automatically registered and available via dependency injection.
…To cache a query, use the .Cacheable() extension method. It can be placed anywhere in the LINQ query chain.
var post = context.Posts
.Where(x => x.Id > 0)
.OrderBy(x => x.Id)
.Cacheable() // Mark this query to be cached
.FirstOrDefault(); // Async methods are also supported.The Cacheable() method uses global settings by default, but you can override them for a specific query:
var post = context.Posts
.Where(x => x.Id > 0)
.OrderBy(x => x.Id)
// Override global settings for this query
.Cacheable(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(5))
.FirstOrDefault();To confirm that the interceptor is working, you should enable logging.
1. Enable Logging in the Configuration
Set ConfigureLogging(true) during service registration.
services.AddEFSecondLevelCache(options =>
options.UseMemoryCacheProvider().ConfigureLogging(true)
);2. Set Log Level to Debug
In your appsettings.json, ensure the log level is set to Debug.
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Debug",
"Microsoft": "Debug"
}
}
}When you run a cacheable query for the first time, the data is fetched from the database and cached. On subsequent executions, you should see log messages indicating a cache hit:
Suppressed result with a TableRows[ee20d2d7-ffc7-4ff9-9484-e8d4eecde53e] from the cache[KeyHash: EB153BD4, CacheDependencies: Page.].
Using the TableRows[ee20d2d7-ffc7-4ff9-9484-e8d4eecde53e] from the cache.Notes:
Suppressed the result with the TableRows message confirms the caching interceptor is working.Executed DbCommand log message from EF Core, but this is expected behavior.ConfigureLogging method. See the Logging Events section for more details.You can control caching globally or on a per-query basis.
Instead of marking individual queries with .Cacheable(), you can define a global caching policy.
To cache every query in the application, use CacheAllQueries().
services.AddEFSecondLevelCache(options =>
{
options.UseMemoryCacheProvider().UseCacheKeyPrefix("EF_");
options.CacheAllQueries(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(30));
});CacheAllQueries(), you don't need to call .Cacheable() on individual queries..Cacheable() call on a query will override the global setting..NotCacheable() method.To cache queries that involve specific entity types or database tables, use CacheQueriesContainingTypes or CacheQueriesContainingTableNames.
services.AddEFSecondLevelCache(options =>
{
options.UseMemoryCacheProvider()
.CacheQueriesContainingTableNames(
CacheExpirationMode.Absolute, TimeSpan.FromMinutes(30), TableNameComparison.ContainsOnly,
"posts", "products", "users"
);
});CacheAllQueriesExceptContainingTypes or CacheAllQueriesExceptContainingTableNames.This library automatically invalidates cache entries when it detects CRUD operations (via its interceptor).
When you use SaveChanges() or SaveChangesAsync(), the interceptor identifies which tables have been modified and invalidates all cached queries that depend on those tables. No additional configuration is needed.
Limitation: ExecuteUpdate and ExecuteDelete
EF Core does not trigger interceptors for bulk operations like ExecuteUpdate and ExecuteDelete for performance reasons. These methods execute raw SQL directly, bypassing EF Core's change tracking and related events. Therefore, cache invalidation will not happen automatically for these operations. You must invalidate the cache manually.
You can manually invalidate the cache by injecting the IEFCacheServiceProvider.
Invalidate the entire cache:
_cacheServiceProvider.ClearAllCachedEntries();Invalidate cache entries related to specific tables:
This is useful if you are using an external tool like SqlTableDependency to monitor database changes.
// The prefix "EF_" should match your configured UseCacheKeyPrefix.
var tableNames = new HashSet { "EF_TableName1", "EF_TableName2" };
_cacheServiceProvider.InvalidateCacheDependencies(new EFCacheKey(tableNames));To receive notifications when cache entries are invalidated, use the NotifyCacheInvalidation method.
…You can define rules to skip caching for certain queries based on their command text or results.
services.AddEFSecondLevelCache(options =>
{
options.SkipCachingCommands(commandText =>
commandText.Contains("NEWID()", StringComparison.InvariantCultureIgnoreCase));
});services.AddEFSecondLevelCache(options =>
{
// Don't cache null values or empty result sets.
options.SkipCachingResults(result =>
result.Value == null || (result.Value is EFTableRows rows && rows.RowsCount == 0));
});Or if you don't want to use the NotCacheable() extension method, you can use query tags to attach comments to your SQL queries directly from your EF Core code. These tags are then included in the generated SQL, allowing you to create custom rules for caching behavior.
To add a query tag, use the .TagWith() extension method on your EF Core query:
var blogs = await context.Blogs
.TagWith("Fetching data")
.Where(b => b.IsActive)
.ToListAsync();The tag, prepended with --, will be included in the generated SQL query:
-- Fetching data
SELECT * FROM Blogs WHERE IsActive = 1;Now you can use query tags to define rules for when to skip caching. This is done by configuring your cache service to check for specific text in the command.
In your Startup.cs or Program.cs file, configure the cache to skip commands that contain your tag. The SkipCachingCommands method accepts a predicate that evaluates the command text.
services.AddEFSecondLevelCache(options =>
{
options.SkipCachingCommands(commandText =>
commandText.Contains("-- Fetching data", StringComparison.InvariantCultureIgnoreCase));
});With this configuration, any query tagged with "Fetching data" will not be cached, giving you granular control over your caching strategy.
In some cases, you may want to prevent a command from invalidating the cache, such as when updating a view counter.
services.AddEFSecondLevelCache(options =>
{
options.SkipCacheInvalidationCommands(commandText =>
// Assumes a command that only updates a post's view count contains this text.
commandText.Contains("UPDATE [Posts] SET [Views]", StringComparison.InvariantCultureIgnoreCase));
});Use OverrideCachePolicy() to dynamically change the caching policy for a query at runtime.
services.AddEFSecondLevelCache(options =>
{
options.OverrideCachePolicy(context =>
{
// Don't cache any CRUD commands
if (context.IsCrudCommand)
{
return null;
}
// Use a "never remove" policy for queries on the 'posts' table
if (context.CommandTableNames.Contains("posts"))
{
return new EFCachePolicy().ExpirationMode(CacheExpirationMode.NeverRemove);
}
// Use the default calculated policy for all other queries
return null;No open issues yet, or sync has not completed.