ASP.NET Core 9 API: Using FusionCache in an ASP.NET Core Minimal API

Using FusionCache in an ASP.NET Core Minimal API

This article explains how the current Core_FusionCache application uses FusionCache with ASP.NET Core Minimal APIs, Entity Framework Core, SQL Server, and Redis. The application exposes product CRUD endpoints, reads product data from the Products table, caches read responses, and invalidates stale cache entries after write operations.



Figure 1: FusionCache read and write flow used by this application.

What is FusionCache?

FusionCache is an advanced caching library for .NET applications. It gives an application a single cache API while supporting useful production features such as in-memory caching, distributed caching, fail-safe behavior, cache stampede protection, eager refresh, serialization, and backplane notifications.

In this application, FusionCache is configured as a two-level cache:

  • L1 cache: a fast in-memory cache inside the running ASP.NET Core process.
  • L2 cache: a Redis distributed cache shared by application instances.

The project also uses a Redis backplane. When one API instance removes a cache key, the backplane can notify other instances so their local L1 entries are removed too.

Use of FusionCache in this application

The Products API uses FusionCache in the ServiceRepository class. Read methods check FusionCache before querying SQL Server. If data is found in cache, the API returns it immediately. If data is not found, the repository queries the database through Entity Framework Core, stores the result in FusionCache, and then returns the response.

Write methods save changes to SQL Server first. After a successful insert, update, or delete, they remove affected cache keys so the next read loads fresh data.

Cache key Used by Purpose
products:all GetAllProductsAsync Caches the full list returned by GET /api/products.
products:{id} GetProductByIdAsync Caches one product returned by GET /api/products/{id}.

Advantages of using FusionCache in ASP.NET Core APIs

  • Faster responses: repeated product reads can be served from memory or Redis instead of SQL Server.
  • Reduced database load: frequently requested records do not hit the database on every request.
  • Two-level caching: local memory gives speed, while Redis gives shared cache storage across instances.
  • Fail-safe support: the configured cache can serve a previous value during temporary source failures.
  • Stampede protection: jitter and eager refresh reduce the chance of many requests refreshing the same key at the same time.
  • Backplane synchronization: Redis pub/sub helps keep local caches synchronized in scaled deployments.
  • Cleaner code: application code depends on IFusionCache instead of manually coordinating memory cache, Redis, serialization, and invalidation messages.

Difference between FusionCache and RedisCache

RedisCache and FusionCache are related, but they solve different problems. RedisCache is the ASP.NET Core distributed cache provider backed by Redis. FusionCache is a higher-level caching library that can use RedisCache as its L2 distributed cache.

Feature FusionCache RedisCache
Main role Provides caching strategy and behavior. Provides Redis-backed distributed storage.
Storage Can combine L1 memory and L2 distributed cache. Stores values in Redis.
Advanced behavior Includes fail-safe, jitter, eager refresh, serialization, and backplane support. Requires custom code for these behaviors.
Use in this app Injected into ServiceRepository as IFusionCache. Configured as FusionCache's Redis L2 layer.

Step 1: Create the SQL Server table from Product.sql

The application uses the Products table defined in Product.sql. The script creates an identity primary key, a unique product identifier, product text fields, price, and manufacturer.

Create Table Products (
  ProductRecordId int Identity Primary Key,
  ProductId varchar(50) Not Null Unique,
  ProductName varchar(500) Not Null,
  Description varchar(1000) Not Null,
  Price int Not Null,
  Manufacturer varchar(100) Not Null
);

Run this script in the Company SQL Server database before scaffolding the EF Core model.

Step 2: Add EF Core packages

Add Entity Framework Core packages for SQL Server, design-time tooling, and database-first scaffolding:

dotnet add package Microsoft.EntityFrameworkCore --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 9.0.0

The current project file also contains Microsoft.EntityFrameworkCore.Relational, which provides relational database abstractions used by EF Core providers.

Step 3: Add assemblies and packages required for FusionCache

The project uses these packages for FusionCache, Redis distributed caching, Redis backplane support, and JSON serialization:

dotnet add package ZiggyCreatures.FusionCache --version 2.6.0
dotnet add package ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis --version 2.6.0
dotnet add package ZiggyCreatures.FusionCache.Serialization.SystemTextJson --version 2.6.0
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis --version 10.0.8

The important namespaces added in Program.cs are:

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis;
using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson;

Step 4: Configure connection strings and cache settings

The application reads SQL Server and Redis connection strings from appsettings.json. It also reads cache behavior from the Cache section.

