Where Does Caching Belong in Clean Architecture?

Where Does Caching Belong in Clean Architecture?

7 min read··

cachingclean-architecturedotnet

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.

Request flow through a caching decorator: on a cache hit it returns the cached value, and on a miss it calls the inner repository, reads the database, stores the result with a TTL, then returns it

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.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.