Library / SDK
VahidN/EFCoreSecondLevelCacheInterceptor avatar
VahidN/EFCoreSecondLevelCacheInterceptor

EFCoreSecondLevelCacheInterceptor: a query cache bolted onto EF Core

EF Core Second Level Cache Interceptor

972 stars106 forksC#Apache-2.0

At a glance

What is it?
EFCoreSecondLevelCacheInterceptor caches the results of Entity Framework Core commands behind a .Cacheable() call, with six cache providers from in-memory to Redis and a database fallback when the cache is unavailable. It suits ASP.NET Core applications whose read-heavy queries repeat, and it does not solve entity freshness.
Who is it for?
Adopt EFCoreSecondLevelCacheInterceptor when your ASP.NET Core application repeats the same read queries and you have already chosen a cache provider, because the package does the wiring for you and keeps the cache choice swappable. Do not adopt it as a way to make writes cheap or to serve fresh data, since it caches query results and the README does not document how entries are invalidated when data changes.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 4 days ago.
What is it written in?
Mainly C#, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A cache for EF Core commands, not for entities

The README defines the scope in one sentence: second-level caching is a query cache, where the results of Entity Framework commands are stored so that the same commands retrieve their data from the cache instead of executing against the database again. That wording matters, because it places the project in a narrower slot than the term second-level cache usually suggests.

Entity Framework Core has no built-in second-level cache, and applications that need one usually end up writing their own: an interceptor that intercepts the command, a key derived from the query, a store in front of it, and an invalidation story that is much harder than the caching itself. This package takes that work and hands it to you as a NuGet library, with the caching decision exposed as one method call on the query chain.

The audience is ASP.NET Core developers with a read-heavy data access layer, which is what the repository topics say as well: aspnetcore, ef-core, entity-framework-core. The primary language is C#, the licence is Apache-2.0, and the solution file is EFCoreSecondLevelCacheInterceptor.slnx, with the source under src/.

Six providers behind one registration call

The interesting design decision is that the cache store is pluggable while the interceptor is not. The library ships as a main package plus one package per provider, and the README states that at least one provider must be installed.

The built-in option is the in-memory provider, described as a simple in-memory cache provider. Beyond that, StackExchange.Redis is supported with a preconfigured MessagePack serializer, which is the distributed option for a multi-instance deployment. FusionCache is implemented as a provider, as is .NET's own HybridCache, along with adapters for the EasyCaching.Core library and the CacheManager.Core library. The list ends with custom, because the README explicitly allows implementing your own provider.

The consequence for an architect is that the choice of cache is not baked into your application. The code that marks a query cacheable does not change when you move from a single-process in-memory cache to a shared Redis instance, which is the main reason to take a dependency like this rather than writing the interceptor by hand. The cost is a dependency per provider, and the Redis option in particular pulls in MessagePack as the serializer.

Install, register, intercept: the three mandatory steps

The README is explicit that using the second-level cache involves three mandatory steps, and the first is adding the main package to the project:

powershell
dotnet add package EFCoreSecondLevelCacheInterceptor

At least one provider package follows, for example the built-in memory provider or the Redis one:

powershell
dotnet add package EFCoreSecondLevelCacheInterceptor.MemoryCache
dotnet add package EFCoreSecondLevelCacheInterceptor.StackExchange.Redis

Step two is registration, where the services are added and the provider configured. The example in the README uses the built-in in-memory provider:

csharp
public void ConfigureServices(IServiceCollection services)
{
    // 1. Add EF Core Second Level Cache services
    services.AddEFSecondLevelCache(options =>
        options.UseMemoryCacheProvider().ConfigureLogging(true).UseCacheKeyPrefix("EF_")
               // Fallback on db if the caching provider fails.
               .UseDbCallsIfCachingProviderIsDown(TimeSpan.FromMinutes(1))
    );

    // 2. Add your DbContext
    var connectionString = Configuration["ConnectionStrings:ApplicationDbContextConnection"];
    services.AddConfiguredMsSqlDbContext(connectionString);

    services.AddControllersWithViews();
}

Three options in that chain are worth pausing on. ConfigureLogging(true) turns on the diagnostic logging used to verify the cache works. UseCacheKeyPrefix("EF_") namespaces the keys, which matters when the same store holds other data. UseDbCallsIfCachingProviderIsDown sets the window in which a failing cache provider falls back to hitting the database instead of throwing.