"ConnectionStrings": {
  "CompanyDB": "Data Source=.;Initial Catalog=Company;Integrated Security=SSPI;TrustServerCertificate=True",
  "RedisCache": "localhost:6379,abortConnect=false,connectRetry=3"
},
"Cache": {
  "L1CacheDurationMinutes": 5,
  "L2CacheDurationMinutes": 30,
  "FailSafeMaxHours": 2,
  "EagerRefreshThreshold": 0.8
}

Step 5: Generate Entity and DbContext classes using EF Core Database-First

Because the database table already exists, use EF Core's database-first approach to generate the entity and context classes. The following command creates the Product entity and CompanyContext in the Models folder.

dotnet ef dbcontext scaffold "Data Source=.;Initial Catalog=Company;Integrated Security=SSPI;TrustServerCertificate=True" Microsoft.EntityFrameworkCore.SqlServer --context CompanyContext --context-dir Models --output-dir Models --table Products --force

The generated Product class contains these properties:

public int ProductRecordId { get; set; }
public string ProductId { get; set; } = null!;
public string ProductName { get; set; } = null!;
public string Description { get; set; } = null!;
public int Price { get; set; }
public string Manufacturer { get; set; } = null!;

The generated CompanyContext exposes DbSet<Product> Products and maps the primary key, unique index, string lengths, and SQL column types.

Step 6: Register EF Core and ServiceRepository

Program.cs registers CompanyContext with SQL Server and registers ServiceRepository as a scoped dependency.

builder.Services.AddDbContext<CompanyContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("CompanyDB")));

builder.Services.AddScoped<ServiceRepository>();

Scoped lifetime is appropriate here because the repository depends on CompanyContext, which is also registered as scoped.

Step 7: Register FusionCache in the ASP.NET Core dependency container

FusionCache is registered in Program.cs using AddFusionCache(). The code configures default cache entry options, Redis as the distributed cache, System.Text.Json serialization, and Redis backplane synchronization.

var cacheConfig    = builder.Configuration.GetSection("Cache");
var l1Minutes      = cacheConfig.GetValue<int>("L1CacheDurationMinutes");
var failSafeHours  = cacheConfig.GetValue<int>("FailSafeMaxHours");
var eagerThreshold = cacheConfig.GetValue<double>("EagerRefreshThreshold");
var redisConn      = builder.Configuration.GetConnectionString("RedisCache");

builder.Services.AddFusionCache()
    .WithDefaultEntryOptions(new FusionCacheEntryOptions
    {
        Duration = TimeSpan.FromMinutes(l1Minutes),
        IsFailSafeEnabled = true,
        FailSafeMaxDuration = TimeSpan.FromHours(failSafeHours),
        JitterMaxDuration = TimeSpan.FromSeconds(2),
        EagerRefreshThreshold = (float)eagerThreshold
    })
    .WithDistributedCache(new RedisCache(new RedisCacheOptions
    {
        Configuration = redisConn
    }))
    .WithSystemTextJsonSerializer()
    .WithBackplane(new RedisBackplane(new RedisBackplaneOptions
    {
        Configuration = redisConn
    }));

Detailed explanation of the FusionCache registration code

The above code registers FusionCache in ASP.NET Core's dependency injection container. After this registration, any class can request IFusionCache through constructor injection. In this application, ServiceRepository receives IFusionCache and uses it to read, write, and remove cache entries for product data.

