Caching

June 10, 2026 · View on GitHub

DataSurface provides two caching layers: query caching (server-side result caching via IDistributedCache) and response caching (HTTP-level ETag and Cache-Control headers). Both are independent and can be used separately or together.


Query Caching

Cache CRUD query results server-side using any IDistributedCache implementation (Redis, SQL Server, memory, etc.).

Setup

// Add a distributed cache implementation
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
});

// Configure DataSurface caching
builder.Services.Configure<DataSurfaceCacheOptions>(options =>
{
    options.EnableQueryCaching = true;
    options.DefaultCacheDuration = TimeSpan.FromMinutes(5);
    options.ResourceConfigs["Product"] = new ResourceCacheConfig
    {
        Duration = TimeSpan.FromMinutes(30),
        CacheList = true,
        CacheGet = true
    };
});

// Register the cache implementation
builder.Services.AddSingleton<IQueryResultCache, DistributedQueryResultCache>();

Behavior

OperationCaching
ListCached by resource key + full query spec (filters, sort, page)
GetCached by resource key + entity ID
CreateInvalidates all cached entries for the resource
UpdateInvalidates the specific entity cache and list caches
DeleteInvalidates the specific entity cache and list caches

Cache hits are still full reads: global hooks (ICrudHook) run and an audit entry is written even when the result is served from cache. Caching is automatically bypassed when per-user security (tenant isolation, row-level security, resource/field authorization) applies to the resource, so cached data is never served across users.

Per-Resource Configuration

Different resources can have different cache durations and strategies:

options.ResourceConfigs["Product"] = new ResourceCacheConfig
{
    Duration = TimeSpan.FromMinutes(30),  // Long cache for rarely-changing data
    CacheList = true,
    CacheGet = true
};

options.ResourceConfigs["Order"] = new ResourceCacheConfig
{
    Duration = TimeSpan.FromSeconds(30),  // Short cache for frequently-changing data
    CacheList = true,
    CacheGet = false  // Don't cache individual orders
};

Feature Flag

opt.Features.EnableQueryCaching = true;  // default: true in Standard/Full

Response Caching

HTTP-level caching using ETags and Cache-Control headers.

ETag-Based Conditional GET

When ETags are enabled, GET responses include an ETag header:

GET /api/users/1

HTTP/1.1 200 OK
ETag: W/"AAAAAAB="

Subsequent requests can include If-None-Match to get a 304 Not Modified when data hasn't changed:

GET /api/users/1
If-None-Match: W/"AAAAAAB="

HTTP/1.1 304 Not Modified

This saves bandwidth — the full response body is not sent.

Cache-Control Headers

Configure Cache-Control headers for client-side caching:

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    EnableConditionalGet = true,          // If-None-Match → 304
    CacheControlMaxAgeSeconds = 300       // Cache-Control: private, max-age=300
});

The emitted header is Cache-Control: private, max-age=N — responses are typically tenant- or user-scoped, so shared caches (CDNs, reverse proxies) must not store them.

Configuration

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    EnableEtags = true,                   // Include ETag in responses (default: false)
    EnableConditionalGet = true,          // Support If-None-Match → 304
    CacheControlMaxAgeSeconds = 300       // Set Cache-Control header
});

Compiled Queries

Pre-compiled EF Core queries for improved performance on common operations:

builder.Services.AddSingleton<CompiledQueryCache>();

// Usage in custom code or overrides
var cache = sp.GetRequiredService<CompiledQueryCache>();
var findById = cache.GetOrCreateFindByIdQuery<User, int>("Id");
var user = findById(dbContext, 5);

Compiled queries avoid the overhead of query expression tree compilation on each request.