원클릭으로
caching-strategies
Use when adding caching to .NET APIs or optimizing response times with distributed cache, output cache, or ETags.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Use when adding caching to .NET APIs or optimizing response times with distributed cache, output cache, or ETags.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Use when configuring API response formats, custom formatters, or Accept header handling.
Use when building controller-based REST APIs with action results, model binding, or MediatR integration.
Use when creating RESTful API controllers with MediatR dispatch and ProblemDetails error responses.
Use when designing gRPC services, proto files, or adding gRPC-Web or JSON transcoding.
Use when building minimal API endpoints with route groups, filters, or TypedResults.
Use when setting up OpenAPI spec generation or Scalar API documentation UI.
| name | caching-strategies |
| description | Use when adding caching to .NET APIs or optimizing response times with distributed cache, output cache, or ETags. |
| metadata | {"category":"api","agent":"api-designer","when-to-use":"When implementing caching strategies, output caching, or distributed cache patterns"} |
Server-side cache built into ASP.NET Core. Caches entire HTTP responses.
// Program.cs — register and enable output caching
builder.Services.AddOutputCache(options =>
{
// Default policy: cache all GET/HEAD responses for 60s
options.AddBasePolicy(p => p.Expire(TimeSpan.FromSeconds(60)));
// Named policy with tag for invalidation
options.AddPolicy("Products", p => p
.Expire(TimeSpan.FromMinutes(5))
.Tag("products"));
// Per-user policy using Authorization header variation
options.AddPolicy("UserSpecific", p => p
.SetVaryByHeader("Authorization")
.Expire(TimeSpan.FromSeconds(30)));
});
app.UseOutputCache();
// Minimal API — apply output cache policy
app.MapGet("/products", async (ISender sender, CancellationToken ct) =>
{
var products = await sender.Send(new ListProductsQuery(), ct);
return Results.Ok(products);
}).CacheOutput("Products");
// Controller — attribute-based
[HttpGet]
[OutputCache(Duration = 60, Tags = ["products"])]
public async Task<ActionResult<List<ProductResponse>>> GetAll(
CancellationToken ct)
{
var result = await sender.Send(new ListProductsQuery(), ct);
return Ok(result);
}
// Tag-based invalidation using IOutputCacheStore
app.MapPost("/products", async (
CreateProductCommand command, ISender sender,
IOutputCacheStore cache, CancellationToken ct) =>
{
var id = await sender.Send(command, ct);
await cache.EvictByTagAsync("products", ct);
return Results.Created($"/products/{id}", new { id });
});
Client-side caching via HTTP Cache-Control headers. The browser or CDN caches responses.
// Program.cs
builder.Services.AddResponseCaching();
app.UseResponseCaching();
// Controller with Cache-Control headers
[HttpGet]
[ResponseCache(Duration = 120, Location = ResponseCacheLocation.Any,
VaryByHeader = "Accept")]
public async Task<ActionResult<List<CategoryResponse>>> GetCategories(
CancellationToken ct)
{
var result = await sender.Send(new ListCategoriesQuery(), ct);
return Ok(result);
}
// No-cache for sensitive data
[HttpGet("me")]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None,
NoStore = true)]
public async Task<ActionResult<UserProfile>> GetProfile(
CancellationToken ct)
{
var result = await sender.Send(new GetProfileQuery(), ct);
return Ok(result);
}
IDistributedCache backed by Redis, SQL Server, or NCache. Shared across app instances.
// Program.cs — Redis distributed cache
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration =
builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MyApp:";
});
// Service using IDistributedCache
public sealed class CachedProductService(
IDistributedCache cache,
IProductRepository repository)
{
private static readonly DistributedCacheEntryOptions CacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
SlidingExpiration = TimeSpan.FromMinutes(2)
};
public async Task<ProductResponse?> GetByIdAsync(
Guid id, CancellationToken ct)
{
var cacheKey = $"product:{id}";
// Try cache first
var cached = await cache.GetStringAsync(cacheKey, ct);
if (cached is not null)
{
return JsonSerializer.Deserialize<ProductResponse>(cached);
}
// Fall back to database
var product = await repository.GetByIdAsync(id, ct);
if (product is null) return null;
var response = product.ToResponse();
await cache.SetStringAsync(
cacheKey, JsonSerializer.Serialize(response),
CacheOptions, ct);
return response;
}
public async Task InvalidateAsync(Guid id, CancellationToken ct)
{
await cache.RemoveAsync($"product:{id}", ct);
}
}
Two-tier cache: L1 in-process memory + L2 distributed. Built-in stampede protection.
// Program.cs — register HybridCache with Redis L2
builder.Services.AddHybridCache(options =>
{
options.MaximumPayloadBytes = 1024 * 1024; // 1 MB
options.MaximumKeyLength = 256;
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(2)
};
});
// Add Redis as the L2 backing store
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration =
builder.Configuration.GetConnectionString("Redis");
});
// Service using HybridCache — stampede-safe GetOrCreateAsync
public sealed class ProductService(
HybridCache cache, IProductRepository repository)
{
public async Task<ProductResponse> GetByIdAsync(
Guid id, CancellationToken ct)
{
return await cache.GetOrCreateAsync(
$"product:{id}",
async token =>
{
var product = await repository.GetByIdAsync(id, token)
?? throw new NotFoundException(
$"Product {id} not found");
return product.ToResponse();
},
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(1)
},
cancellationToken: ct);
}
public async Task InvalidateAsync(Guid id, CancellationToken ct)
{
await cache.RemoveAsync($"product:{id}", ct);
}
}
Return 304 Not Modified when content has not changed, saving bandwidth.
// ETag generation helper
public static class ETagHelper
{
public static string Generate(object data)
{
var json = JsonSerializer.Serialize(data);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"\"{Convert.ToBase64String(bytes)}\"";
}
}
// Controller with ETag support
[HttpGet("{id:guid}")]
public async Task<ActionResult<ProductResponse>> GetById(
Guid id, CancellationToken ct)
{
var product = await sender.Send(new GetProductQuery(id), ct);
if (product is null) return NotFound();
var etag = ETagHelper.Generate(product);
if (Request.Headers.IfNoneMatch.Contains(etag))
{
return StatusCode(StatusCodes.Status304NotModified);
}
Response.Headers.ETag = etag;
Response.Headers.CacheControl = "private, max-age=60";
return Ok(product);
}
Strategies to keep cached data consistent with the source of truth.
// Tag-based invalidation (Output Cache)
app.MapPut("/products/{id:guid}", async (
Guid id, UpdateProductCommand command, ISender sender,
IOutputCacheStore cache, CancellationToken ct) =>
{
await sender.Send(command with { Id = id }, ct);
await cache.EvictByTagAsync("products", ct);
return Results.NoContent();
});
// Event-based invalidation with MediatR notification
public sealed record ProductUpdatedEvent(Guid ProductId) : INotification;
public sealed class InvalidateProductCacheHandler(
HybridCache cache) : INotificationHandler<ProductUpdatedEvent>
{
public async Task Handle(
ProductUpdatedEvent notification, CancellationToken ct)
{
await cache.RemoveAsync(
$"product:{notification.ProductId}", ct);
}
}
Key invalidation approaches:
EvictByTagAsyncAbsoluteExpiration for automatic cleanupRemoveAsync directly in write endpoints| Scenario | Cache Type | Why |
|---|---|---|
| Public GET, same response for all users | Output Cache | Server-side, zero client config |
| Static assets and CDN-friendly responses | Response Cache | Cache-Control for browser/CDN |
| Shared data across multiple app instances | Distributed Cache (Redis) | Centralized, survives restarts |
| High-throughput reads, local + shared needs | HybridCache (.NET 9+) | L1 speed + L2 consistency + stampede guard |
| Bandwidth-sensitive mobile clients | ETag / Conditional | 304 saves payload transfer |
| Reference data (countries, currencies) | HybridCache or Output Cache | Rarely changes, high read volume |
| User session or cart data | Distributed Cache | Per-user, shared across instances |
| Expensive aggregation queries | HybridCache with long TTL | Compute once, serve many |
| Problem | Why It Hurts | Correct Approach |
|---|---|---|
| No TTL on cache entries | Memory grows unbounded, stale data forever | Always set AbsoluteExpiration or SlidingExpiration |
| Caching behind auth without Vary | User A sees User B's data | SetVaryByHeader("Authorization") or skip output cache |
| Cache-then-write without invalidation | Reads return stale data after mutations | Invalidate or evict on every write path |
| Caching error responses | Errors served repeatedly from cache | Only cache successful (2xx) responses |
| In-memory cache in multi-instance deploy | Each instance has different cache state | Use IDistributedCache or HybridCache |
| Stampede on cache miss (thundering herd) | All requests hit DB simultaneously | Use HybridCache.GetOrCreateAsync with stampede protection |
| Over-caching volatile data | Users see outdated information | Match TTL to change frequency; skip real-time data |