Query Caching in Entity Framework Core using NCache

In this article, let's look at how we can configure and use NCache for query caching in Entity Framework Core with an illustrating example in ASP.NET ..

Caching is one of the most important strategies used for boosting application performance. It is a technique in which certain type of data is maintained in a high speed memory and is accessed whenever a request for that data arises.

Caching is applied at various levels of application – for example, web browsers cache content based on the headers sent out by the API / Server via the response.

Other types of Caching includes application data caching, where frequently accessed application data is cached at particular location (for example, in-memory) so that when that particular record or object is needed, it doesn’t need to be fetched again.

This avoids an actual database hit and contributes to overall request turnaround time.

Object Relational Mappers and Query Caching

Object Relational Mappers aka ORMs use caching to improve query and database performance.

Generally they maintain two levels of caches – a Level 1 Cache and an optional Level 2 Cache.

A Level 1 Cache generally stores all the entities that are touched down by transactions in a session. This is an implicit mechanism used to boost performance and for persistence purposes.

Whereas a Level 2 Cache is an optional handle which can be plugged to any third-party cache provider by the developer for further optimizing the query performance.

With a Level 2 Cache, the ORMs can persist query results based on the queries being run onto the database and reuse the stored datasets for consecutive calls, thereby avoiding unnecessary database calls and costs.

Query Caching and Entity Framework Core

Although most of the popular ORMs such as Hibernate provide built-in support for leveraging both Level 1 and Level 2 Caching, Entity Framework Core supports only Level 1 caching. In order to have an extra layer of caching for query performance, we need to implement our own layer of caching.

This is where NCache comes in to the picture.

What is NCache?

NCache is a popular Cache provider in the .NET space. The cache is built using .NET and has a very good support for .NET applications. It has a rich set of library which can help in implementing query caching over Entity Framework Core. NCache offers a great set of features and comes in three flavors – Open Source, Professional and Enterprise; which customers can choose from based on their needs.

Heads Up! While the Open Source version of NCache is completely free, the Professional and Enterprise versions come with Licensing – which isn’t. Also, the features offered do vary with the flavors. So do check out the editions while deciding which one to choose. https://www.alachisoft.com/ncache/edition-comparison.html

In this article, let’s look at how we can configure and use NCache for query caching in Entity Framework Core and reduce database calls with an illustrating example in ASP.NET Core. We’ll use ASP.NET Core 3.1 for this experiment.

Implementing Query Caching in Entity Framework Core with NCache

To demonstrate query caching with NCache, let’s build a simple web application which provides users with option to Add, Update, Delete and Retrieve all the Books stored in an underlying database.

Setting up the Sample Project – NCachedBookStore

Let’s create a new project called NCachedBookStore. We’d design it in a layered architecture with well-defined Core, Infrastructure and Web tiers. We’d do it with the following commands in .NET Core CLI.

# create a new Solution file
> dotnet new sln --name NCachedBookStore

# create a new MVC project
> dotnet new mvc --name NCachedBookStore.Web

# create classlib projects for Core and Infrastructure
> dotnet new classlib --name NCachedBookStore.Core
> dotnet new classlib --name NCachedBookStore.Infrastructure

# add projects to Solution
> dotnet sln add ./NCachedBookStore.Core
> dotnet sln add ./NCachedBookStore.Infrastructure

We now have our solution with the layers ready. It looks like this:

wp-content/uploads/2022/05/ncache_solution.png

We can now start building the necessary controller and providers for adding and retrieving data from the database. For this example, I’m using an SQL Server LocalDB database, my connection string would look like this:

{
    "ConnectionStrings": {
        "DefaultConnection": "Data Source=(localdb)\mssqllocaldb;Database=ncachedbookstore;MultipleActiveResultSets=true"
    },
    ...
}

The entity Book would look like below. It sits inside the Core layer. The Infrastructure layer references the Core layer for the Contracts and Entities.

namespace NCachedBookStore.Contracts.Entities
{
    [Serializable]
    public class Book
    {
        public Book()
        {
            this.AddedOn = DateTime.UtcNow;
        }

        [Key]
        public int Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public string ISBN { get; set; }
        public double Price { get; set; }
        public string AuthorName { get; set; }
        public DateTime AddedOn { get; set; }
    }
}

