Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Compendium.sln
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Sto
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Storage.Tests", "tests\Unit\Compendium.Abstractions.Storage.Tests\Compendium.Abstractions.Storage.Tests.csproj", "{585E082A-E775-426B-BEA5-D41D3AF53DE7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Caching", "src\Abstractions\Compendium.Abstractions.Caching\Compendium.Abstractions.Caching.csproj", "{4A6496B8-5833-428F-92D7-3EB331F5CA32}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Caching.Tests", "tests\Unit\Compendium.Abstractions.Caching.Tests\Compendium.Abstractions.Caching.Tests.csproj", "{262B8E91-B53C-49BC-833D-739DD4CFC2E1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -460,6 +464,14 @@ Global
{585E082A-E775-426B-BEA5-D41D3AF53DE7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{585E082A-E775-426B-BEA5-D41D3AF53DE7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{585E082A-E775-426B-BEA5-D41D3AF53DE7}.Release|Any CPU.Build.0 = Release|Any CPU
{4A6496B8-5833-428F-92D7-3EB331F5CA32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A6496B8-5833-428F-92D7-3EB331F5CA32}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A6496B8-5833-428F-92D7-3EB331F5CA32}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A6496B8-5833-428F-92D7-3EB331F5CA32}.Release|Any CPU.Build.0 = Release|Any CPU
{262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6}
Expand Down Expand Up @@ -543,5 +555,7 @@ Global
{D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B} = {A2517BFF-352C-4123-81B2-2D848F3FB497}
{B4108100-52A7-4680-A0C4-7EB6955DB5F6} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2}
{585E082A-E775-426B-BEA5-D41D3AF53DE7} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
{4A6496B8-5833-428F-92D7-3EB331F5CA32} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2}
{262B8E91-B53C-49BC-833D-739DD4CFC2E1} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
EndGlobalSection
EndGlobal
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.16" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.16" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.16" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.16" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.16" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// -----------------------------------------------------------------------
// <copyright file="CachingErrors.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

namespace Compendium.Abstractions.Caching;

/// <summary>
/// Provides standardized error definitions for caching operations.
/// </summary>
public static class CachingErrors
{
/// <summary>
/// Gets the error code prefix for caching errors.
/// </summary>
public const string Prefix = "Caching";

/// <summary>
/// The supplied TTL was zero or negative.
/// </summary>
public static Error InvalidTtl(TimeSpan ttl) =>
Error.Validation(
$"{Prefix}.InvalidTtl",
$"TTL must be positive; received {ttl}.");

/// <summary>
/// The cache backend failed to satisfy the request and the failure is non-retriable
/// from the caller's perspective.
/// </summary>
public static Error BackendFailure(string message) =>
Error.Failure($"{Prefix}.BackendFailure", message);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<AssemblyName>Compendium.Abstractions.Caching</AssemblyName>
<RootNamespace>Compendium.Abstractions.Caching</RootNamespace>
<PackageId>Compendium.Abstractions.Caching</PackageId>
<Description>Caching abstractions for Compendium Framework: ICache port for provider-agnostic key/value caching (Get / Set / Remove / Exists) with TTL.</Description>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Compendium.Abstractions\Compendium.Abstractions.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Compendium.Abstractions.Caching.Tests" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// -----------------------------------------------------------------------
// <copyright file="GlobalUsings.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

global using Compendium.Core.Results;
69 changes: 69 additions & 0 deletions src/Abstractions/Compendium.Abstractions.Caching/ICache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// -----------------------------------------------------------------------
// <copyright file="ICache.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

namespace Compendium.Abstractions.Caching;

/// <summary>
/// Provides provider-agnostic key/value caching operations with TTL.
/// Implementations may target backends such as in-memory dictionaries, Redis, Memcached,
/// or distributed caches behind <c>IDistributedCache</c>.
/// </summary>
/// <remarks>
/// <para>
/// Implementations should never throw for ordinary cache misses or transient backend failures;
/// they MUST surface failures via <see cref="Result"/> instead. Argument-validation errors
/// (null / whitespace key) are permitted to throw.
/// </para>
/// <para>
/// Tenant isolation, key namespacing, and serialization are implementation concerns and
/// MUST be transparent to callers.
/// </para>
/// </remarks>
public interface ICache
{
/// <summary>
/// Retrieves the cached value at <paramref name="key"/>, or <c>null</c> when no
/// live entry exists. A missing key is a successful result with a <c>null</c> value,
/// not a failure.
/// </summary>
/// <typeparam name="T">The expected value type.</typeparam>
/// <param name="key">The cache key. Must be non-null and non-whitespace.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result containing the cached value (or <c>null</c>), or an error.</returns>
Task<Result<T?>> GetAsync<T>(string key, CancellationToken cancellationToken = default);

/// <summary>
/// Stores <paramref name="value"/> at <paramref name="key"/>, replacing any existing entry.
/// </summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="key">The cache key. Must be non-null and non-whitespace.</param>
/// <param name="value">The value to store.</param>
/// <param name="ttl">Optional absolute time-to-live. When <c>null</c>, the entry has no expiry.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result indicating success or an error.</returns>
Task<Result> SetAsync<T>(
string key,
T value,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Removes the entry at <paramref name="key"/>. Removing a missing key is a no-op success.
/// </summary>
/// <param name="key">The cache key. Must be non-null and non-whitespace.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result indicating success or an error.</returns>
Task<Result> RemoveAsync(string key, CancellationToken cancellationToken = default);

/// <summary>
/// Determines whether a live entry exists at <paramref name="key"/>.
/// </summary>
/// <param name="key">The cache key. Must be non-null and non-whitespace.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result containing <c>true</c> when an entry exists, otherwise <c>false</c>; or an error.</returns>
Task<Result<bool>> ExistsAsync(string key, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// -----------------------------------------------------------------------
// <copyright file="InMemoryCache.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

using Compendium.Abstractions.Caching;
using Compendium.Multitenancy;
using Microsoft.Extensions.Caching.Memory;

namespace Compendium.Infrastructure.Caching;

/// <summary>
/// In-memory implementation of <see cref="ICache"/> backed by
/// <see cref="IMemoryCache"/>. Suitable for single-process scenarios, tests,
/// and framework E2E samples. For multi-instance deployments, use a distributed
/// adapter (e.g. <c>Compendium.Adapters.Redis</c>).
/// </summary>
/// <remarks>
/// <para><b>Tenant isolation</b>. When an <see cref="ITenantContext"/> with a non-empty
/// <see cref="ITenantContext.TenantId"/> is resolved from DI, every key is prefixed
/// with <c>{tenantId}:</c> before being stored. With no tenant context (null or empty
/// <c>TenantId</c>), keys are written verbatim. The prefix is applied transparently
/// — callers always see the original key.</para>
/// <para><b>Thread safety</b>. <see cref="IMemoryCache"/> is thread-safe, so this
/// adapter is also thread-safe.</para>
/// <para><b>Result contract</b>. Cache-miss is a successful <see cref="Result{T}"/>
/// with a <c>null</c> value, never a failure. Argument validation (null/whitespace
/// key) is permitted to throw <see cref="ArgumentException"/>.</para>
/// </remarks>
public sealed class InMemoryCache : ICache
{
private readonly IMemoryCache _memoryCache;
private readonly ITenantContext? _tenantContext;

/// <summary>
/// Initializes a new instance of the <see cref="InMemoryCache"/> class.
/// </summary>
/// <param name="memoryCache">The underlying <see cref="IMemoryCache"/>.</param>
/// <param name="tenantContext">Optional tenant context used to scope keys per tenant.</param>
public InMemoryCache(IMemoryCache memoryCache, ITenantContext? tenantContext = null)
{
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_tenantContext = tenantContext;
}

/// <inheritdoc />
public Task<Result<T?>> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);

var scopedKey = ScopeKey(key);

if (_memoryCache.TryGetValue(scopedKey, out var raw) && raw is T typed)
{
return Task.FromResult(Result.Success<T?>(typed));
}

return Task.FromResult(Result.Success<T?>(default));
Comment on lines +55 to +60
}

/// <inheritdoc />
public Task<Result> SetAsync<T>(
string key,
T value,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);

if (ttl.HasValue && ttl.Value <= TimeSpan.Zero)
{
return Task.FromResult(Result.Failure(CachingErrors.InvalidTtl(ttl.Value)));
}

var options = new MemoryCacheEntryOptions();
if (ttl.HasValue)
{
options.AbsoluteExpirationRelativeToNow = ttl.Value;
}

_memoryCache.Set(ScopeKey(key), value, options);
return Task.FromResult(Result.Success());
Comment on lines +83 to +84
}

/// <inheritdoc />
public Task<Result> RemoveAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);

_memoryCache.Remove(ScopeKey(key));
return Task.FromResult(Result.Success());
}

/// <inheritdoc />
public Task<Result<bool>> ExistsAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);

var exists = _memoryCache.TryGetValue(ScopeKey(key), out _);
return Task.FromResult(Result.Success(exists));
}
Comment on lines +88 to +103

/// <summary>
/// Applies the active tenant's prefix to <paramref name="key"/>, or returns it unchanged
/// when no tenant context is bound.
/// </summary>
private string ScopeKey(string key)
{
var tenantId = _tenantContext?.TenantId;
return string.IsNullOrEmpty(tenantId) ? key : $"{tenantId}:{key}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// -----------------------------------------------------------------------
// <copyright file="ServiceCollectionExtensions.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

using Compendium.Abstractions.Caching;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Compendium.Infrastructure.Caching;

/// <summary>
/// DI extension methods for registering the in-memory <see cref="ICache"/> adapter.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="InMemoryCache"/> as the <see cref="ICache"/> implementation,
/// backed by <see cref="IMemoryCache"/>. The underlying <see cref="IMemoryCache"/> is
/// also registered (via <see cref="MemoryCacheServiceCollectionExtensions.AddMemoryCache(IServiceCollection)"/>)
/// when no other registration exists.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration for the underlying <see cref="MemoryCacheOptions"/>.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddInMemoryCache(
this IServiceCollection services,
Action<MemoryCacheOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);

if (configure is null)
{
services.AddMemoryCache();
}
else
{
services.AddMemoryCache(configure);
}

services.TryAddSingleton<ICache, InMemoryCache>();

return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<ProjectReference Include="..\..\Abstractions\Compendium.Abstractions\Compendium.Abstractions.csproj" />
<ProjectReference Include="..\..\Abstractions\Compendium.Abstractions.Caching\Compendium.Abstractions.Caching.csproj" />
<ProjectReference Include="..\..\Core\Compendium.Core\Compendium.Core.csproj" />
<ProjectReference Include="..\..\Application\Compendium.Application\Compendium.Application.csproj" />
<ProjectReference Include="..\..\Multitenancy\Compendium.Multitenancy\Compendium.Multitenancy.csproj" />
Expand All @@ -24,6 +25,8 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="System.Text.Json" />

<!-- Resilience -->
Expand Down
Loading
Loading