Skip to content

Commit dd769a8

Browse files
committed
feat: Implement hybrid caching with Redis and Aspire orchestration, including localization-aware extensions and a new caching guide.
1 parent 7c25926 commit dd769a8

9 files changed

Lines changed: 162 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ BookStore/
134134
- **[API Conventions](docs/api-conventions-guide.md)** - Time handling and JSON serialization standards
135135
- **[ETag Support](docs/etag-guide.md)** - Optimistic concurrency and caching
136136
- **[Correlation & Causation IDs](docs/correlation-causation-guide.md)** - Distributed tracing
137+
- **[Caching Guide](docs/caching-guide.md)** - Hybrid caching with Redis and localization support
137138
- **[Real-time Notifications](docs/signalr-guide.md)** - SignalR integration and optimistic updates
138139
- **[Contributing Guidelines](CONTRIBUTING.md)** - How to contribute to this project
139140

docs/caching-guide.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Caching Guide
2+
3+
This guide explains how to configure and use caching in the BookStore API, specifically focusing on the hybrid caching strategy integrated with .NET Aspire and localization.
4+
5+
## Overview
6+
7+
The BookStore API uses **Hybrid Caching** (`HybridCache`), enriched by **.NET Aspire** for seamless distributed cache orchestration.
8+
9+
**Components:**
10+
- **L1 Cache (In-Memory)**: Local, fast access.
11+
- **L2 Cache (Distributed)**: Redis, orchestrated by Aspire.
12+
- **Stampede Protection**: Built-in to coalescing requests.
13+
- **Localization Awareness**: Automatically scopes cache keys to the user's culture.
14+
15+
## Configuration
16+
17+
### Aspire Orchestration
18+
19+
The caching infrastructure is automatically wired up by .NET Aspire.
20+
21+
1. **AppHost**: Declares the Redis resource.
22+
```csharp
23+
var cache = builder.AddRedis("cache");
24+
builder.AddProject<Projects.BookStore_ApiService>("apiservice")
25+
.WithReference(cache);
26+
```
27+
28+
2. **Service Defaults**: Redis configuration is injected via service discovery. The API service adds the distributed cache:
29+
```csharp
30+
builder.Services.AddRedisDistributedCache("cache");
31+
builder.Services.AddHybridCache();
32+
```
33+
34+
## Localized Caching
35+
36+
**Challenge**: Content (e.g., book descriptions) changes based on the user's language (`Accept-Language`). If you cache "book-123" without considering culture, a Portuguese user might receive English content cached by a previous request.
37+
38+
**Solution**: Use the `GetOrCreateLocalizedAsync` extension method.
39+
40+
### Usage Pattern
41+
42+
Instead of `GetOrCreateAsync`, use `GetOrCreateLocalizedAsync`. This method automatically appends the current UI culture (e.g., `|en-US`, `|pt-BR`) to the cache key.
43+
44+
```csharp
45+
public class BookService(HybridCache cache)
46+
{
47+
public async Task<Book?> GetBookAsync(string id, CancellationToken token = default)
48+
{
49+
// Key becomes "book-{id}|{culture}" automatically
50+
return await cache.GetOrCreateLocalizedAsync(
51+
key: $"book-{id}",
52+
factory: async cancel => await RetrieveBookFromDatabaseAsync(id, cancel),
53+
token: token
54+
);
55+
}
56+
}
57+
```
58+
59+
### Invalidation
60+
61+
When invalidating localized content, ensure you remove the localized entry or use tags.
62+
63+
**Remove specific localized entry**:
64+
```csharp
65+
// Removes "book-{id}|{current_culture}"
66+
await cache.RemoveLocalizedAsync($"book-{id}");
67+
```
68+
69+
**Remove by Tag (Recommended)**:
70+
Tags are culture-agnostic. Tagging all variations of a book allows you to clear all languages at once.
71+
72+
```csharp
73+
await cache.GetOrCreateLocalizedAsync(
74+
$"book-{id}",
75+
factory,
76+
tags: [$"book:{id}"]
77+
);
78+
79+
// Clears English, Portuguese, Spanish, etc. for this book
80+
await cache.RemoveByTagAsync($"book:{id}");
81+
```
82+
83+
## Best Practices
84+
85+
1. **Use Tags for Entities**: Always tag cache entries with the entity ID (e.g., `book:123`). This makes invalidation much easier than tracking every culture-variant key.
86+
2. **Use Localized Methods**: Prefer `GetOrCreateLocalizedAsync` for any content that *might* be localized, even if it isn't yet.
87+
3. **Environment Awareness**: Aspire handles the connection strings. In development, it spins up a Redis container. In production, it points to your managed Redis instance.

docs/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
href: logging-guide.md
2323
- name: ETag
2424
href: etag-guide.md
25+
- name: Caching
26+
href: caching-guide.md
2527
- name: Correlation & Causation
2628
href: correlation-causation-guide.md
2729
- name: API Conventions