Code Explanation
builder.Services.AddFusionCache() Adds FusionCache services to the ASP.NET Core dependency container. This makes IFusionCache available to application classes such as ServiceRepository.
.WithDefaultEntryOptions(...) Defines default behavior for cache entries. These options apply to cache operations unless a method supplies different options for a specific key.
Duration = TimeSpan.FromMinutes(l1Minutes) Controls how long a cache entry is considered fresh. In this project, l1Minutes is read from Cache:L1CacheDurationMinutes in appsettings.json. If the value is 5, cached product data remains fresh for 5 minutes before FusionCache treats it as expired.
IsFailSafeEnabled = true Enables FusionCache's fail-safe feature. If the cache entry is expired but an old value is still available, FusionCache can temporarily serve that old value when the data source has a problem. This helps the API continue responding during short database, Redis, or network failures.
FailSafeMaxDuration = TimeSpan.FromHours(failSafeHours) Sets the maximum time FusionCache may keep using an expired value for fail-safe responses. In this application, failSafeHours comes from Cache:FailSafeMaxHours. If the value is 2, FusionCache can use the last known cached value for up to 2 hours during temporary failures.
JitterMaxDuration = TimeSpan.FromSeconds(2) Adds a small random amount of time, up to 2 seconds, to cache expiration. This prevents many cache entries from expiring at exactly the same moment. It helps reduce cache stampedes, where many requests miss the cache at once and overload SQL Server.
EagerRefreshThreshold = (float)eagerThreshold Enables background refresh when a cache entry is close to expiration. The value is read from Cache:EagerRefreshThreshold. For example, 0.8 means FusionCache may refresh the entry after 80% of its lifetime has passed. This helps users continue receiving fast cache responses while FusionCache refreshes data in the background.
.WithDistributedCache(new RedisCache(...)) Configures Redis as FusionCache's distributed L2 cache. FusionCache first checks the local in-memory L1 cache. If L1 misses, it can check Redis before going to SQL Server. This is useful when the API is running on multiple servers because Redis is shared by all instances.
Configuration = redisConn Supplies the Redis connection string. In this application, redisConn is read from the RedisCache connection string in appsettings.json, for example localhost:6379,abortConnect=false,connectRetry=3.
.WithSystemTextJsonSerializer() Tells FusionCache to serialize cached objects with System.Text.Json when storing them in Redis. This is required because Redis stores bytes/strings, while the application works with .NET objects such as Product and List<Product>.
.WithBackplane(new RedisBackplane(...)) Configures Redis pub/sub as the FusionCache backplane. The backplane is used to notify other application instances when a cache key is changed or removed. For example, when UpdateProductAsync removes products:{id} and products:all, other instances can also evict their local L1 copies.

In simple terms, this configuration creates a robust caching pipeline. The API reads from fast local memory first, then from Redis, and only then from SQL Server when necessary. It also serializes objects for Redis, protects the API during short failures with fail-safe behavior, reduces simultaneous expirations with jitter, refreshes entries before they become too old, and keeps multiple API instances synchronized through the Redis backplane.

Note that L2CacheDurationMinutes exists in configuration, but the current code does not read it directly. The default FusionCache entry duration is currently taken from L1CacheDurationMinutes.

Step 8: Create ServiceRepository

ServiceRepository is the application's data access and cache coordination class. It receives CompanyContext for database operations and IFusionCache for cache operations.

using Microsoft.EntityFrameworkCore;
using ZiggyCreatures.Caching.Fusion;

namespace Core_FusionCache.Models;

public class ServiceRepository
{
    private readonly CompanyContext _context;
    // IFusionCache is the main entry point for all L1/L2 cache operations.
    private readonly IFusionCache _cache;

    // Cache keys are centralized here so every method references the same string.
    // "products:all" covers the full list returned by GetAllProductsAsync.
    // "products:{id}" covers a single product returned by GetProductByIdAsync.
    private const string AllProductsCacheKey = "products:all";
    private static string ProductKey(int id) => $"products:{id}";

    public ServiceRepository(CompanyContext context, IFusionCache cache)
    {
        _context = context;
        _cache   = cache;
    }

    // Read operations:
    // Hit  - return data from cache and avoid a database query.
    // Miss - query SQL Server, store the result in FusionCache, and return it.

    public async Task<ApiResponse<List<Product>>> GetAllProductsAsync(CancellationToken ct = default)
    {
        var cached = await _cache.TryGetAsync<List<Product>>(AllProductsCacheKey, token: ct);

        if (cached.HasValue)
        {
            return new ApiResponse<List<Product>>(
                cached.Value,
                new CacheMetadata(
                    DataSource:   "Cache",
                    CacheAction:  null,
                    AffectedKeys: [AllProductsCacheKey],
                    Message:      $"Data served from cache (key: '{AllProductsCacheKey}'). " +
                                  "No database query was executed."
                )
            );
        }

        var data = await _context.Products.ToListAsync(ct);
        await _cache.SetAsync(AllProductsCacheKey, data, token: ct);

        return new ApiResponse<List<Product>>(
            data,
            new CacheMetadata(
                DataSource:   "Database",
                CacheAction:  "Populated",
                AffectedKeys: [AllProductsCacheKey],
                Message:      $"Cache miss. Data fetched from database and stored in cache " +
                              $"under key '{AllProductsCacheKey}'."
            )
        );
    }

