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 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 spreads this way because each call site looks harmless on its own.
The Split: Mechanism vs. Decision
Split caching into two parts:
- The mechanism is infrastructure. Redis clients,
IMemoryCache, serialization, TTL bookkeeping, and key formatting are technical details you can replace without touching business behavior. - 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. A product catalog can be 5 minutes stale; an account balance cannot.
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.
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 invalidation on write, 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>();
With 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 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. An in-memory store hands every caller the same instance, so a change one request makes to it leaks into other requests. A distributed cache means serialization instead. 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, because the staleness contract is on the query definition, where the person changing the use case will see it.
The decision is visible in the application layer, and the mechanism stays behind ICacheService in infrastructure.
Invalidation Is Application Logic Too
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.
Add two guardrails:
- Always set a TTL, even with explicit invalidation. Expiration limits how long stale data lasts when an eviction path is missed.
- On the query side, cache DTOs and read models, not entities. Responses and read models are flat and safe to serialize. Entity caching belongs in the repository decorator, centralized and paired with its invalidation.
I cover this decision, including where caching fits among the other cross-cutting concerns, in Pragmatic Clean Architecture.
Which Approach Should You Choose?
- Entity-by-id lookups behind a repository: decorator. The policy is uniform, handlers never see it, and rollback is one line.
- Query results and composed read models: pipeline behavior with
ICachedQuery. The policy is declared by the use case that owns the staleness requirement. - Both in one codebase: fine. They share the same
ICacheService, so there is one cache implementation in infrastructure.
Whichever you pick, run the flush test: if flushing the cache changes any business outcome (slower responses do not count), 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 layer never references the cache. If someone asks "what happens if we flush Redis", the answer is that requests get slower until the cache refills, 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.



