Caching in Clean Architecture splits in two: the mechanism (Redis, IMemoryCache, serialization) belongs in the infrastructure layer, while the decision (what to cache, how stale, when to evict) belongs in the application layer.
The domain layer never knows caching exists.
Here are two clean ways to implement that split: a repository decorator and a pipeline behavior driven by the use case itself.
Someone on your team adds IMemoryCache to a query handler.
A week later a DbContext-shaped cache call shows up in a domain service, and a month later you cannot answer "what happens if we flush Redis" without reading half the codebase.
Caching has a way of leaking everywhere, because it feels harmless at every individual call site.
The Split: Mechanism vs. Decision
Here is the principle that resolves every "where does this go" debate about caching:
- The mechanism is infrastructure. Redis clients,
IMemoryCache, serialization, TTL bookkeeping, key formatting. All of it is a technical detail, replaceable without touching business behavior. It lives in the infrastructure layer. - The decision is application-level. What is worth caching, how stale it may be, and when it must be invalidated are things only the use case knows. Product catalog: 5 minutes stale is fine. Account balance: absolutely not. That knowledge belongs to the application layer.
And one hard rule: the domain layer never knows caching exists. No entity, value object, or domain service should reference a cache, and no invariant may depend on cached state. Caching is a performance optimization, and the domain models business rules, not performance.
So the application layer expresses caching intent through an abstraction it owns, and infrastructure provides the implementation. The abstraction is small:
public interface ICacheService
{
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
Task SetAsync<T>(
string key,
T value,
TimeSpan? expiration = null,
CancellationToken ct = default);
Task RemoveAsync(string key, CancellationToken ct = default);
}
The interface is defined in the application layer.
Infrastructure implements it with Redis, HybridCache, or an in-memory store, and the dependency arrow points inward, exactly as the dependency rule requires.
With the split established, there are two clean places to apply it.
Approach 1: The Caching Decorator
The decorator pattern is the classic answer, and it is still the cleanest when you want caching to be invisible to consumers.
Say you have a repository interface in the application layer:
public interface IProductRepository
{
Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task UpdateAsync(Product product, CancellationToken ct = default);
}
The caching decorator wraps the real implementation:
public sealed class CachedProductRepository(
IProductRepository inner,
ICacheService cache) : IProductRepository
{
private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5);
public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
string key = $"products:{id}";
Product? cached = await cache.GetAsync<Product>(key, ct);
if (cached is not null)
{
return cached;
}
Product? product = await inner.GetByIdAsync(id, ct);
if (product is not null)
{
await cache.SetAsync(key, product, Expiration, ct);
}
return product;
}
public async Task UpdateAsync(Product product, CancellationToken ct = default)
{
await inner.UpdateAsync(product, ct);
await cache.RemoveAsync($"products:{product.Id}", ct);
}
}
Notice UpdateAsync: the decorator is also the natural home for write-through invalidation, because it sees every mutation that goes through the interface.
Registration with Scrutor is one Decorate call:
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
The strengths of this approach: use case handlers do not change at all, caching is centralized per aggregate, and removing it is a one-line rollback. The weakness: the TTL decision now lives in infrastructure, one step removed from the use cases that actually know the staleness requirements. For entity-by-id caching that is usually fine, because the policy is uniform.
One caveat: the decorator caches a domain entity, which costs nothing with an in-memory store but means serialization in a distributed cache. Encapsulated entities with private setters often do not round-trip through JSON without extra serializer configuration, so verify that before pointing this at Redis.
Approach 2: The Use Case Declares Its Caching
For query results (the read side of CQRS), I prefer the use case itself to declare its caching policy. The query says what it needs; a pipeline behavior does the work.
Define a marker interface in the application layer:
public interface ICachedQuery
{
string CacheKey { get; }
TimeSpan? Expiration { get; }
}
A query opts in by implementing it:
public sealed record GetProductCatalogQuery(int Page, int PageSize)
: IRequest<ProductCatalogResponse>, ICachedQuery
{
public string CacheKey => $"catalog:page-{Page}:size-{PageSize}";
public TimeSpan? Expiration => TimeSpan.FromMinutes(5);
}
And a single pipeline behavior handles every cached query in the system:
public sealed class QueryCachingBehavior<TRequest, TResponse>(
ICacheService cache,
ILogger<QueryCachingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICachedQuery
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
TResponse? cached = await cache.GetAsync<TResponse>(request.CacheKey, ct);
if (cached is not null)
{
logger.LogDebug("Cache hit: {CacheKey}", request.CacheKey);
return cached;
}
TResponse response = await next();
await cache.SetAsync(request.CacheKey, response, request.Expiration, ct);
return response;
}
}
Registered once:
builder.Services.AddMediatR(config =>
{
config.RegisterServicesFromAssembly(typeof(ICachedQuery).Assembly);
config.AddOpenBehavior(typeof(QueryCachingBehavior<,>));
});
This is my default for query caching, for one reason: the staleness contract sits on the query definition, right where the person changing the use case will see it.
The decision is application-level, visibly, while the mechanism stays behind ICacheService in infrastructure.
Exactly the split we wanted.
Invalidation Is Application Logic Too
The part most articles skip: who evicts?
Knowing that UpdateProductCommand invalidates products:{id} and every catalog:* page is pure use-case knowledge.
So invalidation belongs in the application layer, and the cleanest trigger is the same event flow you already have.
If your aggregates raise domain events, a cache-eviction handler is a natural subscriber:
public sealed class ProductUpdatedCacheEvictionHandler(ICacheService cache)
: INotificationHandler<ProductUpdatedDomainEvent>
{
public async Task Handle(ProductUpdatedDomainEvent domainEvent, CancellationToken ct)
{
await cache.RemoveAsync($"products:{domainEvent.ProductId}", ct);
await cache.RemoveByPrefixAsync("catalog:", ct);
}
}
RemoveByPrefixAsync is an extra method you add to ICacheService for view-level eviction; the Redis implementation backs it with key scans or key tagging.
One handler owns the mapping from "product changed" to "these views are stale". When invalidation logic is scattered across command handlers, one forgotten eviction ships a stale-data bug; centralizing it per event keeps the mapping auditable. For the eviction techniques themselves (prefix removal, versioned keys, TTL fallbacks), see cache invalidation strategies.
Two guardrails to keep this honest:
- Always set a TTL, even with explicit invalidation. Expiration is your safety net when an eviction path is missed.
- On the query side, cache DTOs and read models, not entities. Responses and read models are stable, flat, and safe to serialize. Entity caching belongs in the repository decorator, centralized and paired with its invalidation, not scattered across handlers.
I cover this decision, including where caching fits among the other cross-cutting concerns, in Pragmatic Clean Architecture.
Which Approach Should You Choose?
Practical guidance:
- Entity-by-id lookups behind a repository: decorator. Uniform policy, invisible to handlers, easy rollback.
- Query results and composed read models: pipeline behavior with
ICachedQuery. Policy lives with the use case that owns the staleness requirement. - Both in one codebase: completely fine, and common. They share the same
ICacheService, so infrastructure stays singular.
Whichever you pick, run the flush test: if flushing the cache changes any business outcome (not latency, outcome), caching has leaked past infrastructure and needs to be pushed back out.
Summary
The mechanism (Redis, memory, serialization, TTLs) is infrastructure behind an application-owned ICacheService.
The decision (what to cache, how stale, when to evict) is application knowledge, expressed either as a decorator policy per repository or as a declaration on the query itself.
The domain never finds out. And when someone asks "what happens if we flush Redis", the answer should be one sentence: everything gets slower for a minute, and nothing else changes.
Frequently Asked Questions
Which layer should caching go in with Clean Architecture?
The caching implementation (Redis, IMemoryCache, serialization) belongs in the infrastructure layer. The decision about what to cache and for how long is application-level knowledge, expressed through an abstraction the application layer owns.
Should the domain layer know about caching?
No. Caching is a performance concern, not a business rule. Domain entities and domain services should never reference a cache, and no invariant should depend on cached state.
How do I add caching without changing my use case handlers?
Use the decorator pattern. A caching decorator wraps the repository or query handler interface, checks the cache before delegating, and stores the result after. The inner implementation and its consumers stay unchanged.
Where does cache invalidation logic belong?
In the application layer, because knowing which data changed and which cached views it affects is use-case knowledge. Command handlers or event handlers evict or update the relevant keys through the same cache abstraction.