    public async Task<ApiResponse<Product?>> GetProductByIdAsync(int id, CancellationToken ct = default)
    {
        var key = ProductKey(id);
        var cached = await _cache.TryGetAsync<Product?>(key, token: ct);

        if (cached.HasValue)
        {
            return new ApiResponse<Product?>(
                cached.Value,
                new CacheMetadata(
                    DataSource:   "Cache",
                    CacheAction:  null,
                    AffectedKeys: [key],
                    Message:      $"Data served from cache (key: '{key}'). " +
                                  "No database query was executed."
                )
            );
        }

        var product = await _context.Products.FindAsync(new object[] { id }, ct);

        if (product is not null)
        {
            await _cache.SetAsync(key, product, token: ct);

            return new ApiResponse<Product?>(
                product,
                new CacheMetadata(
                    DataSource:   "Database",
                    CacheAction:  "Populated",
                    AffectedKeys: [key],
                    Message:      $"Cache miss. Data fetched from database and stored in cache " +
                                  $"under key '{key}'."
                )
            );
        }

        return new ApiResponse<Product?>(
            null,
            new CacheMetadata(
                DataSource:   "Database",
                CacheAction:  null,
                AffectedKeys: null,
                Message:      $"Record with id {id} was not found in cache or database."
            )
        );
    }

    // Write operations:
    // Persist to SQL Server first, then evict the affected cache keys.

    public async Task<ApiResponse<Product>> CreateProductAsync(Product product, CancellationToken ct = default)
    {
        _context.Products.Add(product);
        await _context.SaveChangesAsync(ct);

        await _cache.RemoveAsync(AllProductsCacheKey, token: ct);

        return new ApiResponse<Product>(
            product,
            new CacheMetadata(
                DataSource:   "Database",
                CacheAction:  "Invalidated",
                AffectedKeys: [AllProductsCacheKey],
                Message:      $"Record saved to database. Cache key '{AllProductsCacheKey}' " +
                              "has been invalidated. Next GET /api/products will reload from the database."
            )
        );
    }

    public async Task<ApiResponse<Product?>> UpdateProductAsync(int id, Product updated, CancellationToken ct = default)
    {
        var existing = await _context.Products.FindAsync([id], ct);
        if (existing is null)
        {
            return new ApiResponse<Product?>(
                null,
                new CacheMetadata(
                    DataSource:   "Database",
                    CacheAction:  null,
                    AffectedKeys: null,
                    Message:      $"Record with id {id} was not found. No cache change was made."
                )
            );
        }

        existing.ProductId    = updated.ProductId;
        existing.ProductName  = updated.ProductName;
        existing.Description  = updated.Description;
        existing.Price        = updated.Price;
        existing.Manufacturer = updated.Manufacturer;

        await _context.SaveChangesAsync(ct);

        var itemKey = ProductKey(id);
        await _cache.RemoveAsync(itemKey, token: ct);
        await _cache.RemoveAsync(AllProductsCacheKey, token: ct);

        return new ApiResponse<Product?>(
            existing,
            new CacheMetadata(
                DataSource:   "Database",
                CacheAction:  "Invalidated",
                AffectedKeys: [itemKey, AllProductsCacheKey],
                Message:      $"Record updated in database. Cache keys '{itemKey}' and " +
                              $"'{AllProductsCacheKey}' have been invalidated. " +
                              "Next GET will reload fresh data from the database."
            )
        );
    }

    public async Task<ApiResponse<bool>> DeleteProductAsync(int id, CancellationToken ct = default)
    {
        var product = await _context.Products.FindAsync([id], ct);
        if (product is null)
        {
            return new ApiResponse<bool>(
                false,
                new CacheMetadata(
                    DataSource:   "Database",
                    CacheAction:  null,
                    AffectedKeys: null,
                    Message:      $"Record with id {id} was not found. No cache change was made."
                )
            );
        }

        _context.Products.Remove(product);
        await _context.SaveChangesAsync(ct);

        var itemKey = ProductKey(id);
        await _cache.RemoveAsync(itemKey, token: ct);
        await _cache.RemoveAsync(AllProductsCacheKey, token: ct);

        return new ApiResponse<bool>(
            true,
            new CacheMetadata(
                DataSource:   "Database",
                CacheAction:  "Invalidated",
                AffectedKeys: [itemKey, AllProductsCacheKey],
                Message:      $"Record deleted from database. Cache keys '{itemKey}' and " +
                              $"'{AllProductsCacheKey}' have been invalidated."
            )
        );
    }
}

The repository centralizes cache key names so every method uses consistent keys. 

GetAllProductsAsync

GetAllProductsAsync checks products:all by calling _cache.TryGetAsync<List<Product>>(). On a cache hit, it returns the cached list with metadata saying the data source was cache. On a miss, it queries _context.Products.ToListAsync(), stores the result with _cache.SetAsync(), and returns metadata saying the cache was populated.

