diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet.sln b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet.sln
new file mode 100644
index 000000000..c731660b7
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet.sln
@@ -0,0 +1,28 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.12.35527.113
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HybridCachingDotNet", "HybridCachingDotNet\HybridCachingDotNet.csproj", "{FF8DF36E-D891-4831-B5D1-99DD21423E2E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {FF8DF36E-D891-4831-B5D1-99DD21423E2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FF8DF36E-D891-4831-B5D1-99DD21423E2E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FF8DF36E-D891-4831-B5D1-99DD21423E2E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FF8DF36E-D891-4831-B5D1-99DD21423E2E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.csproj b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.csproj
new file mode 100644
index 000000000..b1ef77fcb
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.http b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.http
new file mode 100644
index 000000000..41f9be2a6
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.http
@@ -0,0 +1,29 @@
+@HybridCachingDotNet_HostAddress = http://localhost:5009
+
+GET {{HybridCachingDotNet_HostAddress}}/cm-courses/4
+Accept: application/json
+
+###
+
+POST {{HybridCachingDotNet_HostAddress}}/cm-courses
+Accept: application/json
+Content-Type: application/json
+
+{
+ "id": 4,
+ "name": "NewCourse",
+ "category": "NewCategory"
+}
+
+###
+
+DELETE {{HybridCachingDotNet_HostAddress}}/cm-courses/4
+Accept: application/json
+
+###
+
+
+DELETE {{HybridCachingDotNet_HostAddress}}/cm-courses/category/NewCategory
+Accept: application/json
+
+###
diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Models/CmCourse.cs b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Models/CmCourse.cs
new file mode 100644
index 000000000..1284ebc2d
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Models/CmCourse.cs
@@ -0,0 +1,10 @@
+namespace HybridCachingDotNet.Models;
+
+public class CmCourse
+{
+ public int Id { get; set; }
+
+ public required string Name { get; set; }
+
+ public required string Category { get; set; }
+}
diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Program.cs b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Program.cs
new file mode 100644
index 000000000..51f133645
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Program.cs
@@ -0,0 +1,68 @@
+using HybridCachingDotNet.Models;
+using HybridCachingDotNet.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Caching.Hybrid;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddHybridCache(options =>
+{
+ options.MaximumPayloadBytes = 1024 * 10 * 10;
+ options.MaximumKeyLength = 256;
+
+ options.DefaultEntryOptions = new HybridCacheEntryOptions
+ {
+ Expiration = TimeSpan.FromMinutes(30),
+ LocalCacheExpiration = TimeSpan.FromMinutes(30)
+ };
+
+ options.ReportTagMetrics = true;
+ options.DisableCompression = true;
+});
+
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+app.MapGet("/cm-courses/{id}", async (
+ ICmCourseService cmCourseService,
+ int id,
+ CancellationToken cancellationToken) =>
+ {
+ var result = await cmCourseService.GetCourseAsync(id, cancellationToken);
+ return Results.Ok(result);
+ }
+);
+
+app.MapPost("/cm-courses", async (
+ ICmCourseService cmCourseService,
+ [FromBody]CmCourse course,
+ CancellationToken cancellationToken) =>
+{
+ await cmCourseService.PostCourseAsync(course, cancellationToken);
+ return Results.Ok();
+}
+);
+
+app.MapDelete("/cm-courses/{id}", async (
+ ICmCourseService cmCourseService,
+ int id,
+ CancellationToken cancellationToken) =>
+{
+ await cmCourseService.InvalidateByCourseIdAsync(id, cancellationToken);
+ return Results.Ok();
+}
+);
+
+app.MapDelete("/cm-courses/category/{tag}", async (
+ ICmCourseService cmCourseService,
+ string tag,
+ CancellationToken cancellationToken) =>
+{
+ await cmCourseService.InvalidateByCategoryAsync(tag, cancellationToken);
+ return Results.Ok();
+}
+);
+
+app.Run();
+
diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Properties/launchSettings.json b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Properties/launchSettings.json
new file mode 100644
index 000000000..d5d88575a
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Properties/launchSettings.json
@@ -0,0 +1,28 @@
+{
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "dotnetRunMessages": true,
+ "applicationUrl": "http://localhost:5009"
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ },
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:60033/",
+ "sslPort": 44312
+ }
+ }
+}
\ No newline at end of file
diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/CmCourseService.cs b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/CmCourseService.cs
new file mode 100644
index 000000000..775336854
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/CmCourseService.cs
@@ -0,0 +1,73 @@
+namespace HybridCachingDotNet.Services;
+
+using HybridCachingDotNet.Models;
+using Microsoft.Extensions.Caching.Hybrid;
+
+public class CmCourseService(HybridCache cache) : ICmCourseService
+{
+ public static readonly List courseList = [
+ new CmCourse
+ {
+ Id = 1,
+ Name = "WebAPI",
+ Category = "Backend"
+ },
+ new CmCourse
+ {
+ Id = 2,
+ Name = "Microservices",
+ Category = "Backend"
+ },
+ new CmCourse
+ {
+ Id = 3,
+ Name = "Blazer",
+ Category = "Frontend"
+ },
+ ];
+
+ public async Task GetCourseAsync(int id, CancellationToken cancellationToken = default)
+ {
+ return await cache.GetOrCreateAsync(
+ $"course-{id}",
+ async token =>
+ {
+ await Task.Delay(1000, token);
+ var course = courseList.FirstOrDefault(course => course.Id == id);
+ return course;
+ },
+ options: new HybridCacheEntryOptions
+ {
+ Expiration = TimeSpan.FromMinutes(30),
+ LocalCacheExpiration = TimeSpan.FromMinutes(30)
+ },
+ tags: ["course"],
+ cancellationToken: cancellationToken
+ );
+ }
+
+ public async Task PostCourseAsync(CmCourse course, CancellationToken cancellationToken = default)
+ {
+ courseList.Add(course);
+
+ await cache.SetAsync($"course-{course.Id}",
+ course,
+ options: new HybridCacheEntryOptions
+ {
+ Expiration = TimeSpan.FromMinutes(30),
+ LocalCacheExpiration = TimeSpan.FromMinutes(30)
+ },
+ tags: [$"cat-{course.Category}"],
+ cancellationToken: cancellationToken);
+ }
+
+ public async Task InvalidateByCourseIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ await cache.RemoveAsync($"course-{id}", cancellationToken);
+ }
+
+ public async Task InvalidateByCategoryAsync(string tag, CancellationToken cancellationToken = default)
+ {
+ await cache.RemoveByTagAsync($"cat-{tag}", cancellationToken);
+ }
+}
diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/ICmCourseService.cs b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/ICmCourseService.cs
new file mode 100644
index 000000000..137ae1348
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/ICmCourseService.cs
@@ -0,0 +1,14 @@
+namespace HybridCachingDotNet.Services;
+
+using HybridCachingDotNet.Models;
+
+public interface ICmCourseService
+{
+ Task GetCourseAsync(int id, CancellationToken cancellationToken = default);
+
+ Task PostCourseAsync(CmCourse course, CancellationToken cancellationToken = default);
+
+ Task InvalidateByCourseIdAsync(int id, CancellationToken cancellationToken = default);
+
+ Task InvalidateByCategoryAsync(string tag, CancellationToken cancellationToken = default);
+}
diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.Development.json b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.json b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/aspnetcore-features/HybridCachingDotNet/Tests/CmCourseServiceTests.cs b/aspnetcore-features/HybridCachingDotNet/Tests/CmCourseServiceTests.cs
new file mode 100644
index 000000000..9df2c6308
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/Tests/CmCourseServiceTests.cs
@@ -0,0 +1,80 @@
+namespace Tests;
+
+using HybridCachingDotNet.Models;
+using HybridCachingDotNet.Services;
+using Microsoft.Extensions.Caching.Hybrid;
+using NSubstitute;
+
+[TestClass]
+public class CmCourseServiceTests
+{
+ [TestMethod]
+ public async Task GivenHybridCache_WhenGetCourseAsync_GetOrCreateAsyncIsCalled()
+ {
+ var hybridCache = Substitute.For();
+
+ var sut = new CmCourseService(hybridCache);
+
+ var actual = await sut.GetCourseAsync(1);
+
+ await hybridCache.Received().GetOrCreateAsync("course-1",
+ Arg.Any>>(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any()
+ );
+ }
+
+ [TestMethod]
+ public async Task GivenHybridCache_WhenPostCourseAsync_SetAsyncIsCalled()
+ {
+ var hybridCache = Substitute.For();
+
+ var sut = new CmCourseService(hybridCache);
+
+ var course = new CmCourse
+ {
+ Id = 4,
+ Name = "NewCourse",
+ Category = "NewCategory",
+ };
+
+ await sut.PostCourseAsync(course);
+
+ await hybridCache.Received().SetAsync("course-4",
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any()
+ );
+ }
+
+ [TestMethod]
+ public async Task GivenHybridCache_WhenInvalidateCourseByIdAsync_RemoveAsyncIsCalled()
+ {
+ var hybridCache = Substitute.For();
+
+ var sut = new CmCourseService(hybridCache);
+
+
+ await sut.InvalidateByCourseIdAsync(1);
+
+ await hybridCache.RemoveAsync("course-1",
+ Arg.Any()
+ );
+ }
+
+ [TestMethod]
+ public async Task GivenHybridCache_WhenInvalidateByCategoryAsync_RemoveByTagAsyncIsCalled()
+ {
+ var hybridCache = Substitute.For();
+
+ var sut = new CmCourseService(hybridCache);
+
+ await sut.InvalidateByCategoryAsync("NewCategory");
+
+ await hybridCache.RemoveByTagAsync("cat-NewCategory",
+ Arg.Any()
+ );
+ }
+}
\ No newline at end of file
diff --git a/aspnetcore-features/HybridCachingDotNet/Tests/MSTestSettings.cs b/aspnetcore-features/HybridCachingDotNet/Tests/MSTestSettings.cs
new file mode 100644
index 000000000..aaf278c84
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/Tests/MSTestSettings.cs
@@ -0,0 +1 @@
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/aspnetcore-features/HybridCachingDotNet/Tests/Tests.csproj b/aspnetcore-features/HybridCachingDotNet/Tests/Tests.csproj
new file mode 100644
index 000000000..8e23fec74
--- /dev/null
+++ b/aspnetcore-features/HybridCachingDotNet/Tests/Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net9.0
+ latest
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+