src/ApiService/BookStore.ApiService/BookStore.ApiService.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
<ItemGroup>
2424
<PackageReference Include="Asp.Versioning.Http" Version="8.1.1" />
2525
<PackageReference Include="Aspire.Azure.Storage.Blobs" Version="13.1.0" />
26+
<PackageReference Include="Aspire.StackExchange.Redis.DistributedCaching" Version="13.1.0" />
2627
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
2728
<PackageReference Include="Marten" Version="8.17.0" />
2829
<PackageReference Include="Marten.AspNetCore" Version="8.17.0" />
2930
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
31+
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.0.0-preview.9.24556.5" />
3032
<PackageReference Include="Microsoft.OpenApi" Version="2.0.0" />
3133
<PackageReference Include="Npgsql.OpenTelemetry" Version="10.0.1" />
3234
<PackageReference Include="Scalar.AspNetCore" Version="2.11.10" />

src/ApiService/BookStore.ApiService/Infrastructure/Extensions/ApplicationServicesExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ public static IServiceCollection AddApplicationServices(
4646
_ = services.AddResponseCaching();
4747
_ = services.AddOutputCache();
4848

49+
#pragma warning disable EXTEXP0018 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
50+
_ = services.AddHybridCache();
51+
#pragma warning restore EXTEXP0018 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
52+
4953
return services;
5054
}
5155

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using System.Globalization;
2+
using Microsoft.Extensions.Caching.Hybrid;
3+
4+
namespace BookStore.ApiService.Infrastructure.Extensions;
5+
6+
/// <summary>
7+
/// Extension methods for HybridCache to support localization-aware caching
8+
/// </summary>
9+
public static class HybridCacheExtensions
10+
{
11+
/// <summary>
12+
/// Gets or creates a cache entry using a key that is automatically scoped to the current UI culture.
13+
/// </summary>
14+
/// <typeparam name="TItem">The type of the item in the cache.</typeparam>
15+
/// <param name="cache">The hybrid cache instance.</param>
16+
/// <param name="key">The base cache key.</param>
17+
/// <param name="factory">The factory to create the item if it does not exist.</param>
18+
/// <param name="options">Optional entry options.</param>
19+
/// <param name="tags">Optional tags for the entry.</param>
20+
/// <param name="token">Cancellation token.</param>
21+
/// <returns>The cached or newly created item.</returns>
22+
public static ValueTask<TItem> GetOrCreateLocalizedAsync<TItem>(
23+
this HybridCache cache,
24+
string key,
25+
Func<CancellationToken, ValueTask<TItem>> factory,
26+
HybridCacheEntryOptions? options = null,
27+
IEnumerable<string>? tags = null,
28+
CancellationToken token = default)
29+
{
30+
var localizedKey = GetLocalizedKey(key);
31+
return cache.GetOrCreateAsync(localizedKey, factory, options, tags, token);
32+
}
33+
34+
/// <summary>
35+
/// Removes a cache entry using a key that is automatically scoped to the current UI culture.
36+
/// </summary>
37+
/// <param name="cache">The hybrid cache instance.</param>
38+
/// <param name="key">The base cache key.</param>
39+
/// <param name="token">Cancellation token.</param>
40+
public static ValueTask RemoveLocalizedAsync(
41+
this HybridCache cache,
42+
string key,
43+
CancellationToken token = default)
44+
{
45+
var localizedKey = GetLocalizedKey(key);
46+
return cache.RemoveAsync(localizedKey, token);
47+
}
48+
49+
/// <summary>
50+
/// Helper to append current culture to the key.
51+
/// Format: "key|culture" (e.g., "book-123|en-US")
52+
/// </summary>
53+
private static string GetLocalizedKey(string key)
54+
{
55+
var culture = CultureInfo.CurrentUICulture.Name;
56+
// Use a pipe delimiter - distinct from colon usually used for hierarchy
57+
return $"{key}|{culture}";
58+
}
59+
}

src/ApiService/BookStore.ApiService/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
// Add Azure Blob Storage client (Azurite locally, Azure in production)
1616
builder.AddAzureBlobServiceClient("blobs");
1717

18+
// Add Redis distributed cache
19+
builder.AddRedisDistributedCache("cache");
20+
1821
// Configure services
1922
builder.Services.AddJsonConfiguration(builder.Environment);
2023
builder.Services.AddApplicationServices(builder.Configuration);

src/BookStore.AppHost/AppHost.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313

1414
var blobs = storage.AddBlobs("blobs");
1515

16+
var cache = builder.AddRedis("cache");
17+
1618
var apiService = builder.AddProject<Projects.BookStore_ApiService>("apiservice")
1719
.WithReference(bookStoreDb)
1820
.WithReference(blobs) // Add blob storage reference
21+
.WithReference(cache)
1922
.WithHttpHealthCheck("/health")
2023
.WithExternalHttpEndpoints()
2124
.WithUrlForEndpoint("http", url =>

src/BookStore.AppHost/BookStore.AppHost.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<ItemGroup>
99
<PackageReference Include="Aspire.Hosting.Azure.Storage" Version="13.1.0" />
1010
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.1.0" />
11+
<PackageReference Include="Aspire.Hosting.Redis" Version="13.1.0" />
1112
</ItemGroup>
1213

1314
<ItemGroup>

0 commit comments

Comments
 (0)