Output Caching
Output caching is a performance optimization technique that stores the complete HTTP response of CQRS query or operation. When a subsequent identical request arrives, the cached response is served directly without executing the handler, reducing database load and improving response times.
LeanCode CoreLibrary integrates ASP.NET Core's built-in OutputCaching middleware into the CQRS pipeline, placing it at the correct position where cache policies have access to the fully deserialized CQRS request and response objects. This provides a type-safe way to define caching behavior per query or operation based on the actual request payload, not just HTTP-level data.
Packages
| Package | Link | Application in section |
|---|---|---|
| LeanCode.CQRS.OutputCaching | Output caching policies |
Why use Output Caching?
- Performance improvement: Significantly reduces response times for frequently requested data by serving cached responses.
- Reduced database load: Minimizes database queries for cacheable data, allowing your system to handle more concurrent users.
- Bandwidth savings: Supports HTTP conditional requests (ETags, Last-Modified) to minimize data transfer.
Why integrate Output Caching into our CQRS?
- Type-safe policies: Define caching behavior per query/operation with full access to deserialized request and response payloads, not just HTTP-level data.
- Correct pipeline integration: The middleware can be placed at the right position in the CQRS pipeline, ensuring cache policies have access to the fully processed CQRS request object.
Limitations
Local Execution Not Supported
Output caching does not work with local execution. The ASP.NET Core OutputCaching middleware requires a real HTTP context with stream manipulation capabilities that are not available in the local execution pipeline. If you need caching with local execution, consider implementing an application-level cache instead.
Commands Not Supported
Output caching is only available for queries and operations. Commands change system state and should not be cached.
Configuration
Registration
To enable output caching in your application, register it in the ConfigureServices method:
public override void ConfigureServices(IServiceCollection services)
{
// Register CQRS
services.AddCQRS(TypesCatalog.Of<ExampleQuery>(), TypesCatalog.Of<ExampleHandler>());
// Register output caching with automatic policy discovery
services.AddCQRSOutputCache(TypesCatalog.Of<ExampleHandler>()); // Catalog with the policies
}
The AddCQRSOutputCache method:
- Automatically discovers all cache policies in the specified assemblies
- Registers them with the DI container
- Configures the ASP.NET Core OutputCache middleware to use them
- Adds endpoint metadata to apply caching to the appropriate CQRS endpoints
Cache Policies Required
Important: Queries and operations are cached only if they have a corresponding cache policy class
that implements ICQRSOutputCachePolicy<T> for their respective type T.
The AddCQRSOutputCache method scans the provided assemblies for policy implementations.
Without a policy, a query/operation will execute normally even if .CacheOutput() is in the pipeline.
Enabling in the CQRS Pipeline
To enable output caching for CQRS endpoints, simply add .CacheOutput() to the query and/or operation pipelines:
protected override void ConfigureApp(IApplicationBuilder app)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapRemoteCQRS("/api", cqrs =>
{
// Enable caching in the query pipeline
cqrs.Queries = q => q
.Secure()
.CacheOutput(); // <- Add this to enable output caching
// Enable caching in the operation pipeline
cqrs.Operations = o => o
.Secure()
.CacheOutput(); // <- Add this to enable output caching
// Commands don't support caching
cqrs.Commands = c => c
.Secure()
.Validate();
});
});
}
Do NOT Use app.UseOutputCache() for CQRS Endpoints
You must not call app.UseOutputCache() globally when using output caching with CQRS endpoints.
The .CacheOutput() method in the CQRS pipeline automatically adds the OutputCache middleware
at the correct position in the pipeline - after the CQRS request payload has been deserialized
and is available to cache policies. Adding app.UseOutputCache() globally would add the middleware
in the wrong place (before CQRS deserialization), preventing cache policies from accessing the request payload
and breaking the caching functionality.
CacheOutput() Placement
The .CacheOutput() method should typically be called after security middlewares.
This ensures that only authorized requests may get cached responses, so that reduces a class of potential bugs.
Mixed Scenarios: CQRS + Other Endpoints
If you need output caching for both CQRS endpoints and non-CQRS endpoints (e.g., minimal APIs, controllers), make sure to apply the UseOutputCache() middleware only to non-CQRS endpoints. You can use eg. UseWhen(...) or Map(...) for that as shown below:
protected override void ConfigureApp(IApplicationBuilder app)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
// Apply output caching ONLY to non-CQRS endpoints
app.UseWhen(
ctx => !ctx.Request.Path.StartsWithSegments("/api/cqrs"),
app => app.UseOutputCache()
);
app.UseEndpoints(endpoints =>
{
// CQRS endpoints use .CacheOutput() in their pipeline
endpoints.MapRemoteCQRS("/api/cqrs", cqrs =>
{
cqrs.Queries = q => q.Secure().CacheOutput();
cqrs.Operations = o => o.Secure().CacheOutput();
});
// Other endpoints can use .CacheOutput() attribute
endpoints.MapGet("/api/health", () => "OK").CacheOutput();
});
}
or
protected override void ConfigureApp(IApplicationBuilder app)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.Map("/api/cqrs", app => UseEndpoints(endpoints =>
// CQRS endpoints use .CacheOutput() in their pipeline
endpoints.MapRemoteCQRS("/", cqrs =>
{
cqrs.Queries = q => q.Secure().CacheOutput();
cqrs.Operations = o => o.Secure().CacheOutput();
})));
// Apply output caching ONLY to non-CQRS endpoints
app.UseOutputCache();
app.UseEndpoints(endpoints =>
{
// Other endpoints can use .CacheOutput() attribute
endpoints.MapGet("/api/health", () => "OK").CacheOutput();
});
}
Cache Policies Are Required
Simply adding .CacheOutput() to the pipeline is not enough to enable caching.
Each query or operation you want to cache must have a corresponding cache policy class that implements ICQRSOutputCachePolicy<TObject>.
Without a policy, the query/operation will execute normally without any caching.
Queries and operations without policies are not affected by the .CacheOutput() middleware and execute as if caching was not enabled.
Creating Cache Policies
Cache policies define the caching behavior for specific queries or operations
and must be provided if a query or operation should be considered eligible for caching.
A policy must implement ICQRSOutputCachePolicy<TObject> to be scanned from assembly,
which also implements the IOutputCachePolicy interface from ASP.NET Core.
While implementing the policies it is recommended to actually inherit from the CQRSOutputCachePolicy<TObject> : ICQRSOutputCachePolicy<TObject>,
which also sets up some basic caching flags while using the base methods.
During implementing a policy we have access to the to both the request payload and the response payload, in order to be able to make some decisions basing on them.
Query Cache Policy
For queries, extend CQRSQueryOutputCachePolicy<TQuery, TResult>:
using LeanCode.CQRS.OutputCaching.BasePolicies;
using Microsoft.AspNetCore.OutputCaching;
public class GetProjectQuery : IQuery<ProjectDTO>
{
public string ProjectId { get; set; }
}
public class GetProjectCachePolicy : CQRSQueryOutputCachePolicy<GetProjectQuery, ProjectDTO>
{
public override ValueTask CacheRequestCoreAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
// Enable caching for this query
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
// Cache for 5 minutes
context.ResponseExpirationTimeSpan = TimeSpan.FromMinutes(5);
// Vary cache by ProjectId
var query = context.HttpContext.GetCQRSRequestPayload<GetProjectQuery>();;
context.CacheVaryByRules.VaryByValues["projectId"] = query.ProjectId;
return ValueTask.CompletedTask;
}
public override ValueTask ServeResponseAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
// Optionally inspect the result to decide if it should be cached
var result = context.HttpContext.GetCQRSRequiredResultPayload<ProjectDTO>();
if (result is null)
{
// Don't cache null results
context.AllowCacheStorage = false;
}
return ValueTask.CompletedTask;
}
}
Operation Cache Policy
For operations, extend CQRSOperationOutputCachePolicy<TOperation, TResult>:
public class GenerateProjectReportOperation : IOperation<ReportDTO>
{
public string ProjectId { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public ReportFormat Format { get; set; } // PDF, CSV, etc.
}
public class GenerateProjectReportCachePolicy
: CQRSOperationOutputCachePolicy<GenerateProjectReportOperation, ReportDTO>
{
public override ValueTask CacheRequestCoreAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
var operation = context.HttpContext.GetCQRSRequestPayload<GenerateProjectReportOperation>();
// Enable caching for completed historical reports
// (Don't cache if the date range includes today - data is still changing)
if (operation.EndDate < DateOnly.FromDateTime(DateTime.UtcNow))
{
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
// Cache historical reports for 1 hour (they're expensive to generate)
context.ResponseExpirationTimeSpan = TimeSpan.FromHours(1);
// Vary by all operation parameters
context.CacheVaryByRules.VaryByValues["projectId"] = operation.ProjectId;
context.CacheVaryByRules.VaryByValues["startDate"] = operation.StartDate.ToString("yyyy-MM-dd");
context.CacheVaryByRules.VaryByValues["endDate"] = operation.EndDate.ToString("yyyy-MM-dd");
context.CacheVaryByRules.VaryByValues["format"] = operation.Format.ToString();
}
return ValueTask.CompletedTask;
}
public override ValueTask ServeResponseAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
var result = context.HttpContext.GetCQRSRequiredResultPayload<ReportDTO>();
// Don't cache if report generation failed
if (result?.FileContent == null)
{
context.AllowCacheStorage = false;
}
return ValueTask.CompletedTask;
}
}
Policy Lifecycle Methods
Cache policies can override three methods that correspond to different stages of the caching middleware:
CacheRequestCoreAsync
This is CacheRequestAsync from the base interface, but renamed to CacheRequestCoreAsync in the CQRSOutputCachePolicy base class.
Called before checking if a cached response exists. This is the main method where you should implement caching logic like:
- Enable or disable caching for the request
- Configure cache key variation rules
- Set expiration times
public override ValueTask CacheRequestCoreAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
// Access the request payload
var query = context.HttpContext.GetCQRSRequestPayload<ExampleQuery>();
// Enable caching
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
// Configure cache behavior
context.ResponseExpirationTimeSpan = TimeSpan.FromMinutes(10);
// Vary cache by specific properties
context.CacheVaryByRules.VaryByValues["key"] = query.SomeProperty;
return ValueTask.CompletedTask;
}
ServeFromCacheAsync
Called when a cached entry is found and about to be served. Use this in exceptional cases to:
- Modify response headers before serving from cache
public override ValueTask ServeFromCacheAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
// You can inspect the cached response
// and decide whether to serve it
return ValueTask.CompletedTask;
}
ServeResponseAsync
Called after the handler executes and before storing the response in cache. Use this to:
- Inspect the result and conditionally disable storing in cache
public override ValueTask ServeResponseAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
// Access the response payload
var result = context.HttpContext.GetCQRSRequiredResultPayload<ExampleResult>();
// Don't cache null responses
if (result is null)
{
context.AllowCacheStorage = false;
}
return ValueTask.CompletedTask;
}
Accessing Request and Response Payloads
Cache policies provide helper methods to access the CQRS object and result:
public class ExampleCachePolicy : CQRSQueryOutputCachePolicy<ExampleQuery, ExampleResult>
{
public override ValueTask CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
// Get the query/operation object
var query = context.HttpContext.GetCQRSRequestPayload<ExampleQuery>();
// Use query properties to configure caching
context.CacheVaryByRules.VaryByValues["id"] = query.Id;
return ValueTask.CompletedTask;
}
public override ValueTask ServeResponseAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
// Get the result object (only available after handler execution)
var result = context.HttpContext.GetCQRSRequiredResultPayload<ExampleResult>();
// Conditionally disable storing in cache basing on result
if (result is null)
{
context.AllowCacheStorage = false;
}
return ValueTask.CompletedTask;
}
}
Cache Variation
Cache variation determines how cache keys are constructed. Different cache keys store separate cached responses.
Vary by Query/Operation Properties
public override ValueTask CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
var query = context.HttpContext.GetCQRSRequestPayload<ExampleQuery>();
// Each unique combination creates a separate cache entry
context.CacheVaryByRules.VaryByValues["userId"] = query.UserId;
context.CacheVaryByRules.VaryByValues["date"] = query.Date.ToString("yyyy-MM-dd");
return ValueTask.CompletedTask;
}
Vary by User
To cache different responses per user:
public override ValueTask CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
// Vary by authenticated user
if (context.HttpContext.User.Identity?.IsAuthenticated == true)
{
var userId = context.HttpContext.User.FindFirst("sub")?.Value;
context.CacheVaryByRules.VaryByValues["user"] = userId ?? "";
}
return ValueTask.CompletedTask;
}
Vary by Headers
public override ValueTask CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
// Vary by Accept-Language header
context.CacheVaryByRules.HeaderNames.Add("Accept-Language");
return ValueTask.CompletedTask;
}
ETags and Conditional Requests
ASP.NET Core's output caching automatically handles ETags when you set them in your handler:
public class GetProjectHandler : IQueryHandler<GetProjectQuery, ProjectDTO>
{
public async Task<ProjectDTO> ExecuteAsync(HttpContext context, GetProjectQuery query)
{
var project = await GetProjectAsync(query.ProjectId);
// Set ETag based on project version
context.Response.Headers.ETag = $"\"{project.Version}\"";
return project;
}
}
When the middleware caches this response, it stores the ETag. On subsequent requests with If-None-Match headers, it automatically returns 304 Not Modified if the ETag matches.
Cache Eviction
Look up the official ASP.NET Core documentation for details on cache eviction.
In order to set up tags for cache eviction set them in your cache policy:
public override ValueTask CacheRequestCoreAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
// Enable caching
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
// Set tags for eviction
context.Tags.Add("projects");
return ValueTask.CompletedTask;
}
Advanced Configuration
Custom Cache Store
By default, output caching uses an in-memory store. For distributed scenarios, configure a custom cache store. Note that AddCQRSOutputCache automatically registers the output cache service (via services.AddOutputCache()), so you can configure it directly:
public override void ConfigureServices(IServiceCollection services)
{
// Register CQRS
services.AddCQRS(TypesCatalog.Of<ExampleQuery>(), TypesCatalog.Of<ExampleHandler>());
// Register CQRS output caching
services.AddCQRSOutputCache(TypesCatalog.Of<ExampleHandler>());
// Configure output cache to use Redis for distributed caching
services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration = "localhost:6379";
});
}
Global Cache Settings
Configure default settings for output caching providing Action<OutputCacheOptions> in AddCQRSOutputCache:
public override void ConfigureServices(IServiceCollection services)
{
services.AddCQRSOutputCache(
TypesCatalog.Of<ExampleHandler>(),
options =>
{
options.DefaultExpirationTimeSpan = TimeSpan.FromMinutes(10);
options.MaximumBodySize = 1024 * 1024; // 1MB
options.SizeLimit = 100 * 1024 * 1024; // 100MB total
});
}
Best Practices
✅ Do
- Cache queries with stable data: Queries that return data that doesn't change frequently are ideal candidates for caching.
- Cache expensive operations with deterministic outputs: Operations like report generation, file exports, or data transformations that are expensive to compute but produce the same result for the same inputs are excellent candidates for caching.
- Set appropriate expiration times: Balance freshness with performance based on your data's volatility and computational cost.
- Vary by relevant properties: Ensure cache keys capture all factors that affect the response.
- Consider memory usage: Monitor cache size and set appropriate limits, especially for operations that return large files.
- Use ETags for large responses: Reduce bandwidth usage with conditional requests, particularly important for generated files.
- Test cache behavior: Verify that cache invalidation and variation work as expected.
❌ Don't
- Don't use
app.UseOutputCache()globally: Never add this middleware to the main pipeline when you have CQRS endpoints. Use.CacheOutput()in the CQRS pipeline instead. If you need caching for non-CQRS endpoints, useapp.UseWhen()to apply it conditionally. - Don't cache commands: Commands change state and caching them is not supported.
- Don't cache operations with side effects that must execute every time: If an operation has important side effects (e.g., logging, analytics, notifications) that need to happen on every call, it should not be cached. Only cache operations where the primary value is the returned data.
- Don't cache user-specific data without varying by user: Always vary by user ID for personalized data.
- Don't set very long expiration times: Stale data can cause confusion and bugs.
- Don't assume local execution with output cache works: Output caching only works with HTTP requests, local executor will fail if output caching is attempted on the local pipeline.
Testing Cache Policies
When testing queries and operations with cache policies:
[Fact]
public async Task Query_is_cached_correctly()
{
var client = factory.CreateClient();
var request = new GetProjectQuery { ProjectId = "123" };
// First request - executes handler
var response1 = await client.PostAsJsonAsync("/api/query/GetProjectQuery", request);
response1.EnsureSuccessStatusCode();
// Second identical request - served from cache
var response2 = await client.PostAsJsonAsync("/api/query/GetProjectQuery", request);
response2.EnsureSuccessStatusCode();
// Check Age header indicates cached response
response2.Headers.Should().ContainKey("Age");
}