MediatR output caching with pipeline behaviors, tag-based invalidation, and optional Entity Framework auto-eviction.
- About
- Features
- Packages
- Requirements
- Installation
- Quick start
- Configuration
- Caching requests
- Invalidation
- How it works
- Examples
- Samples and benchmarks
- Changelog
- Contributing
- License
NexGen.MediatR.Extensions.Caching extends MediatR with opt-in response caching as a cross-cutting concern. Mark a request with [RequestOutputCache], and a pipeline behavior serves cached responses on hits and stores results on misses.
Invalidation is tag-based: associate tags with cached requests, then evict by tag manually or automatically when Entity Framework Core saves related entity changes. Providers include in-memory, Redis, and Garnet so the same API works for single-node and distributed / microservice scenarios.
| Feature | Description |
|---|---|
| Opt-in attribute caching | Only requests decorated with [RequestOutputCache] are cached; unmarked requests pass through unchanged. |
| MediatR pipeline behavior | Transparent get / miss / set flow via RequestOutputCacheBehavior<,> — no changes inside handlers for cache hits. |
| Multi-target frameworks | Ships net8.0, net9.0, and net10.0 in one NuGet package set. |
| In-memory provider | Built into the core package using IMemoryCache for local and development scenarios. |
| Redis provider | Distributed cache via IDistributedCache + StackExchange.Redis (NexGen.MediatR.Extensions.Caching.Redis). |
| Garnet provider | Distributed Garnet-compatible provider mirrored with Redis (NexGen.MediatR.Extensions.Caching.Garnet). |
| Tag-based invalidation | Group related cache entries with tags and evict with EvictByTagsAsync. |
| EF Core auto-evict | On SaveChanges / SaveChangesAsync, evict tags matching changed entity type names (UseMediatROutputCacheAutoEvict). |
| CQRS eviction bus | Cross-DI / split-host invalidation via in-process bus, Redis/Garnet Pub/Sub, or custom Rabbit/Kafka/MassTransit adapters. |
| Command eviction attribute | [RequestOutputCacheEvict] publishes or evicts tags after a successful command. |
| Deterministic cache keys | Key = NexGen.MediatR.Extensions:{Namespace:segments}:{TypeName}:{SHA-256(JSON)} — namespaced, Redis-tree friendly, collision-safe across namespaces. |
| Per-request expiration | expirationInSeconds on the attribute (default 300); 0 means no absolute expiration. Provider DefaultExpirationInSeconds can replace the library default when the attribute omits an explicit value. |
| Flush all | IRequestOutputCacheInvalidator.FlushAll clears the entire cache store for the provider. |
| Clear on startup | Optional ClearCacheOnStartup() during DI configuration. |
| FluentResults | Cache get/set/evict APIs return Result / Result<T> for success and failure paths. |
| ASP.NET Core DI | Integrates with IServiceCollection and standard Microsoft.Extensions.Caching abstractions. |
| Enterprise packaging | Central Package Management, SourceLink, symbol packages (.snupkg), XML docs on public APIs. |
| Package | Role |
|---|---|
NexGen.MediatR.Extensions.Caching |
Core: attribute, behavior, contracts, in-memory provider |
NexGen.MediatR.Extensions.Caching.Redis |
Redis distributed provider |
NexGen.MediatR.Extensions.Caching.Garnet |
Garnet distributed provider |
NexGen.MediatR.Extensions.Caching.EntityFramework |
EF Core ChangeTracker auto-eviction |
All four packages share the same version (lockstep releases).
- .NET 8, .NET 9, or .NET 10
- MediatR (registered in your app as usual)
- Optional: Redis/Garnet for distributed cache; EF Core for auto-evict
dotnet add package NexGen.MediatR.Extensions.Cachingdotnet add package NexGen.MediatR.Extensions.Caching.Redis
dotnet add package NexGen.MediatR.Extensions.Caching.Garnet
dotnet add package NexGen.MediatR.Extensions.Caching.EntityFrameworkOr via Package Manager Console:
Install-Package NexGen.MediatR.Extensions.Caching// Program.cs
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddMediatROutputCache(opt =>
{
opt.UseMemoryCache();
});[RequestOutputCache(tags: ["weather"], expirationInSeconds: 300)]
public sealed class WeatherForecastRequest : IRequest<IEnumerable<WeatherForecastDto>>
{
public int Limit { get; set; } = 10;
}Send the request through MediatR as usual; the first call executes the handler and caches the response. Later identical requests (same type + payload) are served from cache until expiration or eviction.
Register one cache provider via AddMediatROutputCache. Configuring more than one throws.
builder.Services.AddMediatROutputCache(opt =>
{
opt.UseMemoryCache();
});Optional provider defaults (applied when the attribute omits an explicit expirationInSeconds, i.e. uses the library constant 300):
builder.Services.AddMediatROutputCache(opt =>
{
opt.UseMemoryCache(o => o.DefaultExpirationInSeconds = 600);
});builder.Services.AddMediatROutputCache(opt =>
{
opt.UseRedisCache("localhost:6379,password=YourRedisPassword");
});Provider-specific options (InstanceName, Database, default TTL, or advanced ConfigurationOptions):
builder.Services.AddMediatROutputCache(opt =>
{
opt.UseRedisCache(o =>
{
o.ConnectionString = builder.Configuration.GetConnectionString("Redis")!;
o.InstanceName = "my-app:";
o.Database = 1;
o.DefaultExpirationInSeconds = 300;
});
});Multiple apps on one Redis: set a distinct
InstanceName(and/orDatabase) per service. CLR namespaces in response cache keys do not isolate the shared container index keys (…:Container:*). Without a prefix, apps share that metadata on the same database.
builder.Services.AddMediatROutputCache(opt =>
{
opt.UseGarnetCache("localhost:6379,password=YourGarnetPassword");
});Same nested options pattern as Redis via UseGarnetCache(Action<GarnetRequestOutputCacheOptions>). Use a distinct InstanceName / Database when multiple apps share one Garnet instance (same guidance as Redis above).
TTL precedence: an explicit
expirationInSecondson[RequestOutputCache]always wins (including0for never expire). ProviderDefaultExpirationInSecondsonly replaces the library default when the attribute uses the constructor default (300). Explicit300is indistinguishable from that default.
After a successful SaveChanges / SaveChangesAsync, the interceptor collects distinct entity CLR type names and calls EvictByTagsAsync with those names. Request tags must match (typically nameof(YourEntity)).
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString);
options.UseMediatROutputCacheAutoEvict(sp);
});When command and query run in separate DI containers (same process or separate services), the command host publishes eviction messages and the query host applies them.
Message contract: RequestOutputCacheEvictionMessage with Tags. Suggested topic for external buses: mediatr.outputcache.evict (RequestOutputCacheEvictionConstants.DefaultBusTopic).
The library does not take a dependency on your broker. Implement thin adapters:
// Command host
public sealed class MassTransitEvictionPublisher(IBus bus) : IRequestOutputCacheEvictionPublisher
{
public Task PublishAsync(RequestOutputCacheEvictionMessage message, CancellationToken ct)
=> bus.Publish(message, ct); // or send to topic mediatr.outputcache.evict
}
services.AddMediatROutputCacheEviction(opt =>
opt.UseCustomEvictionPublisher<MassTransitEvictionPublisher>());
writeDb.UseMediatROutputCacheAutoEvict(sp);
// Query host — either a library subscriber...
public sealed class MassTransitEvictionSubscriber : IRequestOutputCacheEvictionSubscriber
{
// SubscribeAsync: consume from your queue/topic and invoke the handler callback
}
services.AddMediatROutputCache(opt =>
{
opt.UseMemoryCache();
opt.UseCustomEvictionSubscriber<MassTransitEvictionSubscriber>();
});
// ...or call EvictByTagsAsync from an existing consumer:
public sealed class EvictionConsumer(IRequestOutputCacheInvalidator cache)
{
public Task Consume(RequestOutputCacheEvictionMessage message, CancellationToken ct)
=> cache.EvictByTagsAsync(message.Tags, ct);
}// Query
services.AddMediatROutputCache(opt =>
{
opt.UseMemoryCache();
opt.UseRedisEvictionBus(redisConnectionString);
});
// Command
services.AddMediatROutputCacheEviction(opt =>
opt.UseRedisEvictionBus(redisConnectionString));(UseGarnetEvictionBus mirrors the same API.)
var bus = new InProcessRequestOutputCacheEvictionBus();
queryServices.AddMediatROutputCache(opt =>
{
opt.UseMemoryCache();
opt.UseInProcessEvictionBus(bus);
});
commandServices.AddMediatROutputCacheEviction(opt =>
opt.UseInProcessEvictionBus(bus));With EF auto-evict on the command DbContext, changed entity type names are published on the bus after a successful save. Query tags must still use nameof(Entity).
For commands without EF, decorate the request with [RequestOutputCacheEvict(nameof(User))].
builder.Services.AddMediatROutputCache(opt =>
{
opt.UseMemoryCache();
opt.ClearCacheOnStartup();
});Apply [RequestOutputCache] on the request type (the class that implements IRequest<TResponse>).
Note:
TResponseshould be a reference type (class, record, or interface), consistent with typical MediatR query responses.
Important: For EF auto-evict, include
nameoffor every related entity type intags.
[RequestOutputCache(
tags: ["weather", nameof(WeatherForecastDbEntity)],
expirationInSeconds: 3600)]
public sealed class WeatherForecastRequest : IRequest<IEnumerable<WeatherForecastDto>>
{
public int Limit { get; set; } = 10;
public int Offset { get; set; } = 0;
}| Attribute parameter | Behavior |
|---|---|
tags |
Labels for grouping and invalidation |
expirationInSeconds |
Absolute lifetime in seconds. Default: 300. Use 0 for no absolute expiration |
Inject IRequestOutputCacheInvalidator or IRequestOutputCache<TRequest, TResponse>:
public sealed class WeatherForecastUpdateHandler(
IRequestOutputCacheInvalidator cache)
: IRequestHandler<WeatherForecastUpdateRequest, string>
{
public async Task<string> Handle(
WeatherForecastUpdateRequest request,
CancellationToken cancellationToken)
{
await cache.EvictByTagsAsync(["weather"], cancellationToken);
return "Evicted";
}
}await cache.FlushAll(cancellationToken);When UseMediatROutputCacheAutoEvict is configured, you usually do not need manual eviction for data that changes through that DbContext. If an eviction publisher is registered (CQRS bus), the interceptor publishes tags instead of calling the local invalidator.
[RequestOutputCacheEvict(nameof(User))]
public sealed record CreateUserCommand(string Name) : IRequest<Unit>;[RequestOutputCache] → RequestOutputCacheBehavior
│
├─ hit → return cached TResponse
└─ miss → handler → store → return TResponse
Key: NexGen.MediatR.Extensions:{Namespace:with:colons}:{TypeName}:{sha256(json(request))}
Index: tag → request types → cache keys (via IRequestOutputCacheContainer)
Evict: EvictByTagsAsync(tags)
or EF ChangeTracker → entity type Name as tags
or eviction bus (in-process / Redis / Garnet / custom) → query host EvictByTagsAsync
Distributed providers (Redis / Garnet) also keep request→response type metadata so payloads can be deserialized correctly across nodes.
[RequestOutputCache(tags: ["weather"], expirationInSeconds: 300)]
public sealed class WeatherForecastRequest : IRequest<IEnumerable<WeatherForecastDto>>
{
public int Limit { get; set; } = 10;
}
public sealed class WeatherForecastRequestHandler
: IRequestHandler<WeatherForecastRequest, IEnumerable<WeatherForecastDto>>
{
public async Task<IEnumerable<WeatherForecastDto>> Handle(
WeatherForecastRequest request,
CancellationToken cancellationToken)
{
await Task.Delay(2000, cancellationToken); // simulate work
// ... build and return forecast list
return [];
}
}public sealed class WeatherForecastUpdateRequest : IRequest<string>;
public sealed class WeatherForecastUpdateRequestHandler(
IRequestOutputCacheInvalidator cache)
: IRequestHandler<WeatherForecastUpdateRequest, string>
{
public async Task<string> Handle(
WeatherForecastUpdateRequest request,
CancellationToken cancellationToken)
{
await cache.EvictByTagsAsync(["weather"], cancellationToken);
return "Evicted!";
}
}| Area | Location |
|---|---|
| Integration / consumer sample | tests/NexGen.MediatR.Extensions.Caching.IntegrationTest (includes docker-compose.yml for Redis/SQL) |
| Unit tests | tests/NexGen.MediatR.Extensions.Caching.UnitTest |
| Benchmarks | benchmarks/NexGen.MediatR.Extensions.Caching.Benchmark |
Larger or more complex responses use more memory with the in-memory provider. Prefer Redis or Garnet for multi-instance and production workloads.
Release notes are maintained in CHANGELOG.md (Keep a Changelog format). Check that file for what changed in each version.
Contributions are welcome through GitHub Issues and Pull Requests.
Please read CONTRIBUTING.md for the full contribution guide (development setup, coding standards, tests, and PR expectations) before opening an issue or PR.
This project is licensed under the MIT License.

