#
Caching data
#
Caching Data in Your API
#
START FROM PREVIOUS MODULE'S END
Developing the Web API Business Rules
#
What you will implement in this module
- HTTP response caching for controllers/actions (client/proxy cache hints)
- A unified, robust in-process caching strategy for business-layer reads using
IMemoryCachevia a small helper calledVersionedMemoryCache - Reliable invalidation on writes (Create/Update/Delete) and for cross-entity impacts
- Consistent cache keys, TTLs, and stampede protection
This replaces the previous ad-hoc patterns (e.g., "Album-<id>" keys, inconsistent expirations, and missing invalidation).
#
Response Caching (HTTP-level)
Response caching is about client/proxy behavior and is complementary to server-side memory caching.
#
1) Add response caching to DI
ServicesConfiguration.AddCaching()
public static void AddCaching(this IServiceCollection services, IConfiguration configuration)
{
services.AddResponseCaching();
services.AddMemoryCache();
// Optional: leave distributed cache registration if you also demo it later
// services.AddDistributedSqlServerCache(...);
}
#
2) Enable middleware in Program/Startup
Ensure UseResponseCaching runs after CORS.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAppSettings(builder.Configuration);
builder.Services.AddConnectionProvider(builder.Configuration);
builder.Services.ConfigureRepositories();
builder.Services.ConfigureSupervisor();
builder.Services.AddAPILogging();
builder.Services.AddCORS();
builder.Services.ConfigureValidators();
builder.Services.AddCaching(builder.Configuration);
builder.Services.AddControllers();
var app = builder.Build();
app.UseCors();
app.UseResponseCaching();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
#
3) Opt-in per controller/action
[Route("api/[controller]")]
[ApiController]
[EnableCors("CorsPolicy")]
[ResponseCache(Duration = 604800)] // 7 days in seconds
public class GenreController : ControllerBase
{
// ...
}
Response header example: Cache-Control: public,max-age=604800
Use Postman/browser dev tools to observe the effect. This does not cache on the server; it only instructs clients/proxies.
#
In-memory caching with VersionedMemoryCache (server-side)
We standardize on an in-process cache-aside approach backed by IMemoryCache and a small helper you already have in the project: Chinook.Domain/Supervisor/Caching/VersionedMemoryCache.cs.
Key benefits:
- Standardized versioned keys per entity (automatic bulk invalidation)
- Per-key async locking to prevent cache stampedes
- Centralized TTL policies
- Minimal code in
ChinookSupervisormethods
#
Core concepts
- Key schema (namespaced + versioned):
- Single entity:
entity:{ver}:{id}(e.g.,album:3:10) - Lists/FK:
entity:{ver}:{suffix}:{id}(e.g.,track:5:by-album:10) - All:
entity:{ver}:all
- Single entity:
- Per-entity version: internal counter stored in cache under
ver:{entity}. Bumping it invalidates all old keys logically. - Cache-aside flow: Try cache → load repo → set cache with TTL → return.
- Writes must remove by-id and bump the entity version (and sometimes cross-entity versions) to avoid stale lists.
#
Helper API you use in Supervisor
From VersionedMemoryCache:
// Build logical keys (they include the current version internally)
string EntityById(string entity, int id)
string ByFk(string entity, string suffix, int id)
string All(string entity)
// Get or create with per-key lock
Task<T?> GetOrCreateAsync<T>(string key, Func<Task<T?>> factory, MemoryCacheEntryOptions options)
// Invalidate
void Remove(string key)
void BumpVersion(string entity)
#
DI wiring (already present)
services.AddMemoryCache()inAddCachingChinookSupervisorinjectsIMemoryCacheand constructs_vCache:
private readonly IMemoryCache _cache;
private readonly VersionedMemoryCache _vCache;
public ChinookSupervisor(..., IMemoryCache memoryCache, ...)
{
_cache = memoryCache;
_vCache = new VersionedMemoryCache(_cache);
}
#
TTL conventions
- Entity by id: Absolute 1 day, Sliding 1 hour
- Large collections (GetAll): 3 minutes
- Lists by FK (by artist/album/genre/etc.): 10 minutes
- Reference/static-like (genre, media type): Absolute 2 days, Sliding 6 hours for by-id; 12 hours for lists
Use MemoryCacheEntryOptions accordingly in each method.
#
How to write Supervisor methods with the new cache
Below are canonical patterns using code that mirrors what’s already implemented in your repo.
#
Reads — Get by id
public async Task<AlbumApiModel?> GetAlbumById(int id)
{
var key = _vCache.EntityById("album", id);
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromDays(1))
.SetSlidingExpiration(TimeSpan.FromHours(1));
return await _vCache.GetOrCreateAsync(key, async () =>
{
var album = await _albumRepository.GetById(id);
if (album == null) return null;
var model = album.Convert();
var artist = await GetArtistById(album.ArtistId);
if (artist != null) model.ArtistName = artist.Name;
model.Tracks = (await GetTrackByAlbumId(id) ?? Array.Empty<TrackApiModel>()).ToList();
return model;
}, options);
}
#
Reads — Get all
public async Task<IEnumerable<AlbumApiModel>> GetAllAlbum()
{
var key = _vCache.All("album");
var options = new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(3));
return await _vCache.GetOrCreateAsync(key, async () =>
{
var albums = await _albumRepository.GetAll();
return albums.ConvertAll();
}, options) ?? Array.Empty<AlbumApiModel>();
}
#
Reads — List by foreign key
public async Task<IEnumerable<TrackApiModel>> GetTrackByAlbumId(int id)
{
var key = _vCache.ByFk("track", "by-album", id);
var options = new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(10));
return await _vCache.GetOrCreateAsync(key, async () =>
{
var tracks = await _trackRepository.GetByAlbumId(id);
return tracks.ConvertAll();
}, options) ?? Array.Empty<TrackApiModel>();
}
#
Writes — Invalidate correctly
On successful update/delete: remove the current by-id item and bump version.
public async Task<bool> UpdateAlbum(AlbumApiModel albumApiModel)
{
// ... map & repo update
var updated = await _albumRepository.Update(album);
if (updated)
{
_vCache.Remove(_vCache.EntityById("album", album.Id));
_vCache.BumpVersion("album");
}
return updated;
}
On create: bump the version (lists need refresh; by-id isn’t cached yet).
public async Task<AlbumApiModel> AddAlbum(AlbumApiModel newAlbumApiModel)
{
// ... validate & repo add
_vCache.BumpVersion("album");
return newAlbumApiModel;
}
#
Cross-entity invalidation rules (examples)
- Artist changes may impact album views → also bump
albumversion when artist updates - Genre/MediaType updates impact track views → bump
trackversion - Employee changes can impact customer support-rep projections → bump
customerversion - Customer changes may impact invoice lists → bump
invoiceversion
These are already reflected in the Supervisor partials.
#
Migration guide from the old approach
Replace any of the following patterns:
- Ad-hoc keys like
"Album-" + idor"Artist-" + id - Misuse of
MemoryCacheEntryOptions(e.g., passing aTimeSpaninstead of the options object toSet) - Missing invalidation on writes
- Mixed in-memory and distributed caching for read paths that should remain in-process
With these steps:
- Ensure
services.AddMemoryCache()is present. - Use
_vCachein all Supervisor read methods:- Build a key with
EntityById,All, orByFk. - Call
GetOrCreateAsyncwith a factory andMemoryCacheEntryOptions.
- Build a key with
- In all write methods (Add/Update/Delete):
- On update/delete:
_vCache.Remove(_vCache.EntityById("entity", id)); - Then
_vCache.BumpVersion("entity"); - Add cross-entity bumps as needed.
- On update/delete:
- Remove any leftover direct
IMemoryCache.Set/Getwith raw strings. - Do not cache
InvoiceLineandPlaylistTrack— intentionally left uncached.
#
Verification checklist
Manual checks (Postman or similar):
- Cold read (e.g.,
GET /api/album/10) hits DB once and populates cache. Repeated reads return fast. - Concurrent requests for the same key do not stampede the DB (harder to see, but you can simulate with a small script).
- After an update to an album, the next
GETmisses and repopulates due to version bump. - Artist rename triggers fresh album views if you also bump
album. - Memory pressure/evictions (optional): enable logging on eviction callbacks to observe behavior.
#
Troubleshooting / FAQ
Q: I updated an entity, but lists still return old data briefly.
- A: Ensure you call
_vCache.BumpVersion("entity")on writes and that readers use versioned keys. Old keys become unreachable; new reads will repopulate.
- A: Ensure you call
Q: Why do I sometimes still see a DB call on a repeated read?
- A: Entry might have expired (TTL), been evicted under pressure, or the version was bumped due to a related write.
Q: Can I make TTLs configurable?
- A: Yes. Wrap TTLs in options loaded from
IConfigurationor create a centralCachePoliciesstatic with values bound fromappsettings.
- A: Yes. Wrap TTLs in options loaded from
Q: Do I need distributed cache?
- A: Not for this in-process strategy. Distributed cache can be used for cross-instance sharing, but keep the Supervisor’s primary layer as
IMemoryCachefor speed.
- A: Not for this in-process strategy. Distributed cache can be used for cross-instance sharing, but keep the Supervisor’s primary layer as
#
Advanced (Optional): Distributed Caching Appendix
If you want to demonstrate SQL Server distributed cache in addition to the in-memory layer, you can keep the existing tooling steps (creating the cache table, connection string, and services.AddDistributedSqlServerCache). However, the Supervisor’s current design standardizes on VersionedMemoryCache (in-memory). If you later add a 2nd layer, use the same key scheme and invalidation semantics. Keep concerns separated (L1 memory, optional L2 distributed).
Tooling recap (optional):
- Install global tool:
dotnet tool install --global dotnet-sql-cache - Create DB/table:
dotnet sql-cache create "<connstring>" dbo ChinookCache - Add connection string
ChinookSQLCachetoappsettings.json - Register in
AddCachingviaservices.AddDistributedSqlServerCache(...)
Do not mix distributed cache calls into the Supervisor read paths unless you explicitly implement a layered cache helper. Stick with _vCache for now.
#
Summary
You now have:
- HTTP response caching for client/proxy hints
- A consistent, versioned, stampede-safe in-memory caching strategy in the Supervisor
- Clear invalidation on writes and cross-entity impacts
- A migration path off legacy patterns and optional guidance for adding a distributed cache later