Skip to content

feat(caching): extract IBlogPostCacheService abstraction and centralize cache keys (#109) - #115

Merged
mpaulosky merged 2 commits into
sprint/5-redis-cachingfrom
squad/109-cache-abstraction
Apr 24, 2026
Merged

feat(caching): extract IBlogPostCacheService abstraction and centralize cache keys (#109)#115
mpaulosky merged 2 commits into
sprint/5-redis-cachingfrom
squad/109-cache-abstraction

Conversation

@mpaulosky

Copy link
Copy Markdown
Owner

Closes #109

Working as Sam (Backend Developer)

What

Introduces a two-tier cache abstraction (IBlogPostCacheService) and centralizes all cache key strings in BlogPostCacheKeys. This is the foundation required before handlers can be refactored in #110.

Changes

File Description
src/Web/Infrastructure/Caching/BlogPostCacheKeys.cs Centralizes "blog:all" and $"blog:{id}" — eliminates magic strings in 4 handlers
src/Web/Infrastructure/Caching/IBlogPostCacheService.cs Public interface: GetOrFetchAllAsync, GetOrFetchByIdAsync, InvalidateAllAsync, InvalidateByIdAsync
src/Web/Infrastructure/Caching/BlogPostCacheService.cs internal sealed implementation — L1 (IMemoryCache, 1 min) + L2 (IDistributedCache/Redis, 5 min), JsonException catch-and-remove on bad L2 bytes, CancellationToken.None on removals
src/Web/Infrastructure/Caching/CachingServiceExtensions.cs AddBlogPostCaching() DI extension, registers as Singleton
src/Web/Program.cs Calls builder.Services.AddBlogPostCaching() after AddMemoryCache()

Design Decisions

  • GetOrFetch pattern over split Get/Set — if the interface only exposes Get/Set/Invalidate, handlers would still orchestrate the L1→miss→L2→miss→DB flow themselves, defeating the abstraction. GetOrFetch lets the service own the entire read path.
  • CancellationToken.None for invalidations — a cancelled request CT must not leave Redis stale indefinitely; removals always complete.
  • ValueTask on reads — L1 (IMemoryCache.TryGetValue) is synchronous; ValueTask avoids a Task heap allocation on the hot L1-hit path.
  • Singleton safetyIMemoryCache and IDistributedCache (Redis) are both Singleton in ASP.NET Core; no captive dependency issue.

Related

Boromir and others added 2 commits April 23, 2026 18:10
…print5)

- Add docs/adr/sprint5-caching-abstraction.md (ADR for IBlogPostCacheService)
- Add docs/sprint5-xml-doc-stubs.md (XML doc stubs for Sam to apply)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ze cache keys (#109)

- Add BlogPostCacheKeys with All constant and ById method
- Add IBlogPostCacheService interface (L1+L2 two-tier cache abstraction)
- Add BlogPostCacheService implementation (L1=1min, L2=5min, JsonException handling)
- Add CachingServiceExtensions.AddBlogPostCaching() DI registration
- Register AddBlogPostCaching() in Program.cs after AddMemoryCache()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 24, 2026 01:22
@github-actions github-actions Bot added the squad Squad triage inbox — Lead will assign to a member label Apr 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🏗️ PR Added to Squad Triage Queue

This PR has been labeled with squad and added to the triage queue.

Next steps:

  • The squad Lead will review and assign to an appropriate team member
  • A squad:member label will be added after triage

If you know which squad member should handle this, you can add the appropriate squad:member label yourself.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results Summary

201 tests  ±0   201 ✅ ±0   14s ⏱️ ±0s
  5 suites ±0     0 💤 ±0 
  5 files   ±0     0 ❌ ±0 

Results for commit ec65903. ± Comparison against base commit 7813f99.

@codecov

codecov Bot commented Apr 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 62 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.30%. Comparing base (7813f99) to head (ec65903).
⚠️ Report is 1 commits behind head on sprint/5-redis-caching.

Files with missing lines Patch % Lines
...Web/Infrastructure/Caching/BlogPostCacheService.cs 0.00% 51 Missing and 8 partials ⚠️
...Infrastructure/Caching/CachingServiceExtensions.cs 0.00% 2 Missing ⚠️
...rc/Web/Infrastructure/Caching/BlogPostCacheKeys.cs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                    Coverage Diff                     @@
##           sprint/5-redis-caching     #115      +/-   ##
==========================================================
- Coverage                   78.19%   71.30%   -6.89%     
==========================================================
  Files                          40       43       +3     
  Lines                         642      704      +62     
  Branches                      107      115       +8     
==========================================================
  Hits                          502      502              
- Misses                         95      149      +54     
- Partials                       45       53       +8     
Files with missing lines Coverage Δ
...rc/Web/Infrastructure/Caching/BlogPostCacheKeys.cs 0.00% <0.00%> (ø)
...Infrastructure/Caching/CachingServiceExtensions.cs 0.00% <0.00%> (ø)
...Web/Infrastructure/Caching/BlogPostCacheService.cs 0.00% <0.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a dedicated two-tier caching abstraction for blog post reads/writes and introduces centralized cache key definitions, wiring the new service into the Web DI container to enable upcoming handler refactors.

Changes:

  • Introduces IBlogPostCacheService and BlogPostCacheService (L1 IMemoryCache + L2 IDistributedCache) plus AddBlogPostCaching() DI registration.
  • Centralizes cache key strings in BlogPostCacheKeys.
  • Adds Sprint 5 documentation (ADR + XML doc stub draft).

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/Web/Program.cs Registers the new blog post cache service in DI.
src/Web/Infrastructure/Caching/IBlogPostCacheService.cs Defines the two-tier cache abstraction API.
src/Web/Infrastructure/Caching/BlogPostCacheService.cs Implements L1/L2 get-or-fetch logic and invalidation.
src/Web/Infrastructure/Caching/BlogPostCacheKeys.cs Adds centralized cache key constants/helpers.
src/Web/Infrastructure/Caching/CachingServiceExtensions.cs Provides AddBlogPostCaching() registration extension.
docs/sprint5-xml-doc-stubs.md Adds draft XML doc stubs and planned README updates.
docs/adr/sprint5-caching-abstraction.md Documents the caching abstraction decision.

JsonSerializer.SerializeToUtf8Bytes(list, JsonOpts),
RedisOpts,
ct);
return result;
Comment on lines +28 to +32
public async ValueTask<IReadOnlyList<BlogPostDto>> GetOrFetchAllAsync(
Func<Task<IReadOnlyList<BlogPostDto>>> fetch,
CancellationToken ct = default)
{
// L1 hit (synchronous — no heap allocation)
Comment on lines +1 to +2
# Sprint 5 — XML Doc Comment Stubs

Comment on lines +16 to +57
/// Retrieves all blog posts from the cache, checking L1 (in-memory) before L2 (Redis).
/// Returns <see langword="null"/> when no cached entry exists.
/// </summary>
/// <param name="ct">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <returns>
/// A <see cref="Task{TResult}"/> that resolves to a read-only list of <see cref="BlogPostDto"/>
/// instances, or <see langword="null"/> if the entry is not present in either cache tier.
/// </returns>
Task<IReadOnlyList<BlogPostDto>?> GetAllAsync(CancellationToken ct);

/// <summary>
/// Stores all blog posts in both cache tiers (L1 in-memory and L2 Redis).
/// </summary>
/// <param name="posts">The list of blog post DTOs to cache.</param>
/// <param name="ct">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous set operation.</returns>
Task SetAllAsync(IReadOnlyList<BlogPostDto> posts, CancellationToken ct);

/// <summary>
/// Retrieves a single blog post by its unique identifier from the cache,
/// checking L1 (in-memory) before L2 (Redis).
/// Returns <see langword="null"/> when no cached entry exists for the given ID.
/// </summary>
/// <param name="id">The unique identifier of the blog post to retrieve.</param>
/// <param name="ct">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <returns>
/// A <see cref="Task{TResult}"/> that resolves to the cached <see cref="BlogPostDto"/>,
/// or <see langword="null"/> if no entry is found.
/// </returns>
Task<BlogPostDto?> GetByIdAsync(Guid id, CancellationToken ct);

/// <summary>
/// Stores a single blog post in both cache tiers (L1 in-memory and L2 Redis),
/// keyed by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier used as the cache key.</param>
/// <param name="post">The blog post DTO to cache.</param>
/// <param name="ct">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous set operation.</returns>
Task SetByIdAsync(Guid id, BlogPostDto post, CancellationToken ct);

/// <summary>
/// back-fill L1 on a hit. Write operations populate both tiers simultaneously.
/// Invalidation removes from both tiers.
/// </remarks>
public sealed class BlogPostCacheService : IBlogPostCacheService

### Registration

`BlogPostCacheService` is registered in `Program.cs` as a scoped or singleton service alongside `IMemoryCache` and the Redis `IDistributedCache`.
Comment on lines +15 to +19
/// <summary>Key for the list of all blog posts.</summary>
public const string All = "blog:all";

/// <summary>Key for a single blog post identified by <paramref name="id"/>.</summary>
public static string ById(Guid id) => $"blog:{id}";
@mpaulosky
mpaulosky merged commit c9117e6 into sprint/5-redis-caching Apr 24, 2026
19 of 20 checks passed
@mpaulosky
mpaulosky deleted the squad/109-cache-abstraction branch April 24, 2026 01:46
mpaulosky pushed a commit that referenced this pull request Apr 24, 2026
Sam's #115 and #118 have merged, so handlers now use IBlogPostCacheService.
Both arch tests pass green:
  - Features_Should_Not_Reference_IDistributedCache_Directly
  - Features_Should_Not_Reference_IMemoryCache_Directly

Closes #113
Working as Gimli (Tester)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mpaulosky pushed a commit that referenced this pull request Apr 24, 2026
… slnx

Sam's #115 (IBlogPostCacheService) and #118 (handler refactor) have merged.
Changes:
- Add Unit.Tests to MyBlog.slnx (safe now that IBlogPostCacheService exists)
- Fix 3 NSubstitute mock setups: async lambda → ValueTask<T>(task) ctor
  (NSubstitute cannot convert async lambdas to ValueTask<T> return type)

All 16 unit tests pass green across 5 handler test classes.

Closes #111
Working as Gimli (Tester)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mpaulosky pushed a commit that referenced this pull request Apr 24, 2026
… slnx

Sam's #115 (IBlogPostCacheService) and #118 (handler refactor) have merged.
Changes:
- Add Unit.Tests to MyBlog.slnx (safe now that IBlogPostCacheService exists)
- Fix 3 NSubstitute mock setups: async lambda → ValueTask<T>(task) ctor
  (NSubstitute cannot convert async lambdas to ValueTask<T> return type)

All 16 unit tests pass green across 5 handler test classes.

Closes #111
Working as Gimli (Tester)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

squad Squad triage inbox — Lead will assign to a member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants