Docs
Tenantry Core

Caching per tenant

A cache keyed only by what the code asks for ("orders:recent") serves one tenant's data to another. Tenantry.Caching keeps HybridCache entries per tenant, and Tenantry.AspNetCore does the same for the output cache, so the code that reads and writes the cache does not have to name the tenant.

dotnet add package Tenantry.Caching
dotnet add package Microsoft.Extensions.Caching.Hybrid   # AddHybridCache(), unless you already register a HybridCache

HybridCache

Register the cache, then IsolateCaches() in AddTenantry:

builder.Services.AddHybridCache();
builder.Services.AddTenantry<Guid>(tenant => tenant
    .ResolveFromSubdomain(o => o.BaseDomains.Add("example.com"))
    .UseStore<EfCoreTenantStore>()
    .IsolateCaches());

Code that injects HybridCache is unchanged, and its entries are now the current tenant's:

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;

public sealed class RecentOrders(HybridCache cache, AppDbContext db)
{
    public ValueTask<List<Order>> GetAsync(CancellationToken ct) =>
        cache.GetOrCreateAsync("orders:recent", async token =>
            await db.Orders.OrderByDescending(o => o.CreatedAt).Take(20).ToListAsync(token), cancellationToken: ct);
}
  • Keys and tags are the tenant's. An entry written while Acme is current is read only while Acme is current. RemoveAsync and RemoveByTagAsync reach only the current tenant's entries, and the tag * (every entry) means every entry of the current tenant.
  • The factory runs as the tenant. Microsoft's HybridCache can run a factory without the caller's async context, where the current tenant lives (it does when the call's token can be cancelled), so without Tenantry a factory that queries a tenant's DbContext could run with no tenant. IsolateCaches() makes the calling tenant current while the factory runs.
  • No tenant, no cache. A call with no current tenant throws TenantNotResolvedException, rather than reading or writing an entry no tenant owns.
  • Register the cache first. IsolateCaches() wraps the HybridCache registered before it, so call AddHybridCache() (or another library's registration of a HybridCache) before AddTenantry. When the host starts, it checks that HybridCache resolves to the cache it keys by tenant, and stops the application with an InvalidOperationException if not: for a HybridCache, keyed or not, registered after AddTenantry, which would replace it, and for AddHybridCache() called after AddTenantry. A service provider built without a host skips this check.
  • Keyed caches too. A keyed HybridCache registered before AddTenantry is kept per tenant the same way: [FromKeyedServices("reports")] HybridCache holds the current tenant's entries, and [FromKeyedServices("reports")] SharedHybridCache holds entries every tenant shares in that cache. Invalidating a tenant clears its entries from each one. A HybridCache registered for any key (KeyedService.AnyKey) stops the host, since its keys are not known in advance to clear.
  • Keys get longer. Each key carries the tenant's id, so keep keys within the cache's maximum key length (1,024 characters by default) with the id added.

Entries every tenant shares

Inject SharedHybridCache where an entry is meant for every tenant, so the constructor says so:

using Tenantry.Caching;

public sealed class ExchangeRates(SharedHybridCache cache)
{
    public ValueTask<decimal> GetAsync(string currency, CancellationToken ct) =>
        cache.GetOrCreateAsync($"fx:{currency}", _ => ValueTask.FromResult(1.17m), cancellationToken: ct);
}

It works with or without a current tenant, and its keys and tags never name a tenant's entry; * means every shared entry. Its factory always runs with no current tenant, on the thread pool, whoever calls: a query through a tenant's DbContext there fails, as it does outside a tenant, rather than caching one tenant's rows for every tenant.

IDistributedCache

IsolateCaches() leaves IDistributedCache as it is: framework components use it outside any tenant (session state, and HybridCache's own second level, whose keys already carry the tenant), so isolating it everywhere would break them. Code that uses IDistributedCache directly for a tenant's data injects ITenantDistributedCache instead, the same cache with each key under the current tenant's prefix:

using Microsoft.Extensions.Caching.Distributed;

public sealed class DraftStore(ITenantDistributedCache cache)
{
    public Task SaveAsync(string id, byte[] draft, CancellationToken ct) =>
        cache.SetAsync($"draft:{id}", draft, new DistributedCacheEntryOptions(), ct);
}

Output caching

IsolateOutputCache(), in Tenantry.AspNetCore, makes every cached response vary by the request's tenant:

builder.Services.AddOutputCache();
builder.Services.AddTenantry<Guid>(tenant => tenant
    .ResolveFromSubdomain(o => o.BaseDomains.Add("example.com"))
    .UseStore<EfCoreTenantStore>()
    .IsolateOutputCache());

var app = builder.Build();
app.UseTenantry();
app.UseOutputCache();   // after UseTenantry()

app.MapGet("/catalogue", () => "…").CacheOutput();

The output cache must come after app.UseTenantry(), so the tenant is known when it runs. A response for a request app.UseTenantry() did not handle first (the other order, or a branch of the pipeline without it) is not cached, and a warning says so once (event 1009). A response for a request without a tenant (an endpoint with AllowMissingTenant()) is cached apart from every tenant's.

Invalidating a tenant

ITenantInvalidator<TKey>.InvalidateAsync(tenantId) removes the tenant's HybridCache entries (by a tag every tenant entry carries) and evicts its cached responses, along with its cached descriptor; InvalidateAllAsync() does it for every tenant, and leaves shared entries. Call it when a tenant changes or is removed (see Tenant stores).

  • On the instance that calls it. Microsoft's HybridCache marks the tag invalid in its second level, but each instance keeps the invalidation times it has already read, so other instances of the application serve their copies until the entries expire. Where that matters, keep entries short-lived (HybridCacheEntryOptions.Expiration and LocalCacheExpiration), as with the tenant cache.
  • Output caching in memory (the default store) is per instance too; a store shared between instances shares the eviction.
  • ITenantDistributedCache entries cannot be removed by tag: they expire.

See also

On this page