Step three is adding the interceptor to the DbContext, which the README notes is registered automatically and available through dependency injection:

csharp
public static class MsSqlServiceCollectionExtensions
{
    public static IServiceCollection AddConfiguredMsSqlDbContext(this IServiceCollection services, string connectionString)
    {
        services.AddDbContextPool<ApplicationDbContext>((serviceProvider, optionsBuilder) =>
                optionsBuilder
                    .UseSqlServer(
                        connectionString,
                        sqlServerOptionsBuilder =>
                        {
                            sqlServerOptionsBuilder
                                .CommandTimeout((int)TimeSpan.FromMinutes(3).TotalSeconds)
                                .EnableRetryOnFailure()
                                .MigrationsAssembly(typeof(MsSqlServiceCollectionExtensions).Assembly.FullName);
                        })
                    // Add the interceptor
                    .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>()));
        return services;
    }
}

The interceptor is resolved from the service provider inside the DbContext registration, so the cache services have to be registered first, which is the ordering the README's steps imply.

Cacheable() marks a query, and expiration is per call

Nothing is cached by default. A query becomes cacheable through the .Cacheable() extension method, and the README notes it can be placed anywhere in the LINQ query chain:

csharp
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 opt-in design is the right one for a cache layered on an ORM. If everything were cached by default, a query that must see the current state of the database would silently return a stale result, and the failure would show up as a data bug rather than a configuration bug.

Global settings apply by default, and a single query can override them. The override takes an expiration mode and a duration:

csharp
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();

CacheExpirationMode.Sliding means the entry's lifetime restarts on each access rather than running out a fixed time after it was written, which suits reference data that is read constantly. The README also states that async methods are supported, so the same marking works with the awaitable form of the query.

Cache down, database up

A cache introduces a dependency that can fail, and the option that deals with it is UseDbCallsIfCachingProviderIsDown(TimeSpan.FromMinutes(1)). The name states the behaviour: if the caching provider is unavailable, the query goes to the database instead. The one minute argument is the window in which that fallback applies.

This is the piece of the design that makes the library usable in production, because without it a Redis outage turns into an application outage. With it, a cache outage degrades to the old behaviour, which is slower but correct. The cost of that safety is that a failing cache can be invisible, since the application keeps working and simply stops benefiting from the cache. The logging option is what makes it visible, and the README's verification section is built around exactly that: enable ConfigureLogging(true), then set the log level to Debug in appsettings.json, and read the log to confirm that results are coming from the cache rather than the database.

Pinning a transitive dependency with Nearest Wins

The troubleshooting section addresses a problem that is not really about caching at all, but that anyone adding a provider package will meet. If a transitive dependency has a problem and the upstream author has not released a fix, NuGet can be forced onto a patched version using the Nearest Wins rule, because a direct package reference in the project file overrides deeper transitive references.

The mechanism is a plain PackageReference entry added to the .csproj file:

xml
<ItemGroup>
  <PackageReference Include="SomeParentLibrary" Version="1.0.0" />

  <PackageReference Include="MessagePack" Version="3.1.7" />
</ItemGroup>

The README is careful about the limits of this workaround. Forcing a minor patch, for example moving MessagePack from 3.1.6 to 3.1.7, is described as safe because of Semantic Versioning guarantees. Forcing a major version jump such as v2.x to v3.x will likely introduce breaking changes and crash the parent library.

The rest of the repository supports the same kind of discipline. Directory.Packages.props and Directory.Build.props sit at the root, which is the central package management pattern, and global.json pins the SDK version for the build. A _build.cmd script, a tag-it.bat script and an update-dependencies.bat script cover the local build and release chores, and update-dependencies.bat is a good clue to how dependency bumps are handled in a project that does not publish GitHub releases.

Where the query cache does not reach

The main limitation follows from the definition. The package caches command results, and the README does not document how cache entries are invalidated when the underlying rows change. A read that is marked cacheable will keep returning its stored result until its expiration elapses, so a workflow that writes and then immediately reads the same entity sees the old value. If your application needs read-your-writes consistency, this library does not provide it out of the box, and the fix is either to not mark those queries cacheable or to expire entries deliberately.