GetProductByIdAsync

GetProductByIdAsync builds the key products:{id} and probes FusionCache. On a hit, no database query is needed. On a miss, it uses FindAsync to load the product by primary key. If the product exists, the method caches it. If it does not exist, the method returns a not-found response and does not cache a null record.

CreateProductAsync

CreateProductAsync adds a new product to _context.Products and saves the change. After the insert succeeds, it removes products:all because the cached list is now stale.

UpdateProductAsync

UpdateProductAsync first looks up the existing product. If the product exists, it copies the updated values into the tracked entity and calls SaveChangesAsync. It then removes both products:{id} and products:all, because both cached entries may contain old data.

DeleteProductAsync

DeleteProductAsync finds the product, removes it from the database, and saves the change. It then removes the individual product key and the full-list key so future reads do not return deleted data.

Classes used in the current application

Class or file Responsibility
Program.cs Configures services, FusionCache, Swagger, and Minimal API endpoints.
Product EF Core entity mapped to the SQL Server Products table.
CompanyContext EF Core DbContext containing DbSet<Product> and table mapping configuration.
ServiceRepository Coordinates SQL Server reads/writes with FusionCache reads, population, and invalidation.
ApiResponse<T> Wraps API data with cache metadata.
CacheMetadata Reports whether data came from cache or database, what action occurred, and which cache keys were affected.

Step 9: Create endpoints in Program.cs

The application uses ASP.NET Core Minimal APIs. Each endpoint receives ServiceRepository from dependency injection and calls the matching repository method.

Endpoint Method called Behavior
GET /api/products GetAllProductsAsync Returns all products from cache or database.
GET /api/products/{id:int} GetProductByIdAsync Returns one product or a 404 response with cache metadata.
POST /api/products CreateProductAsync Creates a product and invalidates the full-list cache key.
PUT /api/products/{id:int} UpdateProductAsync Updates a product and invalidates item and list cache keys.
DELETE /api/products/{id:int} DeleteProductAsync Deletes a product and invalidates item and list cache keys.
app.MapGet("/api/products", async (ServiceRepository repo) =>
{
    var response = await repo.GetAllProductsAsync();
    return Results.Ok(response);
});

app.MapGet("/api/products/{id:int}", async (int id, ServiceRepository repo) =>
{
    var response = await repo.GetProductByIdAsync(id);
    return response.Data is not null ? Results.Ok(response) : Results.NotFound(response);
});

app.MapPost("/api/products", async (Product product, ServiceRepository repo) =>
{
    var response = await repo.CreateProductAsync(product);
    return Results.Created($"/api/products/{response.Data.ProductRecordId}", response);
});

app.MapPut("/api/products/{id:int}", async (int id, Product product, ServiceRepository repo) =>
{
    var response = await repo.UpdateProductAsync(id, product);
    return response.Data is not null ? Results.Ok(response) : Results.NotFound(response);
});

app.MapDelete("/api/products/{id:int}", async (int id, ServiceRepository repo) =>
{
    var response = await repo.DeleteProductAsync(id);
    return response.Data ? Results.Ok(response) : Results.NotFound(response);
});

End-to-end application flow

For read requests, the client calls a Minimal API endpoint. The endpoint calls ServiceRepository. The repository checks FusionCache, which checks L1 memory first and Redis L2 second. If both cache layers miss, the repository queries SQL Server through CompanyContext and then stores the result in FusionCache.

For write requests, the repository writes to SQL Server first. After the database operation succeeds, it removes stale cache keys. Because Redis backplane support is configured, cache invalidation can be propagated to other running API instances.

Run the Application, the result will be as shown in the video



The code for this article can be downloaded from this link.

Conclusion

FusionCache improves this ASP.NET Core Products API by adding a fast L1 memory cache, Redis-backed L2 caching, fail-safe behavior, eager refresh, jitter, and backplane synchronization. The application keeps the caching logic clear by placing it in ServiceRepository: reads populate cache entries, while successful writes invalidate stale keys. This design reduces database load, improves response times, and keeps product data consistent after create, update, and delete operations.

Popular posts from this blog

ASP.NET Core 8: Creating Custom Authentication Handler for Authenticating Users for the Minimal APIs

ASP.NET Core 7: Using PostgreSQL to store Identity Information

Azure AI Document Intelligence: Processing an Invoice and Saving it in Azure SQL Server Database using Azure Functions