Operations to be performed on this entity is defined by an interface IBookRepository, following a Repository pattern.

namespace NCachedBookStore.Contracts.Repositories
{
    public interface IRepository<T>
    {
        Task<IEnumerable<T>> GetAllAsync();
        Task<T> GetAsync(int id);
        Task AddAsync(T entity);
        Task UpdateAsync(T entity);
        Task DeleteAsync(int id);
        int Count();
    }

    public interface IBookRepository : IRepository<Book> { }
}

The IDataService maintains the instance of IBookRepository and is registered in the container, to be injected into controllers through Dependency Injection.

namespace NCachedBookStore.Contracts.Services
{
    public interface IDataService
    {
        public IBookRepository Books { get; }
    }
}

The implementations of IBookRepository and IDataService are present in the Infrastructure layer, where we connect to the database using Entity Framework Core.

Setting up Commands and Queries for Book CRUD operations

To provide a clean abstraction of the data operations, we implement a Command Query approach following CQRS pattern with the help of MediatR. These implementations reside inside the Core layer. For example, a GetAllBooks query would look like below:

namespace NCachedBookStore.Core.Handlers
{
    public class GetAllBooksQuery : IRequest<IEnumerable<Book>>
    {
    }

    public class GetAllBooksQueryHandler 
        : IRequestHandler<GetAllBooksQuery, IEnumerable<Book>>
    {
        private readonly IDataService _db;

        public GetAllBooksQueryHandler(IDataService db)
        {
            _db = db;
        }

        public async Task<IEnumerable<Book>> Handle(
            GetAllBooksQuery request, CancellationToken cancellationToken)
        {
            return await _db.Books.GetAllAsync();
        }
    }
}

The controller for Books, which handles the client requests uses MediatR to send queries or commands and get things done. The BooksController looks like below:

namespace NCachedBookStore.Web.Controllers
{
    public class BooksController : Controller
    {
        private readonly IMediator _mediator;

        public BooksController(IMediator mediator)
        {
            _mediator = mediator;
        }

        // GET: BooksController
        public async Task<ActionResult> Index()
        {
            var books = await _mediator.Send(new GetAllBooksQuery());
            return View(books);
        }

        // GET: BooksController/Details/5
        public async Task<ActionResult> DetailsAsync(int id)
        {
            var book = await _mediator.Send(new GetBookByIdQuery(id));
            return View(book);
        }

        // GET: BooksController/Create
        public ActionResult Create()
        {
            return View();
        }

        // POST: BooksController/Create
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> CreateAsync(BookDto entity)
        {
            try
            {
                var book = await _mediator.Send(new CreateBookCommand(entity));   
                return RedirectToAction(nameof(Index));
            }
            catch
            {
                return View();
            }
        }

        // GET: BooksController/Edit/5
        public async Task<ActionResult> EditAsync(int id)
        {
            var book = await _mediator.Send(new GetBookByIdQuery(id));
            return View(book);
        }

        // POST: BooksController/Edit/5
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> EditAsync(int id, BookDto entity)
        {
            try
            {
                var book = await _mediator.Send(new UpdateBookCommand(entity, id));
                return RedirectToAction(nameof(Index));
            }
            catch
            {
                return View();
            }
        }

        // GET: BooksController/Delete/5
        public async Task<ActionResult> DeleteAsync(int id)
        {
            var _ = await _mediator.Send(new DeleteBookCommand(id));
            return RedirectToAction(nameof(Index));
        }
    }
}

With this we complete our initial application design. We’ll now design the implementation of our IBookRepository where the application connects to the database. We’ll piggyback these implementations with NCache so that it takes care of the database querying and ensure the results are cached.

To do this, we need to install packages for NCache and register the service.

Installing NCache libraries for Entity Framework Core

To use NCache features, we install the package EntityFrameworkCore.NCache along with the usual EntityFrameworkCore packages. The Infrastructure layer looks like below:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
	  <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..NCachedBookStore.CoreNCachedBookStore.Core.csproj" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="EntityFrameworkCore.NCache" Version="5.2.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.13">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.1.13" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer.Design" Version="1.1.6" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.13">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="System.Data.SqlClient" Version="4.5.1" />
  </ItemGroup>
</Project>

Further, we need to configure NCache provider accordingly while registering our DatabaseContext in the Startup.