The second limitation is that this is a query cache, not an identity map. Each cached result is a separate materialisation, so tracked entities are still tracked per context, and the pool of DbContext instances behaves as it always does. The win is in not going to the database, not in reducing object graph work.

Third, the provider matrix is wide and each option brings its own operational baggage. FusionCache, HybridCache, EasyCaching and CacheManager are all real libraries with their own configuration and their own release cycles, and the Redis option adds MessagePack as a serializer with its own versioning, which is exactly the case the troubleshooting section warns about.

Fourth, the repository publishes no GitHub releases at all, so there is no tagged version to pin against and no release notes to read for upgrade notes. The last push was on 2026-10-19.

Against hand-rolling the same interceptor

The alternative is to write the cache yourself, and for a small application that is a reasonable option. An EF Core interceptor can capture the command, build a key from the query and its parameters, look the key up in a cache you already run, and store the result. The dependency list in your project stays unchanged, and you control exactly how keys are built and when entries expire.

The difference in approach is where the responsibility sits. A hand-rolled interceptor makes the caching rules part of your application, which is good when the rules are unusual: composite keys spanning joined tables, per-tenant key prefixes, a cache that lives next to the database rather than next to the app. The cost is that every one of those decisions becomes yours to maintain, and the parts that are easy to get subtly wrong are the parts you would have to review.

This package takes the opposite position. The key building, the store access and the fallback behaviour are the library's problem, and the provider swap keeps the store out of your code. What you accept in exchange is a dependency, a registration step in every context, and the fact that the interceptor sits in the request path of every query in the application, cacheable or not, since it is registered on the options builder.

The practical test is whether your read queries repeat enough to matter and whether you already run a cache you would rather reuse. If the answer to both is yes, the package saves you the wiring. If your application runs a handful of queries with a handful of users, the hand-rolled version is smaller than the dependency.

Editorial conclusion

Adopt EFCoreSecondLevelCacheInterceptor when your ASP.NET Core application repeats the same read queries and you have already chosen a cache provider, because the package does the wiring for you and keeps the cache choice swappable. Do not adopt it as a way to make writes cheap or to serve fresh data, since it caches query results and the README does not document how entries are invalidated when data changes. Verify first that your provider package matches the runtime you deploy on, since Redis, FusionCache, HybridCache, EasyCaching and CacheManager each bring their own dependencies, and note that the repository publishes no GitHub releases even though the last push was on 2026-10-19.

Frequently asked questions

How do I add the EF Core second level cache to a project?

Install the main package with dotnet add package EFCoreSecondLevelCacheInterceptor plus at least one provider package, register the services with AddEFSecondLevelCache in ConfigureServices or Program.cs, and add the SecondLevelCacheInterceptor to your DbContext options through AddInterceptors.

Which cache providers does EFCoreSecondLevelCacheInterceptor support?

A built-in in-memory provider, StackExchange.Redis with a preconfigured MessagePack serializer, FusionCache, .NET's HybridCache, EasyCaching.Core and CacheManager.Core, each shipped as its own NuGet package, and the README states you can also implement your own provider.

How do I make an EF Core query cacheable?

Add the .Cacheable() extension method anywhere in the LINQ chain. It uses global settings by default, and a single query can override them with .Cacheable(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(5)) or an equivalent mode and duration.

What happens when the cache provider is unavailable?

The option UseDbCallsIfCachingProviderIsDown sets a window in which the query falls back to the database instead of failing, as shown with TimeSpan.FromMinutes(1) in the registration example. Enable ConfigureLogging(true) so you can see when this is happening.

How do I verify that the second level cache is working?

Set ConfigureLogging(true) when registering the services, then set the log level to Debug in appsettings.json and read the log output to confirm results are being served from the cache rather than the database.

Can I force a patched version of a transitive dependency in this setup?

Yes, by adding a direct PackageReference to your project file, which overrides deeper transitive references under the Nearest Wins rule. The README describes this as safe for minor patches but warns that a major version jump will likely introduce breaking changes.

Official sources

  1. Issues
  2. License: Apache-2.0
  3. README
  4. VahidN/EFCoreSecondLevelCacheInterceptor on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/vahidn-efcoresecondlevelcacheinterceptor.svg)](https://hysenlabs.com/projects/vahidn-efcoresecondlevelcacheinterceptor)

Was this useful?