namespace NCachedBookStore.Infrastructure
{
    public static class ServiceExtensions
    {
        public static IServiceCollection AddRepositories(
            this IServiceCollection services, IConfiguration configuration)
        {
            services.AddDbContext<DatabaseContext>(optionsBuilder =>
            {
                string cacheId = configuration["CacheId"];

                NCacheConfiguration.Configure(
                    cacheId, DependencyType.SqlServer);
                NCacheConfiguration.ConfigureLogger();

                optionsBuilder.UseSqlServer(
                    configuration.GetConnectionString("DefaultConnection"));
            });

            return services.AddScoped<IDataService, DataService>();
        }
    }
}

During the NCache configuration, we need to provide it with details about which cache to be used and so on. I’m maintaining the name of cache which the application needs to connect to in the appsettings JSON.

{
    ...
    "CacheId": "democache"
}

Connecting application to the Cache

Generally, applications need to maintain the Cache information (the Server IP, Port and other information) inside a configuration file called client.nconf file. If NCache doesn’t find such file in the execution directory, it looks for it in the default installation path. A sample file looks like below:

<?xml version="1.0" encoding="UTF-8"?>
<!-- Client configuration file is used by client to connect to out-proc caches. 
Light weight client also uses this configuration file to connect to the remote caches. 
This file is automatically generated each time a new cache/cluster is created or 
cache/cluster configuration settings are applied.
-->
  <configuration>
    <ncache-server connection-retries="5" retry-connection-delay="0" retry-interval="1" command-retries="3" command-retry-interval="0.1" client-request-timeout="90" connection-timeout="5" port="9800" local-server-ip="192.168.100.6" enable-keep-alive="False" keep-alive-interval="0"/>
    <cache id="demoLocalCache" client-cache-id="" client-cache-syncmode="optimistic" skip-client-cache-if-unavailable="True" reconnect-client-cache-interval="10" default-readthru-provider="" default-writethru-provider="" load-balance="False" enable-client-logs="False" log-level="error">
      <server name="192.168.100.6"/>
    </cache>
    <cache id="mycache" client-cache-id="" client-cache-syncmode="optimistic" skip-client-cache-if-unavailable="True" reconnect-client-cache-interval="10" default-readthru-provider="" default-writethru-provider="" load-balance="False" enable-client-logs="False" log-level="error">
      <server name="192.168.100.6"/>
      <security>
        <primary user-id="JARVISbruv" password="*************"/>
        <secondary user-id="" password=""/>
      </security>
    </cache>
  </configuration>

I have my NCache cluster running in my local machine where the application is being run, so it by default looks into the NCache installation folder for client.nconf file.

The DatabaseContext class looks like below:

namespace NCachedBookStore.Infrastructure
{
    public class DatabaseContext : DbContext
    {
        public DatabaseContext(
            DbContextOptions options) : base(options)
        {
        }

        protected override void OnConfiguring(
            DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);
        }

        public DbSet<Book> Books { get; set; }
    }
}

Implementing Caching Strategies with NCache

Generally caching can be implemented in two Strategies –

  1. Lazy Loading approach where the record is not put into the cache until it is queried at least once by the application and subsequent calls would be catered from the Cache.
  2. Read/Write through cache, which maintains a replica of the database within the cache so that any call for data can be served from the cache itself.

NCache provides three extension methods through which we can implement these Strategies –

  1. FromCache() – checks for the dataset being queried in the cache, if not available queries from the database and stores it for subsequent calls.
  2. LoadIntoCache() – loads the entire dataset being queried into the cache and makes it the primary datasource for subsequent queries
  3. FromCacheOnly() – queries for the dataset in the cache only; returns default if not found.
  4. GetCache() – returns an instance of the Cache, which can be used to write/update cache after the entity has changed in database.

LoadIntoCache() and FromCacheOnly() together can be used to preload data into the cache during an application startup and then completely bypass database calls.

Lazy Loading with FromCache()

To demonstrate lazy loading, we can use the FromCache() method from the NCache LINQ library while querying for a single book in the database. This ensures that the dataset if not present be added to the cache and any subsequent call for the same entity is returned from the cache itself.

The method GetAsync() looks like below:

public async Task<IEnumerable<Book>> GetAllAsync()
{
    CachingOptions options = new CachingOptions
    {
        StoreAs = StoreAs.SeperateEntities
    };
    var items = (from r in _context.Books select r).FromCache(options).ToList();
    return await Task.FromResult(items);
}

The FromCache() method is attached to the LINQ query to be executed onto the database, and NCache stores the result dataset into the cache with an auto-generated unique key based on the query.

Any subsequent call with the same query means the same CacheKey and so the cached data is returned instead of querying on the database.

It takes an instance of type CachingOptions, and the property StoreAs has two possible values – SeperateEntities and Collection. StoreAs.SeperateEntities ensures that each entity in the result dataset be stored as individual entities, while Collection means that the entire dataset be stored as a single Collection.

In this case, all the result books are stored inside the cache and any further querying for a book would result from the cache itself instead of the database.

The difference can be seen as below:

wp-content/uploads/2022/05/ncache_fromquery.png

We can see that while the query to fetch all books has been executed on the database (as the translated query is printed on console) the queries for individual bookIds don’t have any queries, which means that they’re fetched from the cache which is populated from the previous fetch.

Btw, the GetAsync() method also has FromCache() method attached, which makes it query from the cache instead of database if available. It looks like below:

public async Task<Book> GetAsync(int id)
{
    CachingOptions options = new CachingOptions
    {
        StoreAs = StoreAs.SeperateEntities
    };

    var item = (from cust in _context.Books
        where cust.Id == id
        select cust).FromCache(options).ToList();

    return await Task.FromResult(item.FirstOrDefault());
}

I can monitor the changes happening in the cache at real-time via the Cache Monitoring feature. I can see the spikes in fetch whenever a cache hit happens.

wp-content/uploads/2022/05/ncache_monitor.png

Read Through / Write Through with LoadIntoCache()

In this approach, the Cache acts as a layer over the database through which the Add/Update/Delete operations pass through. whenever the dataset inside the database is altered/deleted the same should reflect in the cache accordingly.

To do this, we use the GetCache() method and add or delete records as they pass through.

For example, the AddAsync() method is implemented as below:

public async Task AddAsync(Book entity)
{
    // Add record to database
    _context.Books.Add(entity);
    await _context.SaveChangesAsync();

    // Get the cache
    string cacheKey;
    var cache = _context.GetCache();

    CachingOptions options = new CachingOptions
    {
        StoreAs = StoreAs.SeperateEntities
    };

    // Add to cache (without querying the database)
    cache.Insert(entity, out cacheKey, options);
}

In the above code, we first insert the record into the database followed by which we call the extension method GetCache() over the database context. This returns the cache that is attached to the database, and we insert the entity to the cache.

This way we add the record to the cache without having to query the database in a subsequent GET.

Similarly, we implement DeleteAsync() method where the record is deleted simultaneously from the database as well as the cache.

public async Task DeleteAsync(int id)
{
    var entity = _context.Books.Find(id);
    _context.Books.Remove(entity);
    await _context.SaveChangesAsync();

    var cache = _context.GetCache();
    cache.Remove(entity);
}

When I delete a record from the database, I can see a spike in the monitoring section indicating that the record was also removed from the cache – indicating a Write-Through.

wp-content/uploads/2022/05/ncache_delete.png

Final Thoughts – Deciding on what to Cache

NCache provides a simple, interesting and reliable implementation of caching over EntityFrameworkCore, which makes it an ideal choice for customers looking to have a Level 2 cache over their database calls.

This reduces the overall turnaround time for client requests, thereby improving application performance and database costs.

Although caching is recommended, deciding on what data to cache and how far to cache is typically a design choice.

We generally recommended caching reference data – data that is frequently accessed but rarely updated, over transactional data which could change over time.

Also, it is ideal to ensure caching happens for a larger chunk of generic data, while user specific data such as thumbnails, profile information and so on could be persisted through cookies or tokens instead of caches.

The code snippets used in this article are a part of NCachedBookStore, a sample solution that demonstrates Query Caching in EntityFrameworkCore with NCache. You can checkout the repository with the below link. Please do leave a Star if you find the code useful.

https://github.com/referbruv/NCachedBookStore

Default image
Sriram Mannava

I'm a full-stack developer and a software enthusiast who likes to play around with cloud and tech stack out of curiosity.

Leave